background blurbackground mobile blur

1/1/1970

3日で20言語対応のi18nを実装した話

やあ!ついさっき、Foonyを20言語に対応させるという巨大タスクを終えたところ。コードベースのほとんど全部のファイルに手を入れるような大仕事だったけど、全部まとめて3日でやり切った。

このあとで、どうやって進めたのか、その裏にある具体的な数字、そしてなぜ業界標準じゃなくて(またしても)自前の翻訳ライブラリを書くことにしたのかを順番に話していく。

なぜ i18next を使わなかったのか

最初に多言語対応を考えたとき、もちろん業界標準の i18nextreact-i18next を候補に入れた。

でも最終的には、AIがメンテしやすいことを最優先にした。i18next は強力だけど、APIの種類が多すぎて、LLM が妄想したり一貫性のないコードを書いたりしがち。そこで、ライブラリの機能をシンプルな t()interpolate() に絞ることで、10体以上のエージェントを並列で走らせても、ほぼ人間の手を入れずに、100%型安全なコードだけを書かせられるようにした。

それに、大きなエコシステムにどっぷり乗っかって、あとから破壊的変更を食らうのも怖かった。過去に React Router v5MUI v4 → v5 のつらい移行で痛い目を見ていて、JavaScript界隈では後方互換をバキバキに壊してくるのが本当に日常茶飯事だと身にしみてる。将来「複数形対応」を足すコストより、いま 13.9 万行のコードを手作業で移行するコストのほうがよっぽど高い。

だから、死ぬほどシンプルで、めちゃくちゃ軽くて、チームのニーズにぴったりハマるものが欲しかった。

で、自分で書いた。

高精度で自律的な AI リファクタリングができるように設計した、3 KB くらいのミニマルなサブセットを作った。おかげで、1人のエンジニアなのに、5人チームが3週間かけてやるような仕事を3日でやりきれた。

自作実装の中身

最終的に、gzip してだいたい 3 KB におさまるミニマルな 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;
  
  // key に '/' が含まれているかどうかをチェック。含まれていれば 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();
  
  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言語分をまるごと配信しないようにする
  • commonmiscgames/{gameId} みたいな「namespace」ごとに翻訳をコードスプリットする
  • すべてが正しく配線されているか確認するために、生のキーを表示する「debug」ロケールを用意

この仕組みをずっとメンテしやすく保つために、shared/src/i18n/README.md にかなりしっかりしたドキュメントも書いた。ファイル構成から、クライアント・サーバー両方での使い方の例まで全部入り。標準ライブラリを使っているわけじゃないので、こういうリファレンスがあるかどうかは、新しいメンバーをオンボーディングするときにも(ついでに未来の自分や LLM に思い出させるときにも)めちゃくちゃ重要。

数字で見た今回の変更

どれくらい大きなアップデートだったのか、コードベースで起きた変化を数字でまとめてみる:

  • 20 言語に対応(開発用の debug ロケールつき)
  • ロケールファイル 360 個を新規作成
  • 翻訳用コードの行数 139,031
  • クライアント全体で t() の呼び出しを 3,938 箇所追加
  • ソースファイル 728 個を変更
  • ソース・オブ・トゥルースになる英語ファイルが 18 個(ゲーム16本分 + common + misc)

エージェントたちのオーケストレーション

これを全部手作業でやっていたら、何か月もかかるうえに完全に作業ゲーになっていたはず。そこで、十数体の Cursor エージェントを同時に走らせて、重いところを全部やらせることにした。

まずはフォルダ単位でコードベースを「セクション」に分割した。Foony 上のそれぞれのゲームに専用フォルダと専用の翻訳 namespace を割り当てるイメージ。こうしておけば、実際に遊んでいるゲームの翻訳しか読み込まれないので、初回ロードのサイズを小さく保てる。

Cursor のエージェントは複数同時に走らせた。各エージェントには「チェスゲームを翻訳対応にする」みたいに特定のセクションを丸ごと任せて、ファイルを1つずつ見ていき、ユーザーに見える文字列を探して t('games/chess/some.key') に置き換えてもらう。

それからエージェントは、そのキーを対応する英語のロケールファイルに追加しつつ、その文字列が「何で」「どこで」使われているのかを JSDoc コメントに書き添える。このコンテキストは他言語への翻訳を生成するときにすごく重要で、「Save」が「ゲームの設定を保存」なのか「お絵かきゲームの絵を保存」なのかを LLM にちゃんと理解させる助けになる。

品質チェック

生成されたコードは全部ざっとチェックした。エージェントたちは思ったより優秀だったけど、それでもたまに return のあとに useTranslation フックを書いちゃう、みたいなミスはあった。

ここでめちゃくちゃ役に立ったのが、ガチガチに型付けされた翻訳システム。これのおかげで、各ロケールの翻訳が「必要なキーは全部そろっていて」「余計なキーは入っていない」状態であることが保証される。さらに、t()interpolate() の呼び出しが、ちゃんと存在する翻訳キーだけを使うように強制される。

型システムは、英語のソースファイルから取りうる翻訳キーを全部かき集めて型にしている:

/**
 * ネストしたオブジェクト型から取りうるすべてのパスを取り出し、ドット記法のキーを作る。
 * 例: {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つの英語ロケールファイルにつき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⟧ みたいに見えるようになっていて、翻訳漏れを見つけるのがすごく楽になる。

最後に、別のエージェントに /{locale}/** 形式のルーティングを実装してもらって、/ja/games/chess みたいなパスならちゃんと日本語版に飛ぶようにした。

ブログの翻訳

UI の文字列を翻訳するだけならまだいいけど、「ブログ記事はどうするの?」って話もある。ブログ記事ごとにさらにエージェントを量産・管理するのはさすがにやりたくなかった。

そこで、まずはエージェントに scripts/src/generateBlogTranslations.ts というスクリプトを書いてもらって、ブログ翻訳の流れをまるごと自動化することにした。

仕組みはこんな感じ:

  1. client/src/posts/en ディレクトリを走査して、英語の MDX ファイルを探す。
  2. 他のロケール用フォルダ(例: posts/ja, posts/es)に未翻訳のファイルがないかチェックする。
  3. 翻訳がなかった場合は、その英語コンテンツを読み込んで、Markdown の書式を保ったまま翻訳するように指示したプロンプトと一緒に Gemini 3 Pro Preview に流し込む。
  4. 生成された翻訳ファイルを、対応するロケールのフォルダに保存する。

フロントエンド側では、import.meta.glob を使ってこれらの MDX ファイルを全部動的インポートしている。PostPage コンポーネントは、ユーザーの現在のロケールをチェックして、対応する MDX ファイルを遅延読み込みするだけ。もし翻訳がまだなかった場合(まだスクリプトを回していない場合)は、きれいに英語版にフォールバックする。

まとめ

ここまでやって、ついに20ロケール全部に対応したフル機能のサイトが完成した!

なかなか濃すぎる3日間だったけど、そのおかげで世界中のユーザーに(ほぼ)ネイティブに感じてもらえるフルローカライズ済みのサイトが手に入った。自前で軽量なライブラリを作り、退屈なリファクタリング作業を AI エージェントに任せることで、1年前ならまず無理だったであろう「複雑なサイトのフル i18n を、1人で3日でやり切る」というチャレンジを達成できた。これからのプログラミングは、ひたすら速くコードを書くことじゃない。AI エージェントたちをうまく指揮して、そのアウトプットを見極められるだけのドメイン知識を持つことこそが鍵になっていく。

8 Ball Pool online multiplayer billiards icon