

1/1/1970
3日間で20言語のi18nを実装した方法
こんにちは!先日、Foonyを20の異なる言語に翻訳するという大規模なタスクを終えたところです。コードベースのほぼすべてのファイルに手を入れる大仕事でしたが、なんとか3日間で完了させることができました。
以下では、どうやってやり遂げたのか、変更にまつわる具体的な数字、そして業界標準を使わずに(またしても)自前の翻訳ライブラリを作ることにした理由を詳しく解説していきます。
なぜi18nextを使わなかったのか
最初に翻訳機能の追加を検討したとき、業界標準であるi18nextとreact-i18nextを考えました。
ですが代わりに、AIによるメンテナンス性を最適化することにしました。i18nextは強力ですが、APIのバリエーションが多いためLLMが幻覚を起こしたり、一貫性のないコードを書いたりする原因になり得ます。ライブラリをシンプルなt()とinterpolate()だけに制約することで、10以上の並列エージェントが、ほぼ人間の介入なしに100%型安全なコードを書けるようにしたのです。
また、後から破壊的変更が入るかもしれない大きなエコシステムに乗っかることにも警戒していました。React Router v5やMUI v4 → v5の苦痛な移行で痛い目を見てきたので、JavaScript界隈では後方互換性が急速に壊されることがあまりにも多いと知っています。後から複数形機能を追加するコストは、今139,000行のコードを手動で移行するコストよりも低いです。
ものすごくシンプルで、極めて軽量で、私のチームのニーズにぴったり合ったものが欲しかったのです。
なので、自分で書きました。
AIによる高精度かつ自律的なリファクタリングを可能にすることに特化した、3KBの制約付きサブセットを作りました。これにより、5人チームが3週間かかる作業を、私1人がたった3日で達成できたわけです。
カスタム実装
gzip後で約3KBの最小限のi18nライブラリを考案しました。主な関数は2つです: 非Reactコンテキスト向けのgetTranslation()と、コンポーネント向けのuseTranslation()フックです。
これらは単純な文字列置換用のt()と、翻訳文字列の中にReactコンポーネント(リンクやアイコンなど)を注入したいときに使うinterpolate()を返します。両方の関数は変数置換に対応しており、例えば"Hello {{thing}}", {thing: 'World'}のように使えます。
キーは「スラッシュ・ドット」表記に従います(スラッシュはローカライズファイルへのファイルパス、ドットはファイル内のネストされたオブジェクトを表します)。一意性を確保するため、ファイル内の翻訳キーにスラッシュを含めることはできません。
これがコアのt()関数です:
export function t(key: TranslationKeys, values?: Record<string, string | number>, locale?: SupportedLocale): string {
let namespace: string = '';
let translationKey: string = key;
// Check if key contains '/' - this indicates a namespace
const slashIndex = key.indexOf('/');
if (slashIndex !== -1) {
const parts = key.split('/');
namespace = parts.slice(0, -1).join('/');
translationKey = parts[parts.length - 1];
}
const targetLocale = locale ?? currentLocale;
const text = getTranslationValue(targetLocale, namespace, translationKey);
if (values) {
return interpolateString(text, values);
}
return text;
}
そしてReactフック:
export function useTranslation() {
const [language] = useLanguage();
// Subscribe to locale loading events to trigger re-renders when translations are loaded
const version = useSyncExternalStore(
(callback) => LocaleQueryer.onLoad(callback),
() => LocaleQueryer.getVersion(),
() => LocaleQueryer.getVersion()
);
return useMemo(() => ({
t: (key: TranslationKeys, values?: Record<string, string | number>) =>
t(key, values, language),
interpolate: (key: TranslationKeys, components: Record<string, ReactNode>) =>
interpolate(key, components, language),
}), [language, version]);
}
ライブラリ全体のコアはわずか約580行のコードです。これが以下を処理します:
- 翻訳ファイルの遅延読み込み。すべてのユーザーに20言語すべてを送らなくて済むように。
- 「ネームスペース」(例:
common、misc、games/{gameId})による翻訳のコード分割。 - 生のキーを表示する「debug」ロケール。すべてが正しく配線されているか検証できます。
システムを今後もメンテナンスしやすく保つため、shared/src/i18n/README.mdに包括的なドキュメントも追加しました。ファイル構造からクライアント・サーバー両方の使用例まで、すべてを網羅しています。標準ライブラリを使っていないので、新しいチームメンバーをオンボーディングする(または、未来の自分やLLMに動作を思い出させる)ためにも、このリファレンスを持っておくことは極めて重要です。
数字で見ると
このアップデートの規模感をお伝えするために、コードベースで何が変わったかをご紹介します:
- 20言語をサポート(プラス開発用のdebugロケール)。
- 360個のロケールファイルを作成。
- 139,031行の翻訳コード。
- クライアント全体で3,938回の
t()呼び出しを追加。 - 728個のソースファイルを変更。
- 真実の源泉となる18個の英語ソースファイル(16ゲーム + common + misc)。
エージェントによるオーケストレーション
これを手作業でやっていたら、頭が麻痺しそうな機械的作業に何ヶ月もかかったでしょう。代わりに、12個以上のCursorエージェントを同時にオーケストレーションして、重労働をやってもらいました。
まずは、コードベースをフォルダごとに「セクション」に分割しました。Foonyの各ゲームは独自のフォルダと独自の翻訳ネームスペースを持ちます。これにより、プレイ中のゲームの翻訳だけをロードすればよくなり、初期ロードサイズが小さく抑えられます。
複数のCursorエージェントを同時に走らせました。各エージェントには「Chessゲームを翻訳を使うように変換して」のような特定のセクションを割り当てると、ファイルを1つずつ処理して、ユーザー向けの文字列を見つけてt('games/chess/some.key')に置き換えていきました。
そしてエージェントは、そのキーを適切な英語ロケールファイルに、その文字列の「何」と「どこ」を説明するJSDocコメント付きで追加しました。このコンテキストは他言語の翻訳を生成するときに重要で、「Save」が「ゲーム設定を保存」なのか「Draw & Guessの絵を保存」なのかをLLMが理解する助けになります。
品質管理
生成されたコードはすべて素早くレビューしました。エージェントは驚くほど優秀でしたが、useTranslationフックを早期return文の後ろに置いてしまうなど、たまにミスをすることもありました。
強い型付けされた翻訳が大いに役立ちました。これにより各ロケールのすべての翻訳が正しいキーを持ち(間違ったキーは持たない)ことが保証されました。また、t()やinterpolate()の呼び出しが実在する翻訳文字列を使っていることも保証されました。
型システムは英語ソースファイルからすべての可能な翻訳キーを抽出します:
/**
* Extracts all possible paths from a nested object type, creating dot-notation keys.
* Example: {a: string, b: {c: string, d: {e: string}}} → 'a' | 'b.c' | 'b.d.e'
*/
type ExtractPaths<T, Prefix extends string = ''> = T extends string
? Prefix extends '' ? never : Prefix
: T extends object
? {
[K in keyof T]: K extends string | number
? T[K] extends string
? Prefix extends '' ? `${K}` : `${Prefix}.${K}`
: ExtractPaths<T[K], Prefix extends '' ? `${K}` : `${Prefix}.${K}`>
: never
}[keyof T]
: never;
export type TranslationKeys =
| ExtractPaths<typeof import('./locales/en/index').default>
| `misc/${ExtractPaths<typeof import('./locales/en/misc').default>}`
| `games/chess/${ExtractPaths<typeof import('./locales/en/games/chess').default>}`
| `games/pool/${ExtractPaths<typeof import('./locales/en/games/pool').default>}`
// ... and so on for all games
これでTypeScriptの自動補完が完璧に効き、翻訳キーのタイポはコンパイル時にキャッチされます。エージェントはt('games/ches/name')のようなミスをすることができません。TypeScriptが即座にフラグを立てるからです。
ローカライゼーション
英語への変換が終わったら、残りのロケールタスクを分割しました。各エージェントに、1つの英語ロケールファイルを指定された言語に変換する責任を割り当てました。
例えば、エージェントに以下のようなプロンプトを与えました:
Please ensure that ar/games/dinomight.ts has all the translations from en/games/dinomight.ts.
Use `export const account: DinomightTranslations = {`.
Iterate until there are no more type errors for your translation file (if you see errors for other files, ignore them--you are running in parallel with other agents that are responsible for those other files).
Your translations must be excellent and correct for the jsdoc context provided in en.
You must do this manually and without writing "helper" scripts, and with no shortcuts.
Cursorにスクリプトを作らせて、各ファイルをLLMに食わせて生成させることも考えましたが、LLMコストを少し節約したかったのです。欠けている翻訳だけを更新するスクリプトを使うほうが良いアプローチで、将来も似たような解決策を使うことになりそうです。どの文字列を更新/翻訳する必要があるかを追跡したいのですが、シンプルに保ちたいです。翻訳作業をデータベースか何かに移すかもしれません。
開発時のみ使える「debug」ロケールも追加しました。これですべての置換された文字列を見て動作を検証できます(個人的にもクールだと思います)。debugロケールを使うと、t()はキーを括弧で囲んで返します:
if (targetLocale === 'debug') {
return `⟦${key}⟧`;
}
なので「Welcome to Foony!」の代わりに⟦welcome⟧が表示され、欠けている翻訳をすぐ見つけられます。
最後に、もう1つのエージェントが/{locale}/**ルーティングを実装し、/ja/games/chessのようなパスが正しい言語(この場合は日本語)にルーティングされるようにしました。
ブログの翻訳
UIの文字列を翻訳するのは1つの話ですが、ブログ記事はどうでしょう?すべてのブログ記事を翻訳するためにさらに多くのエージェントを起動して管理するのは避けたかったのです。
エージェントにスクリプト(scripts/src/generateBlogTranslations.ts)を作らせて、プロセス全体を自動化することで解決しました。
仕組みはこうです:
client/src/posts/enディレクトリで英語のMDXファイルをスキャンする。- 他のロケールフォルダ(例:
posts/ja、posts/es)に欠けている翻訳をチェックする。 - 翻訳が欠けている場合、英語のコンテンツを読み込み、Markdownのフォーマットを保持しながら翻訳するための特定のプロンプト付きでGemini 3 Pro Previewに渡す。
- 新しいファイルを正しい場所に保存する。
フロントエンドではimport.meta.globを使ってこれらのMDXファイルをすべて動的にインポートしています。PostPageコンポーネントは単にユーザーの現在のロケールをチェックして、正しいMDXファイルを遅延読み込みします。翻訳が欠けている場合(まだスクリプトを走らせていないため)は、優雅に英語にフォールバックします。
4日目: 自動翻訳生成
最初の解決策はスケールしないとわかっていました。なので、i18nがリリースされた今、データベース駆動のアプローチで少し堅牢化する時が来たのです。
要するに: 英語のテキストやJSDocコメントが変更されたら、翻訳を再生成する必要があります。何を更新すべきかを手動で追跡するのはエラーが起こりやすく、開発者の時間の無駄です。
そこで、当初計画していた解決策を構築しました: PostgreSQLバックの翻訳生成システムです。
データベーススキーマ
PostgreSQLデータベースに以下の構造のtranslationsテーブルを追加しました:
key: 「スラッシュ・ドット」表記の翻訳キー(例:"games/yacht/nested.name"、"config.timeLimit.label")。en_value: 英語のソース値target_locale: ターゲットロケールコード(例:"es"、"fr"、"zh")target_value: 翻訳された値context: このキーとすべての祖先キーのJSDocを含むJSONBフィールドcreated_atとupdated_at: 追跡用のタイムスタンプ
ユニークインデックスは(key, target_locale, en_value, context)にあります。これが重要: ユニーク制約にcontextを含めることで、JSDocコメントが変更されたときを自動検知して翻訳を再生成できます。古い翻訳は履歴参照のために保持されます。
生成スクリプト
翻訳ワークフロー全体を自動化するscripts/src/generateLocalizations.tsを作成しました:
- 英語キーの抽出: AST解析(ts-morph)を使って
shared/src/i18n/locales/en/**ファイルからすべての翻訳キーを抽出し、デフォルトエクスポートのみを処理 - JSDocコンテキストの抽出: 各キーとすべての祖先キー(親オブジェクト)のJSDocコメントを解析して、リッチなコンテキストを提供
- データベースのクエリ: PostgreSQLの既存翻訳をチェックし、
key、target_locale、en_value、ANDcontextでマッチング。これらのいずれかが変更されたら翻訳が再生成される。 - 欠けている/変更されたキーの特定: 翻訳が必要なキー、または英語の値/コメントが変更されたキーを検出
- 翻訳のバッチ化: より効率的なLLM呼び出しのためにロケールとネームスペースのプレフィックスでグループ化(翻訳も速くなる)。ただしバッチが大きすぎると、翻訳品質は悪化します。
- 翻訳の生成: 包括的なコンテキスト(JSDoc、言語+地域、トーン、用語集、例)を持つGPT 5.1を使用。5.1は5.2より文章生成に優れている(味気なく聞こえない)と読みましたが、確認はしていません。
- QAチェック: プレースホルダの保持(例:
{{name}})、キーの整合性、JSON形式を検証 - データベースへの保存: 完全なコンテキスト(JSDoc + 祖先のJSDoc)と共に翻訳を保存
- ロケールファイルの生成: データベースから読み取り、
RecursivePartial型を持つ適切にフォーマットされたTypeScriptロケールファイルを書き出す
主なメリット
このアプローチはいくつかのDevEx改善をもたらします:
- 自動再生成: 英語のテキストまたはJSDocコメントが変更されると、翻訳が自動的に再生成される。なので誰かが翻訳が悪いと言ったら、コメントとしてより多くのコンテキストを提供することで簡単に再生成できる。
- リッチなコンテキスト: JSDocコメントが翻訳のコンテキストを提供(例: 「プレイヤーに表示されるエラーメッセージ、最大15文字」)、LLMがより正確な翻訳を生成するのを助ける
- 祖先のコンテキスト: 親オブジェクトのJSDocがネームスペースのコンテキストを提供(例: 「全ての卵が破壊されたゲームに参加した実績」)、もう少し明確さを与える
- 履歴の追跡: 古い翻訳はデータベースに保存される。あまり場所を取らないので、今のところ削除する理由はあまりなく、履歴を見られるのもクール。
技術的な詳細
実装では信頼性と効率性を確保するためにいくつかの技術を使っています:
- 正しいコメントを取得するためのASTベースの抽出
- 並列バッチ翻訳のためのSemaphoreを使った並列処理
- API失敗時の指数バックオフリトライロジック。LLM呼び出しは悪名高く不安定です。
スクリプトはscriptsディレクトリからnpm run generate-localizationsで実行できます。実行するとPostgreSQLに接続し、サポートされているすべてのロケールについて欠けている、または変更された翻訳をすべて処理します。
結論
この時点で、20のロケールすべてに翻訳された完全に機能するサイトができあがりました!
クレイジーな3日間でしたが、結果として世界中のユーザーにとって(ほぼ)ネイティブに感じられる完全にローカライズされたサイトができました。カスタムで軽量なライブラリを構築し、退屈なリファクタリング作業にAIエージェントを活用することで、ほんの1年前には不可能だったことを達成しました: 複雑なウェブサイトの完全なi18nを、エンジニア1人が3日間で実装する。プログラミングの未来は、コードを速く書くことではありません。それはAIエージェントをオーケストレーションし、彼らの出力を検証する深いドメインの専門知識を持つことなのです。