Web i18n は何をしますか?
国際化はアーキテクチャであり、最後に行う翻訳作業ではありません。目的は、特別なケースを設けずに、どのロケールでも正しく表示されるUIを実現することです。すなわち、連結された文、ハードコードされた書式、ドイツ語で35%長くなったりアラビア語で方向が反転したりすると壊れるレイアウトを排除します。
このスキルを使うタイミング
ユーザーが以下を依頼してきた場合に使用します:
- 言語、言語スイッチャー、またはロケールルーティングの追加
- アプリの翻訳、文字列の抽出、翻訳ワークフローのセットアップ
- 複数形、性別、または「1 item / 2 items」バグの修正
- ロケールに応じた日付、数値、通貨、相対時間、リストの書式設定
- RTL(アラビア語、ヘブライ語、ペルシア語、ウルドゥー語)のサポート
- hreflang、ローカライズされたメタデータ、または多言語サイトマップの追加
- 既存のi18n設定、欠落キー、または翻訳のずれのレビュー
- next-intl、react-i18next、FormatJS、Lingui、またはTMSの選択
関連するリファレンスは references/ に、スクリプトは scripts/ にあります。タスクに必要なものだけを読んでください。パスはこのスキルのディレクトリからの相対パスです(エージェントランタイムはスキルが読み込まれたときにそのパスを表示します)。
絶対条件
- 翻訳された断片を連結して文を構築しないこと。 プレースホルダーを含むメッセージ全体を翻訳します:
"{count} 件の新着メッセージ"。t('new') + ' ' + count + ' ' + t('messages')は不可。 - キーとして英語テキストを使用しないこと。 キーは安定した識別子(
inbox.unreadCount)であり、コピーの編集によってルックアップが壊れたり、すべての翻訳が無効になったりしないようにします。 - ユーザー向けのすべての文字列は外部化する。
aria-label、alt、title、placeholder、バリデーションエラー、空の状態、トースト、メール、プッシュ通知、PDF出力を含みます。 - 書式をハードコードしないこと。
toFixed(2) + '$'、手動でのMM/DD/YYYY、英語の月名、24hの仮定は禁止。Intlを使用すること。 - ロケールはBCP 47タグであり、言語ではない。
pt-BRとpt-PT、zh-Hansとzh-Hant、es-MXとes-ESは別のプロダクトです。完全なタグを保存すること。 - サーバー上では、ロケール状態はリクエストスコープでなければならない。 SSR中に現在のロケールをモジュールレベルの可変変数に決して入れないでください。同時リクエストが互いに言語を漏洩させます。
- TMS、コミュニティ、CMSから来た翻訳は信頼できない入力として扱う。 サニタイズせずに
innerHTML/dangerouslySetInnerHTMLに渡さないこと。
ワークフロー
1. コードを変更する前に監査する
- スタック、ルーター、既存のi18nライブラリを何かを提案する前に特定する。
- ハードコードされた文字列を見つける:
出力は候補として扱い、絶対視しない:テストフィクスチャ、定数、URL、開発者向けログはスキップすること。node scripts/scan-hardcoded.mjs src - 現在出荷されているすべてのロケールと、カタログの保存場所を一覧化する。
- リスクの高い領域を最初に特定する:認証、チェックアウト、エラー、メール、法的コピー。
2. アーキテクチャを決定する(文書化する)
コードを書く前に以下に答える:
| 決定事項 | デフォルト | 備考 |
|---|---|---|
| ロケール戦略 | パスプレフィックス /es/ | SEOとキャッシュ可能性に最適。サブドメイン/ccTLDは理由がある場合のみ。 |
| デフォルトロケール | ソース言語 | 初回訪問者を推測したロケールに自動リダイレクトしない。オーバーライド手段を提供すること。 |
| ロケール検出 | URL → Cookie → Accept-Language → デフォルト | URLが優先されるため、共有リンクは言語を変更しない。 |
| カタログ形式 | ロケールごと、名前空間ごとのJSON | 名前空間を遅延ロードする。すべてのロケールをクライアントに出荷しない。 |
| メッセージ構文 | ICU MessageFormat | ライブラリやTMS間で移植可能。複数形/選択/序数をサポート。 |
| キースタイル | feature.component.element | 安定しており、grep可能で、英語テキストを含まない。 |
| 翻訳の信頼できるソース | TMSまたはリポジトリ、両方ではない | 一つ選び、CIで一方向に同期する。 |
| フォールバック | ロケールチェーン es-MX → es → en | 開発環境でフォールバックをログ出力し、ギャップを検出する。 |
3. メッセージを外部化し構造化する
- 文字列をセマンティックキーに置き換え、コピーをベースロケールカタログに移動する。
- 完全な文ごとに1つのキーを使用する。文にリンクや太字の単語が含まれる場合は、分割せずにリッチテキスト補間を使用する。
- プレースホルダーには名前を付ける(
{userName})。位置指定({0})は禁止。 - ライブラリが明示的に型付きタグをサポートしていない限り、カタログにHTMLを入れない。
- ページ単位ではなく、プロダクトサーフェス領域ごとにグループ化する:
checkout.json、auth.json、errors.json。
複数形、選択、序数、リッチテキストのルールについては references/icu.md を参照。
4. ロケールルーティングとレンダリングを配線する
- ロケールをルートから導出し、サポートされているリストに対して検証し、未知のロケールは静かにフォールバックするのではなく404を返す。
- 現在のパスとクエリ文字列を保持する言語スイッチャーを提供する。
- サーバーでレンダリングされたドキュメントに
<html lang="...">と<html dir="...">を設定する。 - アクティブなロケールのカタログとデフォルトのフォールバックのみを読み込む。
- 同じカタログをサーバーとクライアントのレンダリングで利用できるようにし、重複したり分岐した翻訳ロジックを避ける。
フレームワーク固有のセットアップと注意点:references/frameworks.md。
ルーティング、検出、hreflang、サイトマップ:references/routing-seo.md。
5. ロケール依存の値を処理する
手動での書式設定は行わず、Intl(またはフレームワークのラッパー)を使用する:
new Intl.NumberFormat(locale, { style: 'currency', currency }).format(amount)
new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeZone }).format(date)
new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format(-3, 'day')
new Intl.ListFormat(locale, { type: 'conjunction' }).format(items)
new Intl.PluralRules(locale).select(count)
new Intl.Collator(locale).compare(a, b)
- タイムスタンプはUTCで保存し、ユーザーのタイムゾーンでレンダリングする(サーバーのタイムゾーンではない)。
- 通貨はロケールと同じではない:フランス人のユーザーがUSDで支払う可能性がある。
- ソート/検索には
Intl.Collatorを使用し、<やロケールを指定しないlocaleCompare()は使用しない。 - 大文字小文字変換:
toLocaleUpperCase(locale)。トルコ語のiはİになるため。
6. 拡張とRTLに備えた設計
- テキストの長さが +30~40% 増加する(ドイツ語、フィンランド語、ロシア語)ことを想定し、固定幅のボタンや単一行のラベルは避ける。
- コンテナは拡張可能にし、折り返しを許可し、擬似ローカライズでテストする:
node scripts/pseudo-localize.mjs locales/en.json --out locales/en-XA.json - 論理CSSプロパティ(
margin-inline-start、padding-inline、inset-inline)をleft/rightの代わりに使用する。 - 方向性のあるアイコン(矢印、シェブロン、進行状況)はミラーリングする。ロゴ、時計、メディアコントロール、チェックマークはミラーリングしない。
- ユーザー生成のインラインコンテンツは
<bdi>またはdir="auto"でラップする。
完全なRTLチェックリスト:references/rtl.md。
7. CIで翻訳QAを設定する
最低限、カタログが構造的に無効な場合にビルドを失敗させる:
node scripts/check-locales.mjs locales --base en
これは、欠落/余分なキー、空の値、プレースホルダーの不一致、壊れたICU構文をチェックします。利用可能な場合は、ライブラリのCLIを介してICUコンパイルを追加する:
npx formatjs compile-folder --ast locales/en locales/compiled/en
複数形カテゴリ、固定タイムゾーンでの日付/数値出力、RTLレンダリングのテストを追加する。references/testing.md を参照。
主要な決定事項と落とし穴
ライブラリの選択
| ニーズ | 選択 |
|---|---|
| Next.js App Router、サーバーコンポーネント | next-intl |
| React SPA、成熟したエコシステム、多くのプラグイン | react-i18next |
| ICUファースト、フレームワーク非依存、CLI抽出 | FormatJS (react-intl) |
| コンパイル時カタログ、小さなランタイム、型安全性 | Lingui |
| Vue | vue-i18n |
| Svelte | svelte-i18n または paraglide |
| Astro | 組み込みのi18nルーティング + メッセージライブラリ |
| バニラ / 最小限 | Intl + 小型カタログローダー |
すでにi18nライブラリが存在するアプリに、2つ目のi18nライブラリを導入しないでください。既存の設定を拡張すること。
複数形
複数形カテゴリは言語固有です。英語には one/other があり、アラビア語には zero/one/two/few/many/other があり、日本語には other しかありません。count === 1 ? ... : ... は絶対に書かないでください。
"inbox.unread": "{count, plural, =0 {未読メッセージはありません} one {# 件の未読メッセージ} other {# 件の未読メッセージ}}"
性別と文法上の一致
性別変数に対して select を使用し、変数の値は言語に依存しない値(female、male、neutral、unknown)に保ち、ローカライズされた単語は使用しない:
"profile.greeting": "{gender, select, female {いらっしゃいませ、{name}様} male {いらっしゃいませ、{name}様} other {いらっしゃいませ、{name}様}}"
言語が形容詞、名詞、冠詞の一致を必要とする場合は、文字列をハックする代わりに、文法的な性/格を引数として公開する。
リッチテキスト
"Read our " + link + " terms" を3つのキーに分割しないでください。タグ補間を使用する:
"signup.terms": "<link>利用規約</link>に同意します"
t.rich('signup.terms', { link: (chunks) => <a href="/terms">{chunks}</a> })
SEO
- ロケールごとに1つのインデックス可能なURL。同じURLの背後で機械翻訳されたコンテンツを提供しないこと。
- 自己参照
hreflangに加えてx-default。絶対URLを使用し、すべてのロケールを含めること。 <title>、メタディスクリプション、OGタグ、構造化データをローカライズする。- ローカライズされたページで
hreflangとnoindexを組み合わせないこと。
詳細は references/routing-seo.md を参照。
キャッシュ
- パスにロケールを含めると、CDNキャッシュが簡単になります。ヘッダーでネゴシエーションする場合は、
Vary: Accept-Languageを送信し、キャッシュの断片化を予期すること。 - 共有CDNレスポンスに個人設定された言語設定をキャッシュしない。
Content-Languageをレンダリングされたロケールと一致させる。
レビューチェックリスト
i18nが完了したと宣言する前に:
- コンポーネント、メール、エラーにハードコードされたユーザー向け文字列がない
- 文の連結がない。すべてのメッセージは完全な翻訳可能単位である
- キーはセマンティックかつ安定しており、キーとして英語テキストを使用していない
- すべてのプレースホルダーがすべてのロケールに存在し、ICU構文がコンパイルされる
- 複数形は
Intl.PluralRules/ICU を使用し、count === 1ではない - 性別/一致は
selectで処理し、"/a"接尾辞ではない - 日付、数値、通貨、相対時間は
Intlを明示的なロケール/タイムゾーンで使用している -
<html lang>とdirが初回ペイント(SSR)で正しく設定され、エフェクトで設定されていない - 言語スイッチャーはパス/クエリを保持し、ディープリンクで動作する
- 未知のロケールは静かなフォールバックではなく404を返す
- レイアウトが+35%の拡張とRTLに耐えられる
-
hreflang、canonical、サイトマップ、ローカライズされたメタデータが正しい - カタログ検証がCIで実行される
- フォールバック動作が意図的であり、観察可能である
アンチパターン
t('hello') + ', ' + name— 断片化された文key={englishText}— キーとしての翻訳テキストcount === 1 ? 'item' : 'items'— 英語のみの複数形ロジック- ロケールやタイムゾーンを指定しない
new Date().toLocaleDateString() - 金額に対する
value.toFixed(2) - ドキュメントレベルの
dirの代わりにコンポーネント全体にlocale === 'ar' && { direction: 'rtl' }を散在させる useEffect内で<html lang>を設定する — フラッシュが発生し、クローラーが壊れる- すべてのロケールを初期バンドルに読み込む
- オーバーライド手段なしにIPで自動リダイレクトする
- 固有名詞、製品名、コード識別子、URLを翻訳する
- 未レビューの機械翻訳文字列を出荷する
- 1つのカタログファイルが数百キーを超えてもスケールすると想定する