@k8ordo/i18n

組み合わせ

@k8ordo/i18n はほかの k8ordo パッケージを import しません。組み合わせる行はアプリケーションが書き、どれも数行で済みます。このページでは @k8ordo/ui@k8ordo/form@k8ordo/router@k8ordo/static@k8ordo/server との結び方と、テストの書き方を扱います。

@k8ordo/ui

コンポーネントが自前で描く文言(閉じるボタンのラベル、必須の表示、読み込み中の読み上げ)は、@k8ordo/ui 自身の辞書から引かれます。辞書は UIProvidermessages で渡し、@k8ordo/ui/i18ndictionaries から描画中のロケールで選びます。

// 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 が持つのは jaen です。集合にそれ以外のロケール(fren-US)があるときは、@k8ordo/ui/i18nMessages 型を注釈した辞書を自分で用意し、ロケールから辞書への対応を Variants<Messages> で書くと、漏れが型で分かります。
  • コンポーネントの props に渡すテキストは文字列です。<Button>{m.form.submit()}</Button> のように、文言を呼んだ結果を渡します。

@k8ordo/ui の文言辞書のページを読む

@k8ordo/form

@k8ordo/form が表示する文言は zod のエラー文言です。formFields は導出の時点でスキーマに値を通して文字列にし、parseForm は検証の時点で作ります。どちらもその瞬間のロケールで作られるので、押さえる点は 3 つです。

  1. zod には文字列ではなく文言の関数を error として渡します。zod が issue を報告するときに呼ぶので、その時点のロケールの文になります。min(1, m.talk.titleRequired()) のように宣言で呼ぶと、その時点のロケール(多くの場合は既定のロケール)の文字列で固定されます。
  2. formFields(schema) は、モジュールの先頭ではなくページの描画の中で呼びます。モジュールの先頭は 1 回しか走らず、多くの場合リクエストの外なので、どのロケールのページにも同じ文言(たいていは既定のロケールのもの)が渡ります。
  3. 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/form のガイドを読む

@k8ordo/router

ロケールは、すべてのパターンの :locale param です。ルーターに i18n のための設定はありません。

  • リンクとナビゲーションは、bindParams(() => ({ locale: locales.getLocale() })) が返す href / navigateTo で書きます。
  • 言語切替は、usePathname() で今の pathname を読み、delocalizelocalize で別のロケールの 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>;
}

@k8ordo/server のハンドラの動かし方を読む

テスト

テストでロケールを決める方法は、テストがサーバーとブラウザのどちらの経路で走るかで変わります。

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 で、欠けがコンパイルエラーになることを確かめます。