@k8ordo/i18n

ロケール

defineLocales がアプリケーションのロケール集合を宣言します。一覧に依存するもの、つまり所属判定・交渉・URL の区間・[locale] のスキーマ・静的化のパス・描画中のロケールは、どれもこの戻り値のメンバーです。

defineLocales(all, options?)

ロケールのタグを並べた配列と、省略できる default を受け取ります。タグはリテラル型のまま推論されるので、as const は要りません。

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

export const locales = defineLocales(['en-US', 'en-GB', 'ja'], {
  default: 'ja',
});

export type Locale = LocaleOf<typeof locales>;

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

default を省くと先頭のタグが既定値です。default の型は一覧の和集合なので、一覧に無いタグはコンパイルで落ちます。

誤った一覧は、使われた時点ではなく定義した時点で TypeError を投げます。重複したタグと BCP 47 ではないタグは型では検出されないので、検出するのはこの検査だけです。一覧に無い default はコンパイルでも落ちますが、JavaScript からの呼び出しや as で通した値のために実行時にも確かめます。

呼び出し投げるエラーの文面
defineLocales(['ja', 'en'], { default: 'fr' })defineLocales: the default "fr" is not in ["ja","en"]
defineLocales(['ja', 'ja'])defineLocales: a locale is listed twice in ["ja","ja"]
defineLocales(['ja', 'not a tag'])defineLocales: "not a tag" is not a BCP 47 language tag

アプリケーションに集合は 1 つ

defineLocales は値を返すだけでなく、message() が読む既定値と所属判定を登録します。message() が集合を引数に取らないのは、取ると宣言がバンドラに副作用と見なされ、使われない文言を落とせなくなるからです。

登録は後勝ちです。開発サーバーがロケールを足したモジュールを評価し直したとき、次に呼ばれた文言が新しい集合を読むためです。その反面、テストやデモや補助関数の中で別の集合を defineLocales すると、その時点からすべての文言がその集合を読みます。集合は 1 つのモジュールで定義して export し、ほかの場所では import してください。

集合のメンバー

戻り値は Locales<L, D> 型です。L はタグの和集合、D は既定値のタグです。

メンバー内容
all一覧のタグ。書いた順のままです。
default既定のロケール。交渉で何も一致しなかったときと、何もロケールを指名していないときに使われます。
is(value)所属判定の型ガード。完全一致で、大文字小文字を区別します(is('EN')false)。
negotiate(requested)希望の並びに対して、対応する最良のロケールを返します(下の「交渉」)。
localize(pathname, locale)pathname の前にロケールの区間を付けます。'/ui''/en/ui''/''/en' になります。
delocalize(pathname)逆向きです。'/en/ui'{ locale: 'en', pathname: '/ui' }、ロケールで始まらない pathname は locale: null です。
paths(patterns)@k8ordo/staticpaths オプション。/:locale 区間を持つパターンをロケールの数だけ展開します。
paramsSchema[locale] 区間のスキーマ(Standard Schema)。受理したロケールが、受理したページの描画のロケールになります。
getLocale()描画中のロケール。hook ではないので、どこからでも呼べます。
run(locale, fn)サーバー専用。fn と、そこから始まる処理を locale のもとで実行します。

URL に関わるメンバー(localizedelocalizepathsparamsSchemagetLocalerun)の使い方は、別のページにまとめています。

URL とロケールを読む

内容
Locales<L, D>defineLocales の戻り値の型。
LocaleOf<typeof locales>集合のタグの和集合。Registerlocale にもこれを書きます。
LocalesOptions<D>第 2 引数の型({ default?: D })。
Delocalized<L>delocalize の戻り値({ locale: L | null; pathname: string })。pathname は空にならず、少なくとも / です。
LocaleParamsSchema<L>paramsSchema の型。スキーマライブラリに依存しないよう、Standard Schema の形をこのパッケージ自身が宣言しています。

BCP 47 のタグ

タグは Intl.Locale が受け付けるかどうかで検査します。en_US のような区切りの誤りや *** は BCP 47 ではありません。

