@k8ordo/i18n

メッセージ

文言は message() で 1 つずつ宣言する関数です。全ロケールの文をその場に並べ、呼ばれた場所のロケールで文字列を返します。キーの一覧も辞書オブジェクトも無く、欠けの検査は TypeScript が、ブラウザに届ける分の選別はバンドラが受け持ちます。

文だけの文言

ロケールをキーに、そのロケールの文を値に書きます。戻り値は引数を取らない関数(Message)で、呼ぶたびにその時点のロケールを読みます。

// src/messages/nav.ts
import { message } from '@k8ordo/i18n';

export const home = message({ ja: 'ホーム', en: 'Home' });

export const search = message({ ja: '検索', en: 'Search' });

何もロケールを指名していないとき(リクエストの外で呼んだサーバー、ロケールの区間が無い URL)は、既定のロケールの文を返します。集合がまだ登録されていない環境では、最初に書いた文を返します。

値を取る文言

値を埋め込む文言は、全ロケールを同じ引数の関数で書きます。どれか 1 つに引数の型を注釈すれば、残りはその型に縛られます。

// src/messages/cart.ts
import { message } from '@k8ordo/i18n';

export const items = message({
  ja: (count: number) => `${String(count)} 件`,
  en: (count) =>
    `${String(count)} ${new Intl.PluralRules('en').select(count) === 'one' ? 'item' : 'items'}`,
});

export const updated = message({
  ja: (date: Date) =>
    `${new Intl.DateTimeFormat('ja', { dateStyle: 'long', timeZone: 'UTC' }).format(date)} 更新`,
  en: (date) =>
    `Updated ${new Intl.DateTimeFormat('en', { dateStyle: 'long', timeZone: 'UTC' }).format(date)}`,
});

覚える記法はありません。埋め込みはテンプレートリテラル、複数形は Intl.PluralRules、日付と数値は Intl.DateTimeFormatIntl.NumberFormat です。各ロケールの関数はどのロケールの文かが決まっているので、Intl に渡すタグもその場に直接書けます。

文言が普通の関数なので、呼ぶ側の引数は TypeScript がそのまま検査し、文を解釈するパーサーをバンドルに積む必要もありません。

  • どのロケールにも注釈を書かないと、引数の型は unknown と推論され、どんな値でも通ってしまいます。少なくとも 1 つには注釈を書いてください。
  • 1 つの文言の中で文と関数は混ぜられないので、値を使わないロケールも関数で書きます。そこでも引数を宣言してください(en: (_count) => 'Items')。引数の無い関数は、どのロケールに置いたかによって、文言の引数が空と推論されてコンパイルエラーになるか、検査をすり抜けます。
  • 日付の書式化には timeZone を明示してください。指定しないと、サーバーは自分のタイムゾーンで、ブラウザは訪問者のタイムゾーンで書式化するので、Client Component が描く文が HTML とハイドレーションで食い違うことがあります。

MessageVariants

message() の戻り値は Message<Args> です。文だけの文言は Message() => string)、値を取る文言は Message<[count: number]> のように引数の組を持ちます。

テキストを受け取るコンポーネントやデータは、文字列ではなく Message を持ち、描くコンポーネントがそれを呼びます。文字列にするのは描く側だけなので、データを組み立てる側はロケールを知る必要がありません。このサイトのナビゲーションのデータも label: Message を持っています。

// src/components/nav-list.tsx
import type { Message } from '@k8ordo/i18n';

export type NavItem = { href: string; label: Message };

export function NavList({ items }: { items: readonly NavItem[] }) {
  return (
    <ul>
      {items.map((item) => (
        <li key={item.href}>
          <a href={item.href}>{item.label()}</a>
        </li>
      ))}
    </ul>
  );
}

Variants<V> は「Register に載せたロケールごとに 1 つの値」を表す型(Readonly<Record<RegisteredLocale, V>>)です。文言以外でも、ロケールごとの値が漏れなく揃っていてほしいもの、たとえば言語切替に並べる言語名に使えます。RegisteredLocale は、Register にロケールを載せた後はロケールの和集合、載せる前は string です。

// src/locale-names.ts
import type { Variants } from '@k8ordo/i18n';

export const LOCALE_NAMES: Variants<string> = {
  ja: '日本語',
  en: 'English',
};

コンパイラが検査すること

Register にロケールを載せた後は、次の書き方はどれもコンパイルが通りません。

書き方理由
message({ ja: 'ホーム' })ロケールが欠けている
message({ ja: 'ホーム', en: 'Home', fr: 'Accueil' })集合に無いロケールがある
message({ ja: '件数', en: () => 'Items' })文と関数が混ざっている
message({ ja: (count: number) => …, en: (count: string) => … })ロケールごとに引数の型が違う
nav.home('x')文だけの文言に引数を渡している
cart.items('3')引数の型が違う

