@k8ordo/i18n

Get Started

@k8ordo/i18n はアプリケーションのロケール軸を持つパッケージです。このページでは、ロケール集合を 1 か所で定義して [locale] 区間に結び、最初の文言を Server Component と Client Component の両方で描くまでを順に進めます。

担当する範囲

どのロケールがあり、どれが既定か。この一覧に依存するものを、defineLocales が返す 1 つの値から導きます。

  • ロケール集合と既定のロケール。所属判定と BCP 47 の検査も含みます。
  • URL の先頭区間(/ja/…)。区間を付け外しする localize / delocalize と、[locale] ルートの paramsSchema を出します。
  • navigator.languagesAccept-Language からの交渉。
  • 描画中のロケール。サーバーではリクエストが受理した区間、ブラウザでは URL です。
  • 文言。message() で 1 つずつ宣言する関数で、呼ばれた場所のロケールで文字列を返します。

持たないもの

  • pathname。ロケール区間より後ろは @k8ordo/router のものです。
  • 文言の文法。プレースホルダ記法も ICU もありません。値の埋め込みはテンプレートリテラル、複数形は Intl.PluralRules、日付と数値は Intl の書式化で書きます。
  • 読み込み。文言は普通の export なので、どの文言がブラウザに届くかは、各 Client Component が何を import したかからバンドラが決めます。ローダーも名前空間の一覧もありません。

ロケールを URL に置くので、ブラウザ側に同期すべき状態がありません。言語を変えることは、同じ pathname を別の区間の下へ移動することです。文言を関数にするので、引数は TypeScript が検査し、呼ばれない文言はバンドラが落とします。

インストール

ランタイムの依存はありません。React もスキーマライブラリも import しないので、peer dependency は同梱の型定義を読む TypeScript だけです。

npm install @k8ordo/i18n
パッケージバージョン用途
typescript>= 7.0.2同梱の型定義(省略可)

サーバーでは、描画中のロケールを node:async_hooksAsyncLocalStorage に載せます。import ではなく process.getBuiltinModule で取り出すので、同じビルドがブラウザでもそのまま動きます(フレームワークの 2 つのモードは Node 24 以降を求めます)。process.getBuiltinModule の無いランタイムではロケールを描画に結び付けられないので、paramsSchema の受理も run も throw します。

ロケール集合を 1 か所で定義する

ロケールの一覧を書くのはこのモジュールだけです。[locale] のスキーマ、静的化のパス展開、言語切替、/ の振り分け、全文言の型は、どれもここから読みます。

// src/i18n.ts
import { defineLocales } from '@k8ordo/i18n';
import type { LocaleOf } from '@k8ordo/i18n';

export const locales = defineLocales(['ja', 'en']);

declare module '@k8ordo/i18n' {
  interface Register {
    locale: LocaleOf<typeof locales>;
  }
}

先頭のロケールが既定値です。別のロケールを既定にするときは defineLocales(['en', 'ja'], { default: 'ja' }) と書きます。既定値は、交渉で何も一致しなかったときと、何もロケールを指名していないときに使われます。

Register にロケールを載せるのは 1 回だけです。載せた後は、すべての message() がこのロケールの和集合に照らして検査されます。Register はマージされるための型なので、type ではなく interface で書きます。

ロケール集合が返すものと、交渉の規則を読む

[locale] 区間に結ぶ

すべてのページを src/routes/[locale]/ の下に置き、その区間のレイアウトから集合の paramsSchema を export します。ルートの paramsSchema を走らせるのは @k8ordo/static@k8ordo/server です。スキーマライブラリは要りません。集合自身が Standard Schema の形でスキーマを出します。

// src/routes/[locale]/layout.tsx
import type { ReactNode } from 'react';

import { locales } from '../../i18n';

export const { paramsSchema } = locales;

export default function LocaleLayout({ children }: { children: ReactNode }) {
  return children;
}
  • 一覧に無いロケールは受理されません。/fr/… はこのパターンが答えない pathname になり、最後は not-found.tsx が本物の 404 で答えます。
  • 受理したロケールは、受理したページの描画の間ずっと現在のロケールになり、ほかのページや 404 には届きません。Server Component も、HTML を作るためにサーバーで走る Client Component も、同じロケールで文言を読みます。
  • このファイルに 'use client' を付けてはいけません。Client モジュールから export した値は、スキーマとしてではなく client reference としてハンドラに届くからです。フックを使う枠が必要なら、_parts/ の Client Component に分けてレイアウトから描きます。

サーバーとブラウザでロケールがどこから来るかを読む

最初の文言

文言は 1 つずつ export します。値を取る文言は、そのロケールの文を返す関数です。

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

export const title = message({ ja: 'ようこそ', en: 'Welcome' });

export const nameLabel = message({ ja: '名前', en: 'Name' });

export const greeting = message({
  ja: (name: string) => `こんにちは、${name}さん`,
  en: (name) => `Hello, ${name}`,
});

引数の型は ja に注釈した関数から決まり、en もその型に縛られます。どちらかのロケールを書き忘れると、宣言の時点でコンパイルが通りません。

Server Component から描く

呼ぶだけです。サーバーでは、[locale] のスキーマがこのリクエストで受理したロケールの文が返ります。

// src/routes/[locale]/page.tsx
import * as home from '../../messages/home';
import { Greeting } from './_parts/greeting';

export default function HomePage() {
  return (
    <main>
      <h1>{home.title()}</h1>
      <Greeting />
    </main>
  );
}

Client Component から描く

書き方は同じです。Provider も hook もありません。ブラウザでは URL の先頭区間がロケールなので、/en/… を開いていれば英語の文が返ります。

// src/routes/[locale]/_parts/greeting.tsx
'use client';

import { useState } from 'react';

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

export function Greeting() {
  const [name, setName] = useState('k8o');

  return (
    <div>
      <label>
        {home.nameLabel()}
        <input
          onChange={(event) => {
            setName(event.currentTarget.value);
          }}
          value={name}
        />
      </label>
      <p>{home.greeting(name)}</p>
    </div>
  );
}

Server Component から Client Component へ文言を props で渡すときは、呼んだ結果の文字列を渡します。関数は Server Component の境界を越えられません。

境界の越え方と、バンドルに残るものを読む

保証されること

  • 一覧に無いロケールはページに届きません。スキーマが拒みます。
  • Register にロケールを載せた後は、ロケールが欠けた文言はコンパイルが通りません。JavaScript から呼んだ場合や as で型を通した場合は、読まれた時点で欠けたロケールと存在するロケールを示して throw し、undefined を返すことはありません。
  • 値を取る文言の引数は、関数の型で検査されます。
  • [locale] の下のページは、URL が示すロケールで描かれます。サーバーではスキーマが受理したロケール、ブラウザでは URL そのものを読みます。

次に読む