最初の言語を選ぶ
/は、ロケールを持たない唯一のURLです。ここを開いた訪問者は、その人の言語のページへ送ります。このページでは、訪問者の希望からロケールを1つ選ぶnegotiateと、@k8ordo/serverと@k8ordo/staticそれぞれでの/の書き方を説明します。
このページの内容
希望の並びから選ぶ
locales.negotiateは、訪問者の希望の並びから、集合のロケールを1つ選びます。ブラウザなら、navigator.languagesをそのまま渡せます。
locales.all; // ['ja', 'en', 'en-GB']
locales.negotiate(navigator.languages);
locales.negotiate(['en-GB']); // 'en-GB'
locales.negotiate(['en-US', 'ja']); // 'en'
locales.negotiate(['ja-JP', 'en']); // 'ja'
locales.negotiate(['***', 'en']); // 'en'
locales.negotiate(['fr', 'de']); // 'ja'- 希望のタグを、先頭から1つずつ見ます。
- そのタグと同じロケールが集合にあれば、それを返します。大文字と小文字は区別しないので、
en-gbを求めても集合の綴りのen-GBが返ります。 - 無ければ、同じ言語のロケールのうち、集合に先に書いたものを返します。
- どちらも無ければ、次のタグへ進みます。BCP 47ではないタグは、エラーにせずに飛ばします。希望の並びは、訪問者が送ってくる値だからです。
- 最後まで一致しなければ、既定のロケールを返します。
先に並び全体から同じタグを探さないのは、訪問者の1番目の希望を優先するためです。['en-US', 'ja']を送る人がいちばん読みたいのは英語なので、集合にenがあれば、2番目のjaが集合と同じタグでもenを返します。
メモ
言語はIntl.Localeのlanguageだけで比べ、用字や地域は比べません。そのため、zh-Hansとzh-Hantを持つ集合にzh-Hant-TWを求めると、先に書いたzh-Hansが選ばれます。
交渉を試す
Accept-Languageヘッダーを書き換えると、parseAcceptLanguageが作る並びと、タグごとの結果、negotiateの答えがその場で変わります。集合はこのサイトのlocales(jaとen、既定はja)です。
parseAcceptLanguageが返す並びfr-CH一致なしfr一致なしen-US同じ言語 → enja見ない(すでに決まった)
negotiateの答えen
試してみる
- 最初に入っているヘッダーでは、
fr-CHとfrはどのロケールにも一致せず、en-USが言語でenに一致します。答えはenで、その後ろのjaは見られません。 en-US, jaを押すと、jaは集合と同じタグですが、その前のen-USが言語でenに一致した時点で答えが決まります。en_US, ja;q=0.5を押すと、en_USはBCP 47ではないので飛ばされ、答えはjaになります。*;q=0.5, en;q=0, deを押すと、*と重みが0のenは並びから落ちます。残ったdeも一致しないので、答えは既定のjaです。- 「このブラウザのnavigator.languages」を押すと、このブラウザの言語の設定で交渉をやり直します。
Accept-Languageを読む
サーバーでは、リクエストのAccept-Languageヘッダーが訪問者の希望です。parseAcceptLanguageは、このヘッダーをnegotiateに渡せる並びにします。
import { parseAcceptLanguage } from '@k8ordo/i18n';
parseAcceptLanguage('en-US;q=0.8, ja, en;q=0.9');
// ['ja', 'en', 'en-US']qの重みが大きい順に並べます。qの無いタグの重みは1です。- 重みが同じタグは、ヘッダーに書かれた順を保ちます。
- 重みが0以下のタグと
*は落とします。*は「どれでもよい」という意味で、それは既定のロケールがすでに表しているからです。 - 数値にならない
qは無視し、重みを1のままにします。 - ヘッダーが無い(
null)ときと、空白だけのときは、空の配列を返します。
タグそのものは確かめません。BCP 47ではないタグは、negotiateが飛ばします。
リクエストから選ぶ
negotiateRequestは、Requestからロケールを選びます。Cookieの名前を渡すと、訪問者が前に選んだ言語をCookieから先に読み、次にAccept-Languageを読みます。
locales.negotiateRequest(request, { cookie: 'locale' });Cookieの値もヘッダーの並びも、同じnegotiateの規則で選びます。そのため、集合からもう消したロケールがCookieに残っていても、続くヘッダーで選び直します。cookieを省くと、ヘッダーだけを読みます。
Cookieを書く側は、「言語を切り替える」で説明しています。
@k8ordo/serverで/に答える
@k8ordo/serverでは、ページを描く前にguard.tsが/に答えます。negotiateRequestでロケールを選び、そのロケールのURLへの307を返します。
routes/(home)/guard.tsimport { withBase } from '@k8ordo/router';
import type { Guard } from '@k8ordo/server/runtime';
import { locales } from '../../i18n';
const guard: Guard<'/'> = ({ request }) => {
const locale = locales.negotiateRequest(request, {
cookie: 'locale',
});
return new Response(null, {
status: 307,
headers: { location: withBase(locales.localize('/', locale)) },
});
};
export default guard;routes/(home)/page.tsxexport default function RootPage() {
return null;
}ファイルは次のように置きます。
routes/
layout.tsx
(home)/
page.tsx
guard.ts
[locale]/
layout.tsx- guardとページは、
(home)/のようなルートグループに入れます。guard.tsは自分のディレクトリより下のすべてのURLの前に走るので、routes/の直下に置くと/en/…まで振り分けてしまうからです。 - ページは描かれませんが、置いておく必要があります。guardが走るのはページが宣言したURLの前だけで、ページが無ければ
/は404になるためです。 308ではなく307にするのは、答えが訪問者によって変わるからです。localizeはbaseを付けないので、withBaseで付け直します。- JavaScriptが動かない訪問者も、
/へのクライアントでの移動も、同じように振り分けられます。guardは、移動のときにブラウザが取りに来るペイロードのリクエストにも答えるからです。
@k8ordo/staticで/に答える
@k8ordo/staticには、リクエストに答えるサーバーがありません。guard.tsも置けないので、/のページは何も描かず、ブラウザで交渉してから移動します。
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;
}navigateToは、@k8ordo/routerのbindParamsで作ったものです。作り方は「ほかのパッケージと組み合わせる」で説明します。
- 移動はeffectの中で行います。静的なビルドはこのページもブラウザの外で描くので、そこにはNavigation APIがありません。ビルドのときに
navigator.languagesがあったとしても、それは訪問者ではなく、ビルドしているマシンのものです。 history: 'replace'にするのは、戻るボタンで/に戻り、また振り分けられるのを防ぐためです。@k8ordo/routerを使わないアプリでは、location.replace(locales.localize('/', locale))で同じように移動できます。
メモ
JavaScriptが動かない訪問者は、/から先へ進めません。
選んだ言語をCookieに覚えているなら、その値をnavigator.languagesの前に並べてnegotiateに渡します。サーバーのnegotiateRequestが読むのと同じ順番です。
routes/page.tsxuseEffect(() => {
void cookieStore.get('locale').then((saved) => {
navigateTo(
'/:locale',
{
locale: locales.negotiate([
...(saved?.value === undefined ? [] : [saved.value]),
...navigator.languages,
]),
},
{ history: 'replace' },
);
});
}, []);