組み合わせ
@k8ordo/i18n はほかの k8ordo パッケージを import しません。組み合わせる行はアプリケーションが書き、どれも数行で済みます。このページでは @k8ordo/ui・@k8ordo/form・@k8ordo/router・@k8ordo/static・@k8ordo/server との結び方と、テストの書き方を扱います。
@k8ordo/ui
コンポーネントが自前で描く文言(閉じるボタンのラベル、必須の表示、読み込み中の読み上げ)は、@k8ordo/ui 自身の辞書から引かれます。辞書は UIProvider の messages で渡し、@k8ordo/ui/i18n の dictionaries から描画中のロケールで選びます。
// src/routes/[locale]/layout.tsx
import { UIProvider } from '@k8ordo/ui';
import { dictionaries } from '@k8ordo/ui/i18n';
import type { ReactNode } from 'react';
import { locales } from '../../i18n';
export const { paramsSchema } = locales;
export default function LocaleLayout({ children }: { children: ReactNode }) {
return (
<UIProvider messages={dictionaries[locales.getLocale()]}>
{children}
</UIProvider>
);
}- 辞書は文字列だけのオブジェクトなので、Server Component のレイアウトから Client Component の
UIProviderへそのまま渡せます。RSC ペイロードに入るのは選んだ 1 つの辞書だけです。 not-found.tsxの下では catch-all の param を検証するものが無いので、そこでのgetLocale()は URL に関係なく既定のロケールです。/en/…の 404 にも既定のロケールの辞書が渡ります。404 を訪問者のロケールで出すには、このサイトのLocaleShellのように、Client Component の中でlocales.delocalize(usePathname()).localeから辞書を選びます。dictionariesが持つのはjaとenです。集合にそれ以外のロケール(frやen-US)があるときは、@k8ordo/ui/i18nのMessages型を注釈した辞書を自分で用意し、ロケールから辞書への対応をVariants<Messages>で書くと、漏れが型で分かります。- コンポーネントの props に渡すテキストは文字列です。
<Button>{m.form.submit()}</Button>のように、文言を呼んだ結果を渡します。
@k8ordo/form
@k8ordo/form が表示する文言は zod のエラー文言です。formFields は導出の時点でスキーマに値を通して文字列にし、parseForm は検証の時点で作ります。どちらもその瞬間のロケールで作られるので、押さえる点は 3 つです。
- zod には文字列ではなく文言の関数を
errorとして渡します。zod が issue を報告するときに呼ぶので、その時点のロケールの文になります。min(1, m.talk.titleRequired())のように宣言で呼ぶと、その時点のロケール(多くの場合は既定のロケール)の文字列で固定されます。 formFields(schema)は、モジュールの先頭ではなくページの描画の中で呼びます。モジュールの先頭は 1 回しか走らず、多くの場合リクエストの外なので、どのロケールのページにも同じ文言(たいていは既定のロケールのもの)が渡ります。- Server Action は
[locale]の描画の外で走るので、ロケールを指名するものがありません。ページでbindしたロケールを受け取り、locales.runの中でparseFormを呼びます。クライアントから戻ってくる値なので、locales.isで確かめてから使います。
// src/messages/talk.ts
import { message } from '@k8ordo/i18n';
export const titleRequired = message({
ja: 'タイトルを入力してください',
en: 'Enter a title',
});
export const titleTooLong = message({
ja: (max: number) => `${String(max)} 文字以内で入力してください`,
en: (max) => `Use at most ${String(max)} characters`,
});// src/routes/[locale]/talks/new/_parts/schema.ts
import * as z from 'zod';
import * as m from '../../../../../messages';
export const talkSchema = z.object({
title: z
.string()
.min(1, { error: m.talk.titleRequired })
.max(120, { error: () => m.talk.titleTooLong(120) }),
});// src/routes/[locale]/talks/new/page.tsx
import { formFields } from '@k8ordo/form/server';
import { locales } from '../../../../i18n';
import { createTalk } from './_parts/actions';
import { talkSchema } from './_parts/schema';
import { TalkForm } from './_parts/talk-form';
export default function NewTalkPage() {
return (
<TalkForm
action={createTalk.bind(null, locales.getLocale())}
fields={formFields(talkSchema)}
/>
);
}// src/routes/[locale]/talks/new/_parts/actions.ts
'use server';
import { parseForm } from '@k8ordo/form/server';
import type { FormState } from '@k8ordo/form/server';
import { saveTalk } from '../../../../../db/talks';
import { locales } from '../../../../../i18n';
import { talkSchema } from './schema';
export async function createTalk(
locale: string,
_previous: FormState,
formData: FormData,
): Promise<FormState> {
const parsed = locales.run(
locales.is(locale) ? locale : locales.default,
() => parseForm(talkSchema, formData),
);
if (parsed.success) await saveTalk(parsed.data);
return parsed.state;
}Server Action は @k8ordo/server にしかありません。@k8ordo/static のサイトに当てはまるのは、1 つ目と 2 つ目です。
@k8ordo/router
ロケールは、すべてのパターンの :locale param です。ルーターに i18n のための設定はありません。
- リンクとナビゲーションは、
bindParams(() => ({ locale: locales.getLocale() }))が返すhref/navigateToで書きます。 - 言語切替は、
usePathname()で今の pathname を読み、delocalizeとlocalizeで別のロケールの URL を作ります。 useMatch('/:locale/docs/*')のように、ロケールを含むパターンのまま区画を判定できます。:localeは区間の値を問わず一致します。
@k8ordo/static
静的化で i18n に関わるのは 2 点です。
framework({ paths: locales.paths })で、ロケールの区間をロケールの数だけ展開します。ほかの param があれば、同じ関数の中で展開します。404.htmlは番兵の区間で 1 回だけ描かれるので、not-found.tsxの文言は Client Component で描き、ハイドレーションで訪問者の URL のロケールに合わせます。このサイトは、<html lang>もハイドレーションの後に effect でdocument.documentElement.langを直しています。
@k8ordo/server
リクエストごとに描くので、paramsSchema と文言の働きは静的化と同じです。違うのは、ページがリクエストを読めることと、Server Action があることです。
- ページは
requestを受け取るので、/でAccept-Languageから交渉できます。答えを HTML に入れておけば、JavaScript の無い訪問者にも行き先のリンクが見えます。 - ページ自身はリダイレクトで応答できません。
redirect()は Server Action のためのもので、redirect.tsの行き先は params から作られ、リクエストのヘッダーでは変わりません。サーバーで307を返したいときは、アプリケーションの外で行います。serveの前に置いたプロキシか、ビルドされたハンドラ(dist/rsc/index.js)を包む自前のホストが、ハンドラを呼ぶ前に/だけを答えます。そうしないなら、移動はクライアントで行います。 - Server Action の中で文言を使うときは、上の
@k8ordo/formの例のようにlocales.runで囲みます。
// src/routes/page.tsx
import { parseAcceptLanguage } from '@k8ordo/i18n';
import type { PageProps } from '@k8ordo/router';
import { locales } from '../i18n';
import { href } from '../links';
import { RedirectTo } from './_parts/redirect-to';
export default function RootPage({ request }: PageProps<'/'>) {
const locale = locales.negotiate(
parseAcceptLanguage(request.headers.get('accept-language')),
);
return <RedirectTo to={href('/:locale', { locale })} />;
}// src/routes/_parts/redirect-to.tsx
'use client';
import { useEffect } from 'react';
export function RedirectTo({ to }: { to: string }) {
useEffect(() => {
navigation.navigate(to, { history: 'replace' });
}, [to]);
return <a href={to}>{to}</a>;
}テスト
テストでロケールを決める方法は、テストがサーバーとブラウザのどちらの経路で走るかで変わります。
Node で走るテスト
何も指名しなければ、文言は既定のロケールで返ります。別のロケールは locales.run の中で呼びます。async の関数を渡せば await をまたいで保たれ、並行に走る run 同士は混ざりません。
// src/messages/messages.test.ts
import { describe, expect, expectTypeOf, it } from 'vitest';
import { locales } from '../i18n';
import * as cart from './cart';
import * as nav from './nav';
describe('messages', () => {
it('renders in the default locale unless run names another', () => {
expect(nav.home()).toBe('ホーム');
expect(locales.run('en', () => nav.home())).toBe('Home');
});
it('keeps the locale across awaits', async () => {
const text = await locales.run('en', async () => {
await Promise.resolve();
return nav.home();
});
expect(text).toBe('Home');
});
it('types the arguments of a message', () => {
expectTypeOf(cart.items).parameters.toEqualTypeOf<[count: number]>();
});
});paramsSchema で検証すると、受理したロケールが、それを呼んだ非同期の流れの残り全体に設定されます。スキーマを検証するテストは run で囲み、後に続くテストへ漏れないようにします。
// src/i18n.test.ts
import { expect, it } from 'vitest';
import { locales } from './i18n';
it('accepts only the listed locales', () => {
const { validate } = locales.paramsSchema['~standard'];
expect(locales.run('ja', () => validate({ locale: 'en' }))).toStrictEqual({
value: { locale: 'en' },
});
expect(validate({ locale: 'fr' })).toMatchObject({
issues: [{ path: ['locale'] }],
});
});ブラウザで走るテスト
Vitest のブラウザモードのような本物のブラウザでは、URL がロケールです。history.replaceState で pathname を変え、終わったら元に戻します。run は throw します。
// src/messages/messages.browser.test.ts
import { afterEach, expect, it } from 'vitest';
import { locales } from '../i18n';
import * as nav from './nav';
const initial = location.pathname;
afterEach(() => {
history.replaceState(null, '', initial);
});
it('renders in the locale the URL spells', () => {
history.replaceState(null, '', '/en/cart');
expect(locales.getLocale()).toBe('en');
expect(nav.home()).toBe('Home');
});jsdom や happy-dom のように document を定義する環境も、このパッケージにとってはブラウザです。run は throw し、ロケールは location.pathname から読まれます。
集合と型
- テストの中で別の集合を
defineLocalesすると、それ以降の文言はその集合を読みます。アプリケーションのlocalesを import して使うか、別の集合を作るテストがあるなら、beforeEachでアプリケーションの集合を定義し直します。 - 型の保証は、型のテストで固定できます。
expectTypeOf(cart.items).parameters.toEqualTypeOf<[count: number]>()で引数を、ロケールを欠いた宣言に付けた// @ts-expect-errorで、欠けがコンパイルエラーになることを確かめます。