@k8ordo/i18n

URL とロケール

ロケールは URL の先頭区間にあります。サーバーはリクエストが受理した区間から、ブラウザは今の URL から読むので、ロケールを同期させる状態はどこにもありません。このページでは、その両側の仕組みと、言語切替・/ の振り分け・<html lang>・静的化・型付きリンクの書き方を扱います。

ロケールの出どころ

文言と getLocale() は、呼ばれた環境に応じて次のところからロケールを読みます。

環境ロケール何も指名していないとき
サーバー(Server Component と、HTML を作るためにサーバーで走る Client Component)paramsSchema がこのリクエストで受理したロケール。run(locale, fn) の中では locale既定のロケール
ブラウザlocation.pathname の先頭区間。呼ばれるたびに読みます。既定のロケール(集合に無い区間も、指名なしと同じ)

サーバーではロケールを AsyncLocalStorage に載せるので、同時に走る複数のリクエストの描画が混ざりません。RSC の環境と、Client Component をサーバーで描く SSR の環境は同じプロセスの中の別のモジュールグラフなので、ストレージと登録された集合は globalThis に置かれています。

どちらの経路を通るかは、モジュールを読み込んだ時点で document が定義されているかどうかで 1 度だけ決まります。

ブラウザで、集合を定義するモジュールがまだ評価されていない間は、区間がロケールかどうかを判定できません。その間は、その文言が文を持たない区間をロケールではないとみなし、最初に書いた文を返します。/fr/… の 404 ページが throw しないのはこのためです。

サーバー: paramsSchema

[locale] 区間のレイアウトが export const { paramsSchema } = locales と書くと、フレームワークは描画の前にこのスキーマを走らせます。ジェネレーターはファイルをパースして export を探すので、分割代入の書き方でも認識されます。

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

import { locales } from '../../i18n';
import { LocaleShell } from './_parts/locale-shell';

export const { paramsSchema } = locales;

