Claude Codeでハードコード文字列を抽出しi18nキー化する — lintで漏れを検出
JSXに直書きされた日本語をeslint-plugin-i18nextのno-literal-stringで洗い出し、Claude Codeでキー化する手順です。変数埋め込み・属性・条件式の扱いを検査で確かめます。
画面に日本語が直書きされたReactアプリを多言語化するとき、最初の難所は翻訳ではなく「どこに文字列が残っているか」を漏れなく数えることです。Claude Codeに「ハードコードの日本語を全部i18n化して」と頼むと、拾い漏れや、変数を含む文の分断が起きます。先にlintで残りを数えられる状態を作り、その数字がゼロになるまでClaude Codeに回してもらう進め方が安定します。
この記事では eslint-plugin-i18next の no-literal-string ルールで直書きを洗い出し、JSXの文字列をキー化する手順を扱います。変数埋め込み・属性・条件式がそれぞれどう検出され、どうキー化されるかも、手元で動かした結果で確かめます。
lintを物差しにして、Claude Codeに任せる範囲を決める
no-literal-string は、翻訳を通さずに利用者へ見せる文字列を直書きしていないかを検査するルールです。公式の説明どおり、自動修正は用意されていません。i18next.t(key) のキーは元の文字列と一致しないのが普通で、機械的に置換できないためです。
自動修正がないことは、役割分担の根拠になります。
- 検出はlintが担う。数が機械的に出るので、漏れが数字で分かる
- キー名の決定と置換はClaude Codeが担う。文脈を読んで意味のあるキーを付けられる
- 完了の判定はlintの終了コードとキー照合のスクリプトが担う
Claude Code側の頼み方は、公式ワークフロー集の「小さく、テストできる単位で進める」「編集前にプランを見る」と同じ考え方です。大きな画面を一度に変えず、ディレクトリ単位でlintの件数を減らしていきます。分割の単位の考え方は、Claude Codeで巨大関数を分割する手順の「1コミット1抽出」と共通です。
ESLint 9のflat configで有効化する
インストールと設定は短く済みます。ESLint 9のflat configでは、プラグインの flat/recommended を配列に足すだけです。
npm install --save-dev eslint-plugin-i18next// eslint.config.mjs
import i18next from 'eslint-plugin-i18next';
import tsParser from '@typescript-eslint/parser';
export default [
{
files: ['src/**/*.{ts,tsx}'],
languageOptions: {
parser: tsParser,
parserOptions: { ecmaFeatures: { jsx: true } },
},
},
{
files: ['src/**/*.{ts,tsx}'],
...i18next.configs['flat/recommended'],
rules: {
'i18next/no-literal-string': [
'error',
{ mode: 'jsx-only', 'should-validate-template': true },
],
},
},
];ESLint 8以前の .eslintrc では "extends": ["plugin:i18next/recommended"] を使います。TypeScriptのJSXを読むには、パーサーの指定が別途要ります。上の例では @typescript-eslint/parser を使っています。
rulesの mode を指定しているのは意図的です。既定のままだと検出範囲がかなり狭いからです。次の節で違いを見ます。
modeによって、検出される範囲が変わる
検出範囲は mode オプションで決まります。ルールの説明では、既定は jsx-text-only でJSXのテキストだけを禁じ、jsx-only はJSX属性まで、all は全リテラル文字列を対象にします。
どこまで見つかるかを確かめるため、次のコンポーネントで3つのmodeを試しました。プラグイン6.1.5、ESLint 9.39.5での結果です。
export function App({ name, count, admin }) {
return (
<div>
<h1>ようこそ</h1>
<p>こんにちは、{name}さん</p>
<p>未読が{count}件あります</p>
<input placeholder="検索" aria-label="検索ボックス" />
<button title="保存する">保存</button>
<span>{admin ? '管理者' : '一般'}</span>
<p>{`合計: ${count}`}</p>
<img src="/a.png" alt="ロゴ" />
</div>
);
}| 直書きの形 | 既定(jsx-text-only) | jsx-only | all |
|---|---|---|---|
<h1>ようこそ</h1> | 既定(jsx-text-only)検出 | jsx-only検出 | all検出 |
placeholder / aria-label / title / alt | 既定(jsx-text-only)検出なし | jsx-only検出 | all検出 |
三項演算子の '管理者' : '一般' | 既定(jsx-text-only)検出なし | jsx-only検出 | all検出 |
| テンプレートリテラル | 既定(jsx-text-only)検出なし | jsx-only検出(※) | all検出(※) |
JSX外の .ts の三項演算子 | 既定(jsx-text-only)検出なし | jsx-only検出なし | all検出 |
※ should-validate-template: true を付けた状態での結果です。
既定のままでは、placeholder も aria-label も alt も報告されません。lintが0件でも、読み上げ用の aria-label に日本語が残ります。最初から jsx-only にするのが安全です。画面外のロジック(通知メッセージやバリデーションの文言)に日本語が散らばっているなら、all でJSX外まで広げます。ただし all は静的な識別子や設定値まで拾い始めるので、報告が増えます。
手元の all では、https://example.com/api のようなURL文字列は報告されませんでした。一方で .ts ファイル内の n > 0 ? '残りあり' : '空です' は、2つの分岐がそれぞれ別の報告になりました。
変数を含む文は、1文を1キーにする
既定のmodeで実行した出力から、キー化で最も崩れやすい箇所が分かります。
5:10 error disallow literal string: <p>こんにちは、{name}さん</p>
5:22 error disallow literal string: <p>こんにちは、{name}さん</p>
6:10 error disallow literal string: <p>未読が{count}件あります</p>
6:20 error disallow literal string: <p>未読が{count}件あります</p>こんにちは、{name}さん は、桁位置の違う2件で報告されます。「こんにちは、」と「さん」が別のテキストノードだからです。lintの件数のまま機械的に直すと、t('hello') と t('san') の2キーに分断され、{name} が文の途中に挟まる形になります。
語順が変わる言語では、この分断は翻訳できません。英語なら Hello, {{name}} と書く場面で、断片を連結する構造にすると、訳側が語順を決められなくなります。正しい直し方は、1つの文を1つのキーにして、変数をプレースホルダーにすることです。
<p>{t('app.greeting', { name })}</p>{
"app.greeting": "こんにちは、{{name}}さん",
"app.unread_other": "未読が{{count}}件あります"
}数量を表す変数は、i18nextの複数形の仕組みに乗せます。日本語の複数形カテゴリは other だけなので、キーは app.unread_other になります。コードからは t('app.unread', { count }) と呼びます。JSONのキーとコードのキーが _other の有無でずれるため、後述の照合スクリプトでは接尾辞を外して比較します。
言語間でキーと変数の過不足を見る検査は、i18nextのJSON翻訳の記事に書いた内容がそのまま使えます。この記事は、その前段にあたるコード側の工程です。
属性と条件式は、キーを分けて残す
属性は、検出された文字列を1つずつ別のキーにします。placeholder="検索" と aria-label="検索ボックス" は同じ「検索」でも用途が違うため、同じ値を2キーに分けるほうが訳しやすくなります。画面上の見出しと読み上げ用ラベルで、訳文が変わる言語があるからです。
<input
placeholder={t('app.search.placeholder')}
aria-label={t('app.search.label')}
/>
<button title={t('app.save.hint')}>{t('app.save.label')}</button>
<img src="/a.png" alt={t('app.logo.alt')} />テストで使う data-testid のように、翻訳しない属性は除外に回します。no-literal-string の jsx-attributes オプションに exclude を書くと、その属性名は報告から外れます。値は正規表現として扱われます。
{
mode: 'jsx-only',
'jsx-attributes': { exclude: ['data-testid', 'className'] },
}条件式は、分岐ごとにキーを持たせます。
<span>{admin ? t('app.role.admin') : t('app.role.member')}</span>ここで t(app.role.$) のようにキーを動的に組み立てる書き方は、Claude Codeが「すっきりさせた」つもりで提案しがちです。しかし動的キーは、後からコードを検索してもキーの使用箇所が見つからなくなります。抽出ツールの未使用キー判定も追えません。分岐が少ないうちは、キーを静的な文字列にしておきます。
Claude Codeへの頼み方と、CLAUDE.mdに置く規約
ここまでの判断を、Claude Codeに毎回説明する必要はありません。CLAUDE.mdに規約として書いておきます。
## 文字列のi18n化
- 画面に出す文字列は `t('...')` を通す。直書きを残さない
- 検出はプロジェクトに設定済みの `npx eslint src` で行う。ルールを都合よく緩めない
- 変数を含む文は断片に分けず、1文を1キーにして `{{name}}` を使う
- キーは `画面.要素.用途` の静的な文字列にする。テンプレートリテラルで組み立てない
- 属性(placeholder / aria-label / title / alt)も別キーにする
- 1回の作業は1ディレクトリまで。終わったら `npx eslint <dir>` と `node check.mjs` を実行する実際の依頼は、まず編集なしでプランを出させます。公式ワークフロー集によると、plan modeはClaudeがファイルを読んでプランを提案するだけで、承認するまで編集しません。起動は claude --permission-mode plan か、セッション中の Shift+Tab です。
claude --permission-mode planプロンプトは、対象と終了条件を1つずつ書きます。
src/components/profile 配下の直書き日本語を i18n 化したい。
まず npx eslint src/components/profile の報告を種類別(JSXテキスト / 属性 / 条件式)に
分けて、各キー名の案と一緒に表で出して。変数を含む文は1文1キーにすること。プランを承認したら、ja.json へのキー追加とJSXの置換を実行させ、その場で npx eslint src/components/profile を再実行させます。件数がゼロにならない間は次のディレクトリへ進みません。検査をStop hookに組み込み、通るまで終了させない構成は、Docusaurusの日本語i18nの記事に具体例があります。
キーの過不足をテストで確認する
lintがゼロでも、キーの付け間違いは残ります。コードで t('app.greeting') と呼んでいるのに、JSONにはそのキーが無い状態です。このとき t は、キーの文字列をそのまま画面に出します。
次のスクリプトは、ソースから t('...') のキーを拾い、JSONと突き合わせます。複数形の _other は外して比較し、i18nextで実際に文字列を組み立てて出力も確かめます。
// check.mjs
import i18next from 'i18next';
import { readFileSync } from 'node:fs';
const ja = JSON.parse(readFileSync('locales/ja.json', 'utf8'));
await i18next.init({
lng: 'ja',
resources: { ja: { translation: ja } },
keySeparator: false,
nsSeparator: false,
});
const src = readFileSync('src/App.tsx', 'utf8');
const used = [...src.matchAll(/t\('([^']+)'/g)].map((m) => m[1]);
const have = new Set(Object.keys(ja).map((k) => k.replace(/_other$/, '')));
const missing = used.filter((k) => !have.has(k));
console.log('used', used.length, 'missing', missing);
console.log(i18next.t('app.greeting', { name: '太郎' }));
console.log(i18next.t('app.unread', { count: 3 }));
if (missing.length) process.exit(1);キーをドット区切りの平坦な文字列として扱うため、keySeparator: false を指定しています。入れ子のJSONにする場合は、この指定を外して照合側も階層をたどるよう直します。
手元で、置換後のコンポーネントに対して eslint と check.mjs を実行した結果です。i18next 25.10.10を使いました。
$ node check.mjs
used 11 missing []
こんにちは、太郎さん
未読が3件あります
$ npx eslint src/App.tsx
$ echo $?
011キーすべてがJSXにもJSONにもあり、変数を含む2文が意図どおりに組み上がっています。eslintの報告もありません。Claude Codeに見せる終了条件は、この「missingが空、かつeslintが終了コード0」の2つです。
なお、自分で t('...') を呼ぶ正規表現は単純な照合です。t を別名で受けている、t に渡すキーを変数で持っている、といったコードでは取りこぼします。本格的に運用するなら、i18nextのJSON翻訳の記事で触れたキー抽出ツールに任せ、このスクリプトは最小の検査として使い分けます。
つまずきやすい点
Trans の中身は検出されない。 Trans コンポーネントは既定で除外対象です。<Trans i18nKey="greeting">Hello World</Trans> の子の文字列は報告されません。直書きが残っているのにlintが通る原因になるので、Trans を使っているコードは別途目で確認します。
自動修正を探さない。公式の説明どおり、キーが元の文字列と一致しないためautofixは無効です。置換はClaude Codeに任せる前提で進めます。
既定のmodeは属性を見ない。先の表のとおり、alt や aria-label の日本語は jsx-text-only では報告されません。「lintが通ったのに、スクリーンリーダーで日本語が読まれる」という状況は、modeの設定漏れが原因のことが多いです。
JSX外の日本語は別に探す。トースト通知、バリデーションのエラーメッセージ、Error のメッセージなどは、JSXのテキストではありません。mode: 'all' で一度洗い出して、画面に出るものだけをキー化します。callees オプションに exclude を書けば、new Error() のようにログ専用の呼び出しを報告から外せます。
まとめて直すと差分が読めなくなる。1回のコミットに数十ファイルの置換が混ざると、レビューで誤訳以前に「どのキーがどの文字列か」を追えません。ディレクトリ単位でlintの件数を減らし、1ディレクトリごとにコミットすると、戻すときも楽です。
状況別の進め方
| 状況 | 進め方 |
|---|---|
| 画面が数個の小さなアプリ | 進め方jsx-only で全体を洗い出し、プランを出させて一括で置換 |
| 画面が多い既存アプリ | 進め方ディレクトリ単位でlintの件数を減らし、1ディレクトリ1コミット |
| 通知やエラーの日本語がJSX外に多い | 進め方mode: 'all' で一度洗い出して、画面に出るものだけキー化 |
すでに t() が混在している | 進め方既存キーの命名規則をCLAUDE.mdに書いてから進める |
まとめ
ハードコードの日本語は、まずlintで数えます。no-literal-string は既定だとJSXのテキストしか見ないので、jsx-only へ上げてから洗い出すと、属性や条件式の取りこぼしを避けられます。キー化では、変数を含む文を分断せず1文1キーにすることが、翻訳できる構造を保つ分かれ目です。
Claude Codeには、規約をCLAUDE.mdに書き、plan modeで案を見てから、ディレクトリ単位で置換させます。終了条件は、lintの終了コード0とキー照合の空リストです。この2つが揃えば、翻訳ファイルの作成と言語間の過不足検査という次の工程に進めます。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。