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/routerのusePathname()を読みます。 - モジュールの先頭で呼んだ値は、読み込んだ時点のロケールで固定されます。
言語切替: localize と delocalize
言語を変えることは、同じ 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では、ページが受け取るrequestのAccept-Languageから、サーバーで交渉できます。
<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/router の bindParams にロケールのソースを 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 のとき、つまり言語切替です。