集合に書いた綴りがそのまま URL の区間になります。en-US と書けば /en-US/… です。URL に出したい綴りで書いてください。

交渉

negotiate(requested) は、希望の並び(navigator.languages や、Accept-Language を解析した結果)から、対応する最良のロケールを 1 つ返します。

  1. 希望のタグを先頭から 1 つずつ見ます。
  2. そのタグと完全に一致するロケールがあれば、それを返します(大文字小文字は区別しません)。
  3. 無ければ、同じ言語を話す最初の対応ロケールを返します。
  4. BCP 47 ではないタグは、throw せずに飛ばします。希望の並びは利用者の入力だからです。
  5. 最後まで一致が無ければ、既定値を返します。
  • 交渉の完全一致は大文字小文字を区別しません(en-gben-GB に一致し、集合側の綴りで返ります)。isdelocalize は区別します。
  • 言語での一致は、言語サブタグ(Intl.Localelanguage)だけを比べます。用字や地域は比べないので、['zh-Hans', 'zh-Hant']zh-Hant-TW を求めると、先に書いた zh-Hans が選ばれます。同じ言語のロケールが複数あるときは、書いた順が言語一致で選ばれる順です。

全タグの完全一致を先に探さず、タグごとに言語まで見てから次へ進むのは、利用者の 1 番目の希望を優先するためです。['en-US', 'ja'] を送る人は英語を最も望んでいるので、集合に en があれば、2 番目の ja が完全一致でも en を返します。

引数は Iterable<string> なので、navigator.languages も配列も Set もそのまま渡せます。

// src/preferred-locale.ts
import { parseAcceptLanguage } from '@k8ordo/i18n';

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

export const fromBrowser = (): Locale => locales.negotiate(navigator.languages);

export const fromHeaders = (headers: Headers): Locale =>
  locales.negotiate(parseAcceptLanguage(headers.get('accept-language')));

集合 defineLocales(['ja', 'en', 'en-GB']) に対する結果です。

希望結果理由
['en-GB']en-GB完全一致
['EN-gb']en-GB完全一致(大文字小文字は区別しない)
['en-US', 'en-GB']enen-US の言語で、先に en が見つかる
['en-US', 'ja']en1 番目の希望の言語が、2 番目の完全一致より先
['ja-JP', 'en']jaja-JP の言語で一致
['***', 'en']en*** は BCP 47 ではないので飛ばす
['fr', 'de']ja一致なし。既定値
[]ja空の並び。既定値

parseAcceptLanguage(header)

Accept-Language ヘッダーを、negotiate に渡せる希望の並びにします。サーバーでヘッダーから交渉するときに使います。

  • q の重みの大きい順に並べます。q の無いタグの重みは 1 です。
  • 重みが同じタグは、ヘッダーに書かれた順を保ちます。
  • 重みが 0 以下のタグ(空の q=0 と読みます)と * は落とします。* は「何でもよい」という意味で、それは既定値がすでに表しています。
  • ヘッダーが無い(null)か空白だけなら、空の並びを返します。
  • 空白、Q= のような大文字の引数名、知らない引数は許容します。q=abc のように数値にならない q は無視され、重みは 1 のままです。
  • タグ自体は検査しません。BCP 47 ではないタグは negotiate が飛ばします。
ヘッダー結果
parseAcceptLanguage('en-US;q=0.8, ja, en;q=0.9, fr;q=0.8')['ja', 'en', 'en-US', 'fr']
parseAcceptLanguage('*;q=0.5, en;q=0, ja')['ja']
parseAcceptLanguage(' en ; foo=bar ; Q=0.5 ,ja')['ja', 'en']
parseAcceptLanguage(null)[]

交渉を試す

ヘッダーを書き換えると、parseAcceptLanguage が作る並び、各タグが集合のどのロケールに一致したか、negotiate の答えがその場で変わります。集合はこのサイト自身の localesjaen、既定は ja)です。説明用に別の集合を定義しないのは、上で書いたとおり、2 つ目の defineLocales がこのページの文言が読む集合を置き換えてしまうからです。

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

en