@k8ordo/i18n

最初の言語を選ぶ

/は、ロケールを持たない唯一のURLです。ここを開いた訪問者は、その人の言語のページへ送ります。このページでは、訪問者の希望からロケールを1つ選ぶnegotiateと、@k8ordo/serverと@k8ordo/staticそれぞれでの/の書き方を説明します。

このページの内容

希望の並びから選ぶ

locales.negotiateは、訪問者の希望の並びから、集合のロケールを1つ選びます。ブラウザなら、navigator.languagesをそのまま渡せます。

ts
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. 希望のタグを、先頭から1つずつ見ます。
  2. そのタグと同じロケールが集合にあれば、それを返します。大文字と小文字は区別しないので、en-gbを求めても集合の綴りのen-GBが返ります。
  3. 無ければ、同じ言語のロケールのうち、集合に先に書いたものを返します。
  4. どちらも無ければ、次のタグへ進みます。BCP 47ではないタグは、エラーにせずに飛ばします。希望の並びは、訪問者が送ってくる値だからです。
  5. 最後まで一致しなければ、既定のロケールを返します。

先に並び全体から同じタグを探さないのは、訪問者の1番目の希望を優先するためです。['en-US', 'ja']を送る人がいちばん読みたいのは英語なので、集合にenがあれば、2番目のjaが集合と同じタグでもenを返します。

情報

メモ

言語はIntl.Localeのlanguageだけで比べ、用字や地域は比べません。そのため、zh-Hansとzh-Hantを持つ集合にzh-Hant-TWを求めると、先に書いたzh-Hansが選ばれます。

Playground

交渉を試す

Accept-Languageヘッダーを書き換えると、parseAcceptLanguageが作る並びと、タグごとの結果、negotiateの答えがその場で変わります。集合はこのサイトのlocales(jaとen、既定はja)です。

例
parseAcceptLanguageが返す並び
  1. fr-CH一致なし
  2. fr一致なし
  3. en-US同じ言語 → en
  4. ja見ない(すでに決まった)
negotiateの答え

en

試してみる

  1. 最初に入っているヘッダーでは、fr-CHとfrはどのロケールにも一致せず、en-USが言語でenに一致します。答えはenで、その後ろのjaは見られません。
  2. en-US, jaを押すと、jaは集合と同じタグですが、その前のen-USが言語でenに一致した時点で答えが決まります。
  3. en_US, ja;q=0.5を押すと、en_USはBCP 47ではないので飛ばされ、答えはjaになります。
  4. *;q=0.5, en;q=0, deを押すと、*と重みが0のenは並びから落ちます。残ったdeも一致しないので、答えは既定のjaです。
  5. 「このブラウザのnavigator.languages」を押すと、このブラウザの言語の設定で交渉をやり直します。

Accept-Languageを読む

サーバーでは、リクエストのAccept-Languageヘッダーが訪問者の希望です。parseAcceptLanguageは、このヘッダーをnegotiateに渡せる並びにします。

ts
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を読みます。

ts
locales.negotiateRequest(request, { cookie: 'locale' });

Cookieの値もヘッダーの並びも、同じnegotiateの規則で選びます。そのため、集合からもう消したロケールがCookieに残っていても、続くヘッダーで選び直します。cookieを省くと、ヘッダーだけを読みます。

Cookieを書く側は、「言語を切り替える」で説明しています。

@k8ordo/serverで/に答える

@k8ordo/serverでは、ページを描く前にguard.tsが/に答えます。negotiateRequestでロケールを選び、そのロケールのURLへの307を返します。

routes/(home)/guard.ts
import { 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.tsx
export default function RootPage() {
  return null;
}

ファイルは次のように置きます。

text
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.tsx
useEffect(() => {
  void cookieStore.get('locale').then((saved) => {
    navigateTo(
      '/:locale',
      {
        locale: locales.negotiate([
          ...(saved?.value === undefined ? [] : [saved.value]),
          ...navigator.languages,
        ]),
      },
      { history: 'replace' },
    );
  });
}, []);
k8ordo

Baselineに入った機能を、制限なく使うReactのライブラリ群。

© 2026 k8o — MIT License

組版:Noto Sans JP / M PLUS 2