ロケールの欠けや混在は、message( の呼び出しに「No overload matches this call」として報告されます。TypeScript は最後のオーバーロード(関数の文言)に対する詳細を添えるので、文だけの文言でロケールが欠けていても、詳細は「string を関数に代入できない」という文面になります。直すべきは欠けたキーです。

Register にロケールを載せる前は RegisteredLocalestring なので、ロケールの欠けは検査されません。

型を通り抜けた欠け(JavaScript からの呼び出しや as)は、宣言ではなく読まれた時点で TypeError になります(message: no text for "en" in ["ja"])。宣言の時点で検査して throw すると、宣言がバンドラに副作用と見なされ、使われない文言を落とせなくなるからです。

描く場所で呼ぶ

文言は呼ばれた瞬間のロケールを読みます。モジュールの先頭で呼ぶと、文字列はモジュールが最初に評価されたときに 1 回だけ作られ、どのページでもそれが使われ続けます。サーバーでは多くの場合リクエストの外なので既定のロケールの文字列になり、リクエストの途中で初めて読み込まれたモジュールなら、そのリクエストのロケールの文字列になります。ブラウザでは、読み込んだ時点の URL のロケールのまま、言語を切り替えた後も変わりません。

モジュールの先頭で呼んだ場合

// src/data/menu.ts
import * as m from '../messages';

export const MENU_LABELS = [m.nav.home(), m.nav.search()];

Message のまま持ち、描くときに呼ぶ

// src/data/menu.ts
import type { Message } from '@k8ordo/i18n';

import * as m from '../messages';

export const MENU_LABELS: readonly Message[] = [m.nav.home, m.nav.search];

同じことは、スキーマのエラー文言にも getLocale() にも当てはまります。値を先に作らず、関数を渡しておき、使う場所で呼んでください。

@k8ordo/form のエラー文言での例を読む

文言の置き場所

どこに置いてもかまいません。読みやすいのは、領域ごとに 1 ファイルにまとめ、索引のモジュールから名前空間として再 export する形です。呼ぶ側は m.nav.home() と読めます。

// src/messages/index.ts
export * as nav from './nav';
export * as cart from './cart';
export * as 'static' from './static';
// src/components/header.tsx
import * as m from '../messages';

export function Header() {
  return (
    <header>
      <a href="/">{m.nav.home()}</a>
      <span>{m.static.title()}</span>
    </header>
  );
}

static のような予約語を名前空間にしたいときは、文字列の export 名(ES2022)で出します。使う側は m.static.title() のようにプロパティとして読めます。

1 つのコンポーネントだけが使う文言は、そのコンポーネントの隣に置いてもかまいません。関係の深い文言をオブジェクトにまとめる(export const dialog = { title: message(…), close: message(…) })こともできます。その場合、バンドラはオブジェクトを丸ごと残します。

このサイトは、src/messages/ に領域ごとのファイルを置き(nav.tshome.ts、ガイドのページごとの i18n-messages.ts など)、index.ts がすべてを名前空間として再 export しています。3 階層になるキーは、m.components.button.description のようにグループのオブジェクトにしています。

Server Component の境界を越える

文言は関数で、関数は Server Component から Client Component へ props として渡せません。渡そうとすると、React が関数をシリアライズできず、描画が失敗します。

呼んだ結果の文字列を渡す

Server Component が呼び、文字列を props で渡します。文字列はその描画のロケールで作られ、RSC ペイロードに入るのはそのロケールの文だけです。文言そのものはクライアントのバンドルに入りません。

// src/routes/[locale]/share/page.tsx
import * as m from '../../../messages';
import { CopyLink } from './_parts/copy-link';

export default function SharePage() {
  return <CopyLink copied={m.share.copied()} label={m.share.copyLink()} />;
}
// src/routes/[locale]/share/_parts/copy-link.tsx
'use client';

import { useState } from 'react';

export function CopyLink({ copied, label }: { copied: string; label: string }) {
  const [done, setDone] = useState(false);

  return (
    <button
      onClick={() => {
        void navigator.clipboard.writeText(location.href).then(() => {
          setDone(true);
        });
      }}
      type="button"
    >
      {done ? copied : label}
    </button>
  );
}

Client Component 自身が import して呼ぶ

文字列を何段も受け渡す必要はありません。Client Component も文言を import して自分で呼べます。その代わり、その文言は全ロケール分がクライアントのバンドルに入ります。ブラウザで文が変わる必要がある(入力に応じて変わる、ハイドレーションの後に呼ぶ)なら import し、サーバーで決まる文なら文字列で渡す、が目安です。

// src/routes/[locale]/share/_parts/copy-link.tsx
'use client';

import { useState } from 'react';

import * as m from '../../../../messages';

export function CopyLink() {
  const [done, setDone] = useState(false);

  return (
    <button
      onClick={() => {
        void navigator.clipboard.writeText(location.href).then(() => {
          setDone(true);
        });
      }}
      type="button"
    >
      {done ? m.share.copied() : m.share.copyLink()}
    </button>
  );
}

ディレクティブの無いコンポーネント

ディレクティブの無いコンポーネントは共有コンポーネントです。Server Component から描かれればサーバーで、Client Component から描かれればブラウザで動き、どちらでも Message の props は境界を越えません。props と文言を読むだけのコンポーネント(ページタイトル、ランディングの枠)は、この理由で 'use client' を付けずにおくのが適しています。このサイトの PageTitle と、このガイドのページを組む DocPage もそう書かれています。

ブラウザに届くもの

message() は宣言の時点で何もしません。関数を返すだけで、グローバルな状態にも触れません。だからバンドラは、どこからも参照されない文言を不要なコードとして落とせます。

  • クライアントのバンドルに入るのは、'use client' のモジュール(とそこから import されるモジュール)が名前で参照した文言だけで、その文言は全ロケール分です。
  • Server Component が描いた文は、どのモジュールで宣言されていても、クライアントの負担になりません。
  • 文言を 1 つの辞書オブジェクトにしないのはこのためです。辞書は丸ごと残るか、丸ごと消えるかしかありません。

ビルドの後、Server Component でしか描かない文を dist/client/assets/ の JavaScript から検索すれば、クライアントに届いていないことを確かめられます。