export default function LocaleLayout({ children }: { children: ReactNode }) {
  return <LocaleShell>{children}</LocaleShell>;
}
  • Standard Schema v1 の形(vendor は @k8ordo/i18n)で、スキーマライブラリに依存しません。検証は同期的です。
  • 一覧に無いロケールには path: ['locale'] の issue を返します。パターンは答えず、表の次の候補へ進み、最後は not-found.tsx が 404 で答えます。
  • 受理した値は { locale } だけです。ほかの param は、後に続くスキーマのために文字列のまま残ります。
  • 受理は、そのページの描画の始まりでもあります。受理したロケールは、それ以降の Server Component、それを HTML にする処理、サーバーで走る Client Component に届き、ほかのページや 404 には届きません。同じスタックの後続のスキーマが弾いたとき(/en/blog/nope)は、パターンと一緒に受理も捨てられ、404 は /en/nothing と同じく既定のロケールで描かれます。AsyncLocalStorage を取り出せないランタイムでは、受理の時点で throw します。
  • スキーマを export するファイルは Server Component でなければなりません。フックを使う枠は _parts/ の Client Component に分けます。
  • レイアウトが受け取る params.locale は、スキーマを export していても string 型です。/:locale/*not-found.tsx の下ではスキーマが走らず、未検証の値が届くからです。

サーバー: run(locale, fn)

[locale] の描画の外で、ロケールを決めて何かを実行するときに使います。Server Action、バッチ処理、メール本文の生成、テストです。fn の戻り値をそのまま返し、fn が async でも await をまたいでロケールが保たれます。

// src/emails/welcome.ts
import { locales } from '../i18n';
import type { Locale } from '../i18n';
import * as m from '../messages';

export const welcomeSubject = (locale: Locale): string =>
  locales.run(locale, () => m.email.welcomeSubject());

ブラウザで呼ぶと throw します。ブラウザでは URL がロケールなので、変えたいときはナビゲーションします。AsyncLocalStorage を取り出せないランタイムでも throw します。

getLocale() は hook ではない

getLocale() は、文言と同じ出どころからタグそのものを返します。hook ではないので、描画の中でも、イベントハンドラの中でも、文言の関数の中でも、bindParams のソースの中でも呼べます。<html lang>Intl の書式化、言語切替の現在値に使います。

this に依存しないので、このサイトの src/i18n.ts のように集合から取り出して export できます。

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

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

export type Locale = LocaleOf<typeof locales>;

declare module '@k8ordo/i18n' {
  interface Register {
    locale: Locale;
  }
}

export const { getLocale } = locales;
// src/components/published-at.tsx
import { getLocale } from '../i18n';

export function PublishedAt({ date }: { date: Date }) {
  return (
    <time dateTime={date.toISOString()}>
      {new Intl.DateTimeFormat(getLocale(), {
        dateStyle: 'medium',
        timeZone: 'UTC',
      }).format(date)}
    </time>
  );
}
  • 購読はしません。Client Component の中で呼ぶと、その瞬間の URL を読むだけです。ロケールが変わるのはナビゲーションなので、ページの再描画で新しい値が読まれます。URL の変化で自分から描き直す必要があるコンポーネントは、@k8ordo/routerusePathname() を読みます。
  • モジュールの先頭で呼んだ値は、読み込んだ時点のロケールで固定されます。

言語切替: localizedelocalize

言語を変えることは、同じ pathname を別のロケールの区間の下へ移動することです。今の pathname から区間を外し(delocalize)、別のロケールで付け直します(localize)。

// src/components/language-switcher.tsx
'use client';

import type { Variants } from '@k8ordo/i18n';
import { usePathname } from '@k8ordo/router';

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

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

export function LanguageSwitcher() {
  const { pathname } = locales.delocalize(usePathname());
  const current = getLocale();

  return (
    <ul>
      {locales.all.map((locale) => (
        <li key={locale}>
          <a
            aria-current={locale === current ? 'true' : undefined}
            href={locales.localize(pathname, locale)}
            hrefLang={locale}
            lang={locale}
          >
            {LABELS[locale]}
          </a>
        </li>
      ))}
    </ul>
  );
}
  • delocalize は区間単位で見ます。/english の先頭区間は english なので locale: null です。/en/en/ の残りは / です。
  • 先頭区間がロケールでないとき、delocalize は既定値を推測せず locale: null を返します。何に落とすかは、呼ぶ側が見える形で決めます。
  • localize は、区間がすでに付いているかを確かめません。localize('/en/ui', 'ja')/ja/en/ui です。必ず delocalize した pathname を渡してください。/ で始まらない値には TypeError を投げます。
  • どちらも pathname だけを扱います。search に状態を持つページで、切り替えの後も残したいなら、location.search を自分で足します。

usePathname() を使うのは、サーバーの描画には location が無く、ハイドレーションではサーバーと同じ値で描く必要があり、ナビゲーションのたびにリンクを描き直す必要もあるからです。パターンから作る href を使わないのは、切り替えが手にしているのが今いるページの具体的な pathname で、パターンではないからです。素の <a> でも、ルーターが Navigation API で遷移を横取りするので、クライアント遷移になります。

/ の振り分け

/ はロケールを持たない唯一の URL です。何も描かず、effect の中で交渉して、ロケールの付いた URL へ移ります。

// src/routes/page.tsx
'use client';

import { useEffect } from 'react';

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

export default function RootRedirect() {
  useEffect(() => {
    navigateTo(
      '/:locale',
      { locale: locales.negotiate(navigator.languages) },
      { history: 'replace' },
    );
  }, []);

  return null;
}
  • 描画中ではなく effect で移動します。サーバーでの描画(静的サイトならビルド時)はブラウザの外で走るので、そこには Navigation API が無く、navigator.languages があっても訪問者のものではなく描画しているマシンのものです。
  • history: 'replace' にするのは、戻るボタンで / に戻り、また振り分けられるのを防ぐためです。
  • @k8ordo/router を使わないアプリケーションなら、location.replace(locales.localize('/', locale)) が同じ働きをします。
  • @k8ordo/server では、ページが受け取る requestAccept-Language から、サーバーで交渉できます。

@k8ordo/server での書き方を読む

<html lang>

lang は、サーバーが書いた HTML の時点で正しくなければなりません。クローラーも読み上げも、ハイドレーションを待たずに読むからです。ルートレイアウトは [locale] より上にありますが、pathname を受け取ります。

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

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

export default function RootLayout({
  children,
  pathname,
}: {
  children: ReactNode;
  pathname: string;
}) {
  const locale = locales.delocalize(pathname).locale ?? locales.default;

  return (
    <html lang={locale}>
      <body>{children}</body>
    </html>
  );
}

スキーマはどの描画よりも先に走るので、スキーマが受理したページでは locales.getLocale() も同じ値を返します。delocalize を使う書き方は、URL だけから決まることが読んで分かり、catch-all の param を検証するものが無い 404 の描画でも pathname の区間を読めます。

静的化: locales.paths

@k8ordo/static は、param を持つパターンに具体的な pathname を求めます。ロケールの区間はどのページでも同じ値を取るので、集合が自分で展開します。

// vite.config.ts
import { framework } from '@k8ordo/static';
import { defineConfig } from 'vite';

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

export default defineConfig({
  plugins: [framework({ paths: locales.paths })],
});
  • /:locale 区間を持つパターンは、ロケールの数だけの pathname になります。区間単位で置き換えるので、/:localeCode のような別の param には触れません。
  • /:locale を持たないパターンは、そのまま返ります。静的ビルドが paths に渡すのは pathname をまだ必要とするパターンだけなので、そのまま返したパターンは展開されないまま残り、ビルドが止まります。その param も同じ関数の中で展開してください。
  • 各 pathname はそれぞれ 1 つのリクエストとして描かれるので、ページごとにスキーマがロケールを受理し、文言はそのロケールで出力されます。ビルドは複数のページを同時に描きますが、あるページのロケールがほかのページに漏れることはありません。

ほかの param もあるとき

/:locale/blog/:slug のように別の param も持つパターンは、locales.paths を通しても /ja/blog/:slug のように :slug が残ります。静的ビルドは param の残った pathname を使わないので、そのパターンは展開されていないものとして扱われ、static build needs pathnames for /:locale/blog/:slug — supply them with the "paths" option でビルドが止まります。残りの param は同じ関数の中で展開してください。

// vite.config.ts
import { framework } from '@k8ordo/static';
import { defineConfig } from 'vite';

import { locales } from './src/i18n';
import { readSlugs } from './src/posts';

export default defineConfig({
  plugins: [
    framework({
      paths: async (patterns) => {
        const slugs = await readSlugs();
        return locales
          .paths(patterns)
          .flatMap((path) =>
            path.includes('/:slug')
              ? slugs.map((slug) => path.replace('/:slug', `/${slug}`))
              : [path],
          );
      },
    }),
  ],
});

静的ホストが知らない URL すべてに返す 404.html は、ビルドの番兵の区間で 1 回だけ描かれます。その区間を受理するスキーマは無いので文言は既定のロケールで描かれ、訪問者のロケールに合わせることはできません。Client Component はハイドレーションの時点で訪問者の URL を読み、そのロケールで描き直されます。このサイトの not-found.tsx が Client Component なのはそのためです。

型付きリンク: bindParams

ロケールは、すべてのパターンの param(/:locale/products/:id)です。@k8ordo/routerbindParams にロケールのソースを 1 回渡せば、リンクはパターンの綴りのまま型で検査され、呼ぶたびにロケールを書く必要がありません。

// src/links.ts
import { bindParams } from '@k8ordo/router';

import { locales } from './i18n';

export const { href, navigateTo } = bindParams(() => ({
  locale: locales.getLocale(),
}));
// src/components/product-link.tsx
import { href } from '../links';

export function ProductLink({ id, name }: { id: string; name: string }) {
  return <a href={href('/:locale/products/:id', { id })}>{name}</a>;
}
  • ソースは呼ぶたびに読まれます。サーバーではそのリクエストのロケール、ブラウザでは今の URL のロケールが入ります。
  • ロケールを明示すれば、ソースを上書きできます。navigateTo('/:locale', { locale: 'en' }, { history: 'replace' }) は英語のトップページへ移ります。
  • 2 つのパッケージは互いを import しません。結んでいるのは、アプリケーションのこの 1 行です。
  • localize / delocalize が残るのは、手にしているのがパターンではなく具体的な pathname のとき、つまり言語切替です。

@k8ordo/router のリンクのページを読む