Claude Codeで疑似ローカライズ — 未翻訳とレイアウトはみ出しを見つける
アクセント付き・約30%伸長の疑似ロケールをClaude Codeで作り、Next.jsアプリの直書き文字列とレイアウトのはみ出しを、翻訳を発注する前に見つける手順です。
翻訳を発注してから「ボタンの文字が枠からはみ出す」「一部の画面だけ英語のまま」と気づくと、直すたびに翻訳会社とのやり取りが発生します。疑似ローカライズ(pseudo-localization)は、その確認を発注前に済ませるための手法です。
英語の文言を機械的に加工して、実際の翻訳版に近い崩れ方をさせた「疑似ロケール」を1つ作ります。加工されていない文字は翻訳の対象外、枠からはみ出した文字は伸長に弱いUI、と読めるようになります。本記事では、Next.js + next-intlのアプリを例に、疑似ロケールの生成、未訳文字列の検出、はみ出しの検出、修正までをClaude Codeと一緒に進める手順を組みます。
疑似ロケールで何が見えるのか
疑似ロケールとは、翻訳時に起きるUIやレイアウトの問題を再現するよう設計されたロケールです。Androidの公式ドキュメントは、すべてのローカライズ対象メッセージを、英語として読めるまま自動変換したものと説明しています。変換されずに残った文字は、ソースコードの中で翻訳に回せない文字列を指します。
同じドキュメントのEnglish (XA)は、元の英文に次の3つの加工をします。
English (XA)疑似ロケールの3つの加工
アクセント付与
英語のテキストにラテン文字のアクセントを付けます。読めるまま、加工済みかどうかを見分けられます。
伸長
元の文字列にアクセントなしの文字を足して長くします。長い訳が入ったときの崩れを先に再現するためです。
括弧で囲む
メッセージ単位を括弧で囲み、途切れや文の分割を目で追えるようにします。
もう一方のAR (XB)は、左から右に書く文を右から左の向きにし、元のメッセージの文字順を逆転させます。RTL(右から左)対応の下見に使うもので、アラビア語やヘブライ語を読めなくても進められる点が利点です。
この手法で見つかる問題として、同ドキュメントは次の4領域を挙げています。
| 見つかる問題 | 疑似ロケールでの見え方 |
|---|---|
| 直書き文字列 | 疑似ロケールでの見え方アクセントが付かず、そのままのASCII文字で残る |
| 文字数の増加によるレイアウト崩れ | 疑似ロケールでの見え方枠からあふれる、折り返す、切れる |
| 文字列の連結 | 疑似ロケールでの見え方1つの文が複数の括弧に分かれて表示される |
| 双方向テキスト・RTLの問題 | 疑似ロケールでの見え方要素が反転しない、句読点の位置がずれる |
ここで押さえておきたいのは、Androidの疑似ロケールは端末の言語設定に組み込まれた仕組みで、Webアプリには標準の対応物がない点です。Webでは、自分で疑似ロケールのメッセージファイルを作り、通常のロケールの1つとして登録します。この「作って登録する」部分がClaude Codeに任せやすい作業になります。
疑似ロケールのメッセージを生成する
next-intlでは、メッセージはロケールごとのJSONファイルとして持つのが基本です。messages/en.json のような基準言語のファイルから、messages/en-XA.json を機械的に作ります。
気をつけるのは、変換してはいけない部分です。next-intlのメッセージはICU構文で、Hello {name}! の {name} は実行時に値が差し込まれる引数です。複数形は {count, plural, =0 {no followers yet} =1 {one follower} other {# followers}} の形で書きます。引数名やキーワードにアクセントを付けると、アプリが壊れます。一方で、複数形の各分岐の中にある文は表示される文なので、アクセントを付けないと「未訳」に見えてしまいます。
次のスクリプトは、波括弧の引数とタグはそのまま残し、複数形の分岐の中身だけを再帰的に変換します。文全体は文字数の約30%分の ~ を足して伸ばし、[ と ] で囲みます。30%という伸長率はこのスクリプトでの設定で、公式の規定値ではありません。
// 使い方: node scripts/pseudo-locale.mjs messages/en.json messages/en-XA.json
import fs from 'node:fs';
const MAP = {
a: 'á', b: 'ƀ', c: 'ç', d: 'ð', e: 'é', f: 'ƒ', g: 'ĝ', h: 'ĥ', i: 'í', j: 'ĵ', k: 'ķ', l: 'ļ', m: 'ɱ',
n: 'ñ', o: 'ó', p: 'þ', q: 'ɋ', r: 'ŕ', s: 'š', t: 'ţ', u: 'ú', v: 'ṽ', w: 'ŵ', x: 'ẋ', y: 'ý', z: 'ž',
A: 'Á', B: 'Ɓ', C: 'Ç', D: 'Ð', E: 'É', F: 'Ƒ', G: 'Ĝ', H: 'Ĥ', I: 'Í', J: 'Ĵ', K: 'Ķ', L: 'Ļ', M: 'Ṁ',
N: 'Ñ', O: 'Ó', P: 'Þ', Q: 'Ɋ', R: 'Ŕ', S: 'Š', T: 'Ţ', U: 'Ú', V: 'Ṽ', W: 'Ŵ', X: 'Ẋ', Y: 'Ý', Z: 'Ž',
};
const accent = (s) => [...s].map((c) => MAP[c] ?? c).join('');
// 波括弧ブロックの終端を探す(入れ子対応)
const close = (s, i) => {
let d = 0;
for (; i < s.length; i++) {
if (s[i] === '{') d++;
else if (s[i] === '}' && --d === 0) return i;
}
return -1;
};
// ICUの引数とタグには触れず、表示されるリテラルだけを変換する
function transform(s) {
let out = '', buf = '', i = 0;
const flush = () => { out += accent(buf); buf = ''; };
while (i < s.length) {
if (s[i] === '{') {
const end = close(s, i);
if (end < 0) { buf += s.slice(i); break; }
flush();
const inner = s.slice(i + 1, end);
const m = inner.match(/^(\s*\w+\s*,\s*(?:plural|selectordinal|select)\s*,)([\s\S]*)$/);
if (m) {
let body = '', j = 0;
while (j < m[2].length) {
if (m[2][j] === '{') {
const e = close(m[2], j);
body += '{' + transform(m[2].slice(j + 1, e)) + '}';
j = e + 1;
} else body += m[2][j++];
}
out += '{' + m[1] + body + '}';
} else out += '{' + inner + '}';
i = end + 1;
} else if (s[i] === '<') {
const end = s.indexOf('>', i);
flush();
out += s.slice(i, end + 1);
i = end + 1;
} else buf += s[i++];
}
flush();
return out;
}
const pseudo = (s) => `[${transform(s)} ${'~'.repeat(Math.ceil(s.length * 0.3))}]`;
const walk = (v) =>
typeof v === 'string' ? pseudo(v)
: Array.isArray(v) ? v.map(walk)
: v && typeof v === 'object' ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x)]))
: v;
const [src, dst] = process.argv.slice(2);
fs.writeFileSync(dst, JSON.stringify(walk(JSON.parse(fs.readFileSync(src, 'utf8'))), null, 2) + '\n');次のような基準言語のファイルを用意して、手元のNode.jsで実行してみます。
{
"Cart": {
"title": "Shopping cart",
"greeting": "Hello {name}!",
"items": "You have {count, plural, =0 {no items} =1 {one item} other {# items}} in your cart.",
"terms": "I agree to the <link>terms of service</link>"
}
}このスクリプトで生成した en-XA.json は、次のようになります。
{
"Cart": {
"title": "[Šĥóþþíñĝ çáŕţ ~~~~]",
"greeting": "[Ĥéļļó {name}! ~~~~]",
"items": "[Ýóú ĥáṽé {count, plural, =0 {ñó íţéɱš} =1 {óñé íţéɱ} other {# íţéɱš}} íñ ýóúŕ çáŕţ. ~~~~~~~~~~~~~~~~~~~~~~~~~]",
"terms": "[Í áĝŕéé ţó ţĥé <link>ţéŕɱš óƒ šéŕṽíçé</link> ~~~~~~~~~~~~~~]"
}
}{name}、{count, plural, ...} のキーワード、<link> タグが無傷で、表示される英文だけが加工されています。
Claude Codeには、このスクリプトの下書きを書かせても構いません。ただし「ICUの引数とタグは変換しない」「複数形の分岐の中は変換する」の2点は、頼むときに明示します。省くと、引数まで変換して実行時エラーになるスクリプトが出てきやすい箇所です。
開発中だけ疑似ロケールを登録する
次に、en-XA を通常のロケールとして登録します。next-intlのルーティング設定では、対応するロケールの一覧を locales に、フォールバック先を defaultLocale に書きます。開発時だけ配列に加えれば、本番に疑似ロケールが出ることはありません。
// src/i18n/routing.ts
import {defineRouting} from 'next-intl/routing';
export const routing = defineRouting({
locales:
process.env.NODE_ENV === 'production'
? ['en', 'ja']
: ['en', 'ja', 'en-XA'],
defaultLocale: 'en'
});メッセージの読み込み側は、他のロケールと同じ扱いでかまいません。messages/en-XA.json は messages/ja.json と同じ置き場所に並べるだけです。ここまでで、開発サーバーの /en-XA/cart のようなURLが疑似ロケールの画面になります。
なお、messages/en-XA.json はスクリプトの出力なので、リポジトリには含めず .gitignore に入れておくと、基準言語の更新に追随し忘れる事故を避けられます。生成はdevサーバー起動前のnpm scriptに寄せると手間がありません。
未訳の文字列を見つける
疑似ロケールの画面では、翻訳関数を通った文字は必ずアクセントが付き、[ ] で囲まれます。それ以外の英文は、翻訳関数を通っていないということです。見つけ方は2通りあります。
コードを読んで直書きを拾う
1つ目は、ソースコード側から探す方法です。Claude Codeの非対話モード(-p)は、スクリプトやCIからClaude Codeを呼ぶための仕組みで、--output-format json と --json-schema を組み合わせると、決められた形のJSONで結果を受け取れます。
claude -p "src/ 配下のTSXから、翻訳関数を通さず直書きされた \
ユーザー向けの英文(JSXのテキスト、placeholder、aria-label、alt、title)を \
列挙して。コンポーネント名や className は含めない" \
--allowedTools "Read,Grep,Glob" \
--output-format json \
--json-schema '{"type":"object","properties":{"hits":{"type":"array","items":{"type":"object","properties":{"file":{"type":"string"},"line":{"type":"number"},"text":{"type":"string"}},"required":["file","line","text"]}}},"required":["hits"]}' \
| jq '.structured_output.hits'ここでは --bare を付けていません。--bare は起動を速くする代わりに、サブスクリプションのログインを使わず ANTHROPIC_API_KEY を要求し、使えるツールもBash・ファイル読み取り・ファイル編集に絞られます。付けない場合は、プロジェクトの CLAUDE.md、フック、.mcp.json のサーバーも通常のセッションと同じく読み込まれます。ProやMaxのログインだけで使っていても、そのまま動きます。結果は structured_output フィールドに入ります。なお、実行が有効な構造化出力を返さずに終わると、このフィールド自体が無いため、スクリプトで使う前に存在を確かめます。
ただし、コードを読む方法には限界があります。alt や aria-label は拾えても、ライブラリが内部で出す文言や、APIから返る文は読んだだけでは分かりません。これを補うのが、実際に描画した画面の確認です。
描画した画面でアクセントのない文字を拾う
2つ目は、疑似ロケールで描画した画面から探す方法です。Playwrightの page.screenshot() はスクリーンショットをファイルに保存でき、fullPage: true を付けるとスクロール全体を1枚にします。同じスクリプトの中でDOMを走査すれば、画像を見る前に機械的な判定もできます。
アクセントを付けたあとの英文には、ASCIIの英字(A-Z a-z)が1文字も残りません。つまり、表示中のテキストにASCIIの英字が1文字でもあれば、翻訳関数を通っていない候補になります。
// scripts/pseudo-check.mjs 使い方: node scripts/pseudo-check.mjs /en-XA /en-XA/cart
import fs from 'node:fs';
import { chromium } from 'playwright';
const base = process.env.BASE_URL ?? 'http://localhost:3000';
const paths = process.argv.slice(2);
fs.mkdirSync('shots', { recursive: true });
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 375, height: 800 } });
const report = [];
for (const p of paths) {
await page.goto(base + p);
await page.screenshot({ path: `shots/${p.replaceAll('/', '_')}.png`, fullPage: true });
const found = await page.evaluate(() => {
const untranslated = [];
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
for (let n; (n = walker.nextNode()); ) {
const t = n.textContent.trim();
const tag = n.parentElement?.tagName;
if (t && !['SCRIPT', 'STYLE', 'NOSCRIPT'].includes(tag) && /[A-Za-z]/.test(t)) {
untranslated.push(t.slice(0, 60));
}
}
const overflow = [...document.querySelectorAll('body *')]
.filter((e) => {
const s = getComputedStyle(e);
const clipped = ['hidden', 'clip'].includes(s.overflowX) || s.textOverflow === 'ellipsis';
return clipped && e.scrollWidth > e.clientWidth + 1;
})
.map((e) => `${e.tagName.toLowerCase()}.${e.className}`.slice(0, 80));
const pageScroll = document.documentElement.scrollWidth > window.innerWidth;
return { untranslated, overflow, pageScroll };
});
report.push({ path: p, ...found });
}
console.log(JSON.stringify(report, null, 2));
await browser.close();
process.exitCode = report.some((r) => r.untranslated.length || r.overflow.length || r.pageScroll) ? 1 : 0;このスクリプトで拾えるのは、画面に出ているテキストだけです。alt や placeholder のような属性値は走査していないので、必要なら querySelectorAll で別に集めます。また、ユーザー名や商品名のようなデータ由来の英文は、正しい挙動でも「未訳」として出ます。その分は、出力を読むときに除外します。
文字数の伸長ではみ出す箇所を見つける
上のスクリプトの overflow は、枠で文字を切っている要素(overflow: hidden や text-overflow: ellipsis)で、中身が幅を超えているものを返します。pageScroll は、ページ全体に横スクロールが発生していないかの判定です。
機械的な判定は、「切れている」ことは分かっても、「どう見えるか」までは分かりません。そこで、保存したスクリーンショットをClaude Codeに読ませます。画像は、パスを指定して渡せます。
node scripts/pseudo-check.mjs /en-XA /en-XA/cart | claude -p \
"このJSONは疑似ロケールの検査結果。shots/ 配下の画像と突き合わせて、\
(1)直書き文字列 (2)枠からはみ出す要素 (3)1文が複数の括弧に分かれた箇所 \
に分けて、原因になっていそうなコンポーネントを挙げて" \
--allowedTools "Read,Grep,Glob"標準入力に流したデータは、そのままプロンプトの材料になります。上限は10MBで、超えるとエラーで終了するため、大きな出力はファイルに書いてパスで渡します。
ブラウザの操作そのものをClaude Codeにやらせる方法もあります。Chrome連携を有効にすると、Claudeが自分でページを開き、スクリーンショットをディスクに保存させられます。ローカルのWebアプリを開いて動作を確かめる用途が、公式の使用例にも挙がっています。
localhost:3000/en-XA/cart を開いて、モバイル幅のスクリーンショットを撮って。
括弧で囲まれていない英文と、枠からはみ出している文字を探して。Chrome連携には条件があります。Claude in Chrome拡張機能のバージョン1.0.36以降と、直接契約のAnthropicプラン(Pro、Max、Team、Enterprise)が必要です。/login でサインインしている必要があり、APIキーや claude setup-token で得た長期トークンで認証していると、--chrome を付けても連携は無効のままです。Amazon Bedrockなどのサードパーティ経由でも使えず、WSLもサポート外です。条件を満たせない環境では、前節のPlaywrightスクリプトが代わりになります。
どちらの経路でも、画像から「はみ出している」と判断するのはモデルの目視です。見落としも誤検出も起こりうるので、機械的な overflow の判定を先に置き、画像はその裏づけに使う使い分けが安定します。
見つかった症状から直す箇所を決める
検出結果が出たら、症状ごとに直す場所を決めます。ここで分岐を間違えると、CSSを直して問題が消えたように見えて、翻訳側の根が残ります。
疑似ロケールで見つけた症状の切り分け
- Qアクセントのない英文が出ているか?答えが次の確認へ答えがはい
翻訳関数を通っていない文字列です。メッセージファイルにキーを追加し、翻訳関数に置き換えます。
- Q1つの文が複数の括弧に分かれているか?答えが次の確認へ答えがはい
文を部品ごとに連結している実装です。日本語のように語順が違う言語では訳せなくなるため、ICU引数を使った1つのメッセージにまとめます。
- Q括弧の中の文が枠で切れる、またはあふれるか?答えが疑似ロケールでは問題なし答えがはい
幅を固定している箇所です。固定幅を最小幅に変える、折り返しを許す、省略表示にして全文を取れる手段を用意する、のいずれかで直します。
再実行して0件になれば完了
scripts/pseudo-check.mjs の終了コードが0になるまで繰り返します。
文を連結している箇所は、Androidのドキュメントも注意を促している点です。日本語、韓国語、タミル語は動詞を文末に置くため、連結された文では翻訳者が語順を変えられません。分割された部分がそれぞれ別のメッセージとして翻訳に回るため、文脈なしでは訳せない場合もあります。
直す作業はClaude Codeに任せられますが、「直したら検査をもう一度流して終了コード0を確かめる」ところまでを依頼文に含めます。再実行する手順を渡しておくと、画像の目視に頼らず完了を判断できます。
CLAUDE.mdに規約を書いておく
毎回プロンプトに書くのは手間なので、プロジェクトの CLAUDE.md に規約として残します。これは対話セッションや、--bare なしの claude -p で修正を任せるときに効く経路です。--bare を付けた実行は CLAUDE.md を読まないため、その場合は依頼文に同じ内容を書きます。
## 国際化(next-intl)
- ユーザーに見える文字列は必ず翻訳関数を通す。JSXに英文を直書きしない
- 文を部品に分けて連結しない。変わる部分はICU引数にする
- 固定幅(width / max-width)でテキストを囲まない。min-width か折り返しを使う
- UIを変えたら `npm run pseudo:gen && node scripts/pseudo-check.mjs /en-XA` を実行し、
終了コードが0であることを確認してから完了とする最後の1行が、疑似ロケールを一度きりの調査で終わらせず、変更のたびに回す仕組みにするための部分です。
つまずきやすい点
基準言語が英語でないとき
アクセントとASCIIの対比で未訳を見分ける方法は、基準言語が英語であることが前提です。日本語が基準のプロジェクトでは、疑似ロケールでも日本語の文字が残るかどうかで直書きを見分けることになります。このときは、検出の正規表現をASCII英字ではなく、日本語の文字範囲に変えます。
日本語訳の実際の長さとは一致しない
英語から日本語への翻訳では、文字数が増えるどころか減ることが多く、約30%の伸長は、英語からドイツ語やフランス語のような長くなりがちな言語への訳を想定した保険です。日本語版だけを出すアプリなら、はみ出しの検査より、直書き文字列の検出とICU引数への整理のほうが効果が大きくなります。伸長率は定数なので、対象言語に合わせて変えて構いません。
フォールバックで未訳が隠れる
next-intlでは、特定のロケールにメッセージが無いときの代わりに、既定ロケールのメッセージを使う設定を入れられます。この設定があると、キーが欠けた箇所が既定ロケールの英文で表示されます。疑似ロケールでは、この英文がアクセントなしで残るので検出できますが、本番の翻訳ロケールでは、キーが欠けても英文が出るだけなので見過ごしやすくなります。疑似ロケールの結果を読むときは、アクセントなしの英文には「直書き」と「キー欠落」の2種類があることを意識しておくと、原因の切り分けが速くなります。
RTLは別の作業になる
AR (XB)に相当する、文字順を逆転させるロケールも作れば、RTL対応の下見ができます。ただし、本記事のスクリプトは伸長とアクセントだけを扱います。RTLの反転は、HTMLの dir 属性やCSSの論理プロパティなど、別の修正が必要になるため、アラビア語やヘブライ語に対応する予定がある場合に別途取り組む範囲です。
まとめ
疑似ローカライズは、翻訳の質ではなく、アプリが翻訳を受け入れられる構造になっているかを確かめる手法です。直書き文字列と固定幅の2つは、翻訳を依頼する前の段階なら、1つ直すのに数分で済みます。
Claude Codeの役割は、生成スクリプトの作成、検査結果の読み解き、修正の実施と再検査です。検出の中心はDOMを走査するスクリプトに置き、画像の目視は裏づけにとどめるのが、結果のぶれを抑える配置です。対応するi18nextのJSON翻訳の過不足検査はClaude Codeでi18nextのJSON翻訳を進めるに、スクリーンショットから画面の崩れを切り分ける流れはClaude CodeでPlaywrightスクリーンショット比較を読むにあります。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。