Claude Codeでi18nextのJSON翻訳を進める — キーと変数の過不足を検査
i18nextのUI文字列JSONをClaude Codeで翻訳し、言語ファイル間のキーの欠落・余剰と{{変数}}の改変を検査スクリプトで止める手順です。Stop hookで検査を完了条件にします。
i18nextのJSONをClaude Codeに訳させるときの最大の穴は、訳文の質ではなく「訳し終えたつもりで、キーが足りない・余っている・{{name}} が書き換わっている」状態が黙って残ることです。これはJSONが構文として正しければ、アプリが起動してしまうために見つかりません。
対策は、言語ファイル同士を突き合わせる検査スクリプトを1本置き、Claude Codeが終了する前にそれを必ず通すことです。本記事では、依存パッケージなしのNode.jsスクリプト、CLAUDE.md の規約、Stop hookの設定、非対話実行の4点を順に組みます。
翻訳で壊れるのは「文」ではなく「構造」
i18nextの翻訳ファイルは、キーと文字列の入れ子JSONです。翻訳作業で起きる不整合は、次の4種類に整理できます。
言語ファイル間で起きる4種類の不整合
キーの欠落
基準言語にあるキーが訳側にありません。
fallbackLngを設定していると、訳側の画面に基準言語の文字列が混ざって表示されます。キーの余剰
基準言語で消したキーが、訳側にだけ残ります。実害は小さいものの、ファイルが肥大し、次の翻訳者が迷います。
変数の改変
{{name}}の中身を訳してしまう、閉じ括弧を落とすといった事故です。表示時に値が差し込まれず、{{namae}}がそのまま出ます。複数形の接尾辞違い
items_oneとitems_otherのような複数形キーは、言語ごとに必要な接尾辞が異なります。英語の形をそのまま写すと過不足が出ます。
変数まわりの根拠は、i18nextの補間の仕様にあります。既定では二重の波括弧で囲んだ名前が補間の対象になり、"key": "{{what}} is {{how}}" に { what: 'i18next', how: 'great' } を渡すと i18next is great が返ります。波括弧の中の名前は、呼び出し側のコードが渡すオブジェクトのキーと一致していなければなりません。訳文の側で名前を変えると、値は差し込まれません。
複数形も同じです。複数形のキーは key_one / key_other の形で書き、アラビア語のように _zero _one _two _few _many _other の6つが要る言語もあります。必要な接尾辞は言語で決まるため、基準言語のキー一覧をそのまま訳側に要求すると、日本語のように形の少ない言語では余剰が出てしまいます。
言語間の過不足を見るスクリプトを置く
検査は「基準言語のキー集合」と「訳側のキー集合」を比べる作業です。コード側の使用キーと突き合わせる抽出ツールとは見る方向が違い、こちらは言語ファイル同士の比較です。次のスクリプトを scripts/check-i18n.mjs に置きます。
// 使い方: node scripts/check-i18n.mjs <基準言語> <対象言語>... (終了コード 1 = 不整合あり)
import fs from 'node:fs';
import path from 'node:path';
const [base, ...targets] = process.argv.slice(2);
const dir = 'locales';
const flatten = (obj, prefix = '') =>
Object.entries(obj).flatMap(([k, v]) =>
typeof v === 'object' && v !== null ? flatten(v, `${prefix}${k}.`) : [[`${prefix}${k}`, String(v)]]);
const vars = (s) => [...s.matchAll(/\{\{\s*-?\s*([\w.]+)[^}]*\}\}|\$t\(([^)]+)\)/g)]
.map((m) => m[1] ?? `$t(${m[2]})`).sort();
// 複数形キー(foo_one / foo_other)は、語幹と、その言語の
// Intl.PluralRules が返すカテゴリで比べる
const PLURAL = /_(zero|one|two|few|many|other)$/;
const stem = (k) => k.replace(PLURAL, '');
let failed = false;
for (const file of fs.readdirSync(path.join(dir, base))) {
const src = new Map(flatten(JSON.parse(fs.readFileSync(path.join(dir, base, file), 'utf8'))));
for (const lng of targets) {
const p = path.join(dir, lng, file);
const dst = new Map(fs.existsSync(p) ? flatten(JSON.parse(fs.readFileSync(p, 'utf8'))) : []);
const cats = new Set(new Intl.PluralRules(lng).resolvedOptions().pluralCategories);
const expected = new Set();
for (const k of src.keys()) {
if (!PLURAL.test(k)) expected.add(k);
else for (const c of cats) expected.add(`${stem(k)}_${c}`);
}
for (const k of expected) {
if (!dst.has(k)) { console.log(`[${lng}/${file}] 欠落: ${k}`); failed = true; }
}
for (const k of dst.keys()) {
if (!expected.has(k)) { console.log(`[${lng}/${file}] 余剰: ${k}`); failed = true; }
}
for (const [k, v] of dst) {
if (v.trim() === '') { console.log(`[${lng}/${file}] 空文字: ${k}`); failed = true; continue; }
const sk = src.has(k) ? k : [...src.keys()].find((x) => stem(x) === stem(k) && x.endsWith('_other'));
if (!sk) continue;
const a = vars(src.get(sk)).join(','), b = vars(v).join(',');
if (a !== b) { console.log(`[${lng}/${file}] 変数不一致: ${k} 基準={${a}} 訳={${b}}`); failed = true; }
}
}
}
if (!failed) console.log('OK: キーと変数は一致しています');
process.exit(failed ? 1 : 0);設計上の要点は3つです。
- ネストしたキーは
cart.items_oneのようにドット区切りで平らにして比べる - 変数は
{{name}}と$t(...)の入れ子参照を取り出し、並べ替えて集合として比較する(語順が変わる言語でも誤検知しない) - 複数形は、訳側の言語の
Intl.PluralRulesが返すカテゴリで期待キーを作る
三つ目の根拠として、i18nextの複数形のドキュメントは、複数形の判定に Intl.PluralRules を使うこと、サポートの無い環境ではポリフィルが要ることを述べています。同じ関数でカテゴリを列挙すれば、i18nextが探すキーと検査が探すキーが揃います。
空文字の検査も入れてあります。i18nextの設定項目 returnEmptyString の既定値は true で、空文字も有効な翻訳として扱われます。つまり未翻訳のプレースホルダが空文字のまま残ると、画面が空白になるだけで警告は出ません。
実行するとどう出るか
欠落・余剰・変数違いをわざと仕込んだ英日のペアで走らせた出力です。Node.js v20.18.0で実行しました。
基準の locales/en/common.json には greeting、cart.title、cart.items_one、cart.items_other、saveButton、welcomeBack の6キーを置きました。日本語側の locales/ja/common.json には saveButton と welcomeBack を置かず、oldLabel を余らせ、greeting の変数名を {{namae}} に変えています。
node scripts/check-i18n.mjs en ja
echo "exit=$?"[ja/common.json] 欠落: saveButton
[ja/common.json] 欠落: welcomeBack
[ja/common.json] 余剰: oldLabel
[ja/common.json] 変数不一致: greeting 基準={name} 訳={namae}
exit=1注目したいのは、日本語側に cart.items_one が無いのに欠落として報告されていない点です。同じNode.jsで new Intl.PluralRules('ja').resolvedOptions().pluralCategories を評価すると [ 'other' ]、'en' では [ 'one', 'other' ] が返りました。日本語に _one を要求しないので、英語の形を写し込んだ余剰キーも検出できます。
CLAUDE.mdに翻訳の規約を書く
スクリプトが検査を担うので、CLAUDE.md には「何を守り、どう確かめるか」だけを置きます。CLAUDE.md はプロジェクトのルートに置くMarkdownで、Claude Codeがセッション開始時に読み込みます。
## 翻訳(i18next)
- 基準言語は `locales/en/`。訳は `locales/ja/` に同じファイル名・同じキー構造で書く
- `{{...}}` と `$t(...)` の中身は一切訳さない・並べ替えない。語順は文に合わせて動かしてよい
- 複数形キーは対象言語に必要な接尾辞だけ書く(日本語は `_other` のみ)
- 値を空文字にしない。訳せない場合は基準言語の文字列をそのまま残さず、確認事項として報告する
- 翻訳を書いたら、必ず `node scripts/check-i18n.mjs en ja` を実行し、
出力が `OK` になるまで直す
- 基準言語のキーを追加・削除・改名するのは翻訳作業の範囲外。見つけたら報告だけする最後の項目が効きます。基準言語まで直すことを許すと、「検査が通らないので基準側のキーを消して揃えた」という逆向きの修正が起きえます。検査の基準を動かせないようにしておきます。
訳す対象は、まとめて渡すより差分に絞るほうが検査と相性がよくなります。「欠落と報告されたキーだけを訳す」という指示にすると、既存の訳文を無駄に書き換えません。
Stop hookで「検査が通るまで終わらない」にする
規約を書いても、Claude Codeが検査を実行し忘れる余地は残ります。そこで、応答を終えようとした時点で検査を走らせるStop hookを .claude/settings.json に置きます。
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "sh -c 'node scripts/check-i18n.mjs en ja >&2 || exit 2'"
}
]
}
]
}
}hookの仕様上の根拠は次のとおりです。Stop hookはClaude Codeが応答を終えるたびに走ります。終了コード2で終われば、Claudeは停止せず会話を続け、標準エラー出力の内容が「なぜ続けるのか」の説明として渡されます。スクリプトの失敗は終了コード1なので、|| exit 2 で2に読み替えています。検査の出力を >&2 で標準エラーに回しているのも同じ理由で、欠落したキーの一覧がそのままClaudeへのフィードバックになります。
注意点がいくつかあります。
- Stop hookは作業が完了したときだけでなく、Claudeが応答を終えるたびに走ります。質問を返しただけの応答でも検査が動くため、検査は軽く保ちます
- ユーザーが中断したときは発火しません
- 無限ループを避けるため、Stop hookの入力には
stop_hook_activeが含まれます。ブロックによる継続中かどうかを判定でき、連続して継続できる回数にも上限(8回)があります
この構成では、検査が通らない限りClaude Codeは翻訳を終えられません。逆に、訳せない文字列があるときは、通すために空文字を入れたくなる誘惑が生まれます。規約に「空文字禁止」を書き、スクリプトにも空文字の検査を入れているのは、この抜け道を塞ぐためです。
非対話で新規キーの翻訳を流す
基準言語にキーが増えるたびに、対話セッションを開くのは手間です。claude -p で非対話実行すれば、スクリプトやCIから翻訳を依頼できます。Claude Codeの概要ページにも、claude -p "translate new strings into French and raise a PR for review" という形の使用例が載っています。
claude -p "locales/en の追加キーを locales/ja に訳して。\
検査で欠落と出たキーだけを対象にする" \
--allowedTools "Read,Edit,Bash(node scripts/check-i18n.mjs *)"--allowedTools は、確認なしで使えるツールを指定する権限ルールです。末尾に半角スペースと * を付けると前方一致になり、Bash(node scripts/check-i18n.mjs *) は検査スクリプトだけを許可します。ファイル編集と検査の実行だけを許す構成にすれば、非対話でも他のコマンドは動きません。
ここでもStop hookが検査を課すので、-p の実行は検査が通るまで続きます。実行後に自分でも確認したいときは、次のコマンドを足します。
node scripts/check-i18n.mjs en ja && git diff --stat locales/差分の行数を見て、訳したはずのないファイルが変わっていないかを確かめます。
コード側の抽出ツールと役割を分ける
言語間の比較は本記事のスクリプトが担いますが、「コードで使っているキーがJSONにあるか」は別の検査です。i18nextの公式リポジトリには、この領域を扱う i18next-cli があります。キー抽出、ロケールの同期、リンティング、翻訳の完成度レポートを1つのCLIにまとめたものです。
npx i18next-cli status— 翻訳の完成度を表示し、未翻訳があれば非ゼロで終了するnpx i18next-cli extract --ci— 抽出で更新が発生したら非ゼロで終了する(CI向け)npx i18next-cli status --unused— 使われていないキーだけを報告し、ファイルは変更しない
動的に組み立てたキー(テンプレートリテラルで作るキー)は静的解析では追えない、とも書かれています。こうしたキーを多用するプロジェクトでは、抽出ツールの「未使用」判定を鵜呑みにしないほうが安全です。
以前の定番だった i18next-parser は、READMEの冒頭に2025年9月以降は保守されていないこと、i18next-cli への移行を勧めることが書かれています。--fail-on-update のようなCI向けオプションの情報を検索で見つけたときは、どちらのツールの話かを先に見分けてください。
使い分けの目安は次のとおりです。
| 見たいこと | 使うもの |
|---|---|
| コードにあるキーが基準言語のJSONに入っているか | 使うものi18next-cli extract --ci |
| 基準言語と訳側のキー・変数・複数形が揃っているか | 使うもの本記事のスクリプト |
| 未使用キーが残っていないか | 使うものi18next-cli status --unused |
両方をStop hookやCIに並べても構いません。コードと基準言語のずれは抽出ツールが、基準言語と訳のずれはスクリプトが受け持ちます。
つまずきやすい点
落とし穴
変数名を訳してしまう
{{name}} を {{名前}} に訳すと、呼び出し側のオブジェクトのキーと一致せず、値が差し込まれません。スクリプトの変数一致検査は、この種の事故を機械的に止めます。規約にも「波括弧の中は訳さない」と書いておくと、最初から起きにくくなります。
- 値のエスケープ指定を落とす: 補間は既定でXSS対策としてエスケープされ、
{{- myVar}}のようにハイフンを付けるとエスケープが外れます。スクリプトは正規表現でハイフンの有無を無視して変数名だけを比べるため、ハイフンが落ちても検出されません。HTMLを含む値を扱う文言では、差分レビューで見るべき箇所です - ネームスペースの分割:
locales/<言語>/<ネームスペース>.jsonの構成を前提にしています。ネームスペースをnsオプションで分けている場合、ファイルごとに比較される点は変わりませんが、ファイル自体が訳側に無いと全キーが欠落として並びます - 言語コードの書式: スクリプトはディレクトリ名をそのまま
Intl.PluralRulesに渡します。i18nextの設定項目lngの説明は、en-USの形式を使い、アンダースコアなどは避けるよう求めています。ディレクトリ名もハイフン区切りに揃えると、両者がずれません - JSON形式のバージョン: 複数形の接尾辞が
_one/_otherの形になったのはi18next v21のJSON形式v4からです。v21より前の形式のファイルが残るプロジェクトは、先にv4へ変換しないと検査の前提が成り立ちません
状況別の進め方
- キーが数百行まで: スクリプトとCLAUDE.mdだけで十分です。Stop hookは任意です
- 複数の言語を同時に追加する:
node scripts/check-i18n.mjs en ja de frのように対象言語を並べられます。言語ごとに別のClaude Codeセッションで進めると、訳文の文脈が混ざりません - 翻訳管理サービスを使っている: 翻訳の正本が外部にある場合は、検査の対象をダウンロード後のJSONに置きます。サービス側の同期コマンドを実行してから検査を走らせます
- ドキュメントサイトの多言語化も同時に進めている: Docusaurusの翻訳ファイルとリンク切れ検査は、Claude CodeでDocusaurusサイトを作るに手順があります
検査を完了条件にする考え方は、翻訳以外にも使えます。型生成を完了条件にする例はReact Router v7のtypegen、テストクライアントを使う例はFlaskのpytestの記事にあります。
まとめ
i18nextのJSON翻訳で問題になるのは、訳文の巧拙よりも、キー・変数・複数形の構造が基準言語から静かにずれることです。ずれはアプリの起動では見つからず、画面に未翻訳の文字列や {{namae}} が出て初めて分かります。
Claude Codeに任せる範囲を「差分のキーを訳す」に絞り、言語間の比較スクリプトをStop hookに載せると、構造のずれは訳した直後に潰れます。基準言語を動かさない規約と、空文字を許さない検査がセットで、はじめて「通すための抜け道」が塞がります。