多言語サイト 作り方 — 言語パックJSONの検証チェック9選
多言語サイト 作り方で検索する人の多くは、翻訳ファイルの抜けに気づかないまま公開してしまいます。ko.jsonとen.jsonのキーが半分しか揃っていなくても、画面は静かに空欄を返すだけだからです。ここに集めた9個はコピペで検査できる出発点で、コンソールに貼るだけで抜けが見えてきます。
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 言語パックの基本形
- 02 片方の言語にだけあるキー
- 03 {プレースホルダ}のずれ
- 04 翻訳漏れのハングル残存
- 05 言語パックをつなぐ t() 関数
- 06 言語パックを通らない直書きテキスト
- 07 ないキーがそのまま画面に出る
- 08 言語ごとのURLと hreflang
- 09 日付・数値の書式が言語ごとに違うとき
9個は実際に2つ目の言語を足すときに出会う順番のまま並べました。まず言語パック2ファイルの基本形(01)を整え、片方にしかないキー(02)を捕まえます。次に文の中を覗いて{プレースホルダ}のずれ(03)と翻訳漏れのハングル残存(04)を確認します。画面と言語パックをつなぐt()関数(05)を確認したら、言語パックを通らない直書きテキスト(06)と、ないキーがそのまま画面に出る問題(07)を塞ぎます。最後にページの外側、言語ごとのURLとhreflang(08)、そして日付・数値の書式(09)を見ます。
01言語パックの基本形
ko.jsonとen.jsonのキーが同じ階層に展開して対応を取り、片方にしかないキーは穴として残ってすぐ目につきます。多言語サイトを初めてセットアップするときや、翻訳ファイルを新しく追加するときに最初に走らせる検査です。
const flatten = (o, p = "") =>
Object.entries(o).flatMap(([k, v]) =>
v && typeof v === "object"
? flatten(v, p ? `${p}.${k}` : k)
: [`${p ? p + "." : ""}${k}`]
);
const load = async (l) =>
flatten(await (await fetch(`/locales/${l}.json`)).json());
const ko = await load("ko");
const en = await load("en");
console.log(ko.length, en.length); // 件数が違えば構造そのものを疑う
02片方の言語にだけあるキー
2列のキー行が順に点灯して埋まっていきますが、ない側は暗いままで赤い欠落マークが浮かび、差を指し示します。英語版だけボタンが空のときや、新しいキーを韓国語にだけ追加したときにそのまま答えになります。
const onlyIn = (a, b, label) => {
const set = new Set(b);
return a.filter((k) => !set.has(k)).map((k) => `${label}: ${k}`);
};
const gaps = [
...onlyIn(ko, en, "koのみ"),
...onlyIn(en, ko, "enのみ"),
];
console.log(gaps);
// → 「koのみ: nav.mypage」のように片方にしかないキーが行になって出る
03{プレースホルダ}のずれ
文中の{name}チップがマーカーのように光って各言語をなめ、欠けた文には警告バッジが揺れながら張り付きます。あいさつに名前が入らない言語があるときや、数量・日付の変数がむき出しになるときに使います。
const ph = (s) => [...String(s).matchAll(/\{(\w+)\}/g)].map((m) => m[1]);
const sameSet = (a, b) =>
a.length === b.length && a.every((x) => b.includes(x));
const koOrder = "{name}님, 주문이 {count}건 접수되었습니다";
const enOrder = "{count} orders placed for {name}";
console.log(sameSet(ph(koOrder), ph(enOrder))); // true
const koRefund = "{date}에 환불이 완료되었습니다";
const enRefund = "Refund completed";
console.log(sameSet(ph(koRefund), ph(enRefund))); // false: {date}が欠落
04翻訳漏れのハングル残存
スキャンラインが英語の行を上からなめ、ハングルだけを赤く染めていきます。通過するたびに残存カウンターが増え、機械翻訳をかけたあとに英語版へハングルがそのまま残っていないか確かめられます。
const hangul = /[가-힣]/;
const enJson = await (await fetch("/locales/en.json")).json();
const residue = Object.entries(enJson).filter(
([k, v]) => typeof v === "string" && hangul.test(v)
);
console.log(residue);
// → [["shipping", "배송이 시작되었습니다."], ["refund", "환불이 처리되었습니다."]]
05言語パックをつなぐt()関数
キーカードがコンベアでt()ボックスに入り、現在の言語の文に変わって出てきます。レバーでko・en・jaを切り替えると、同じキーの結果がそのまま差し替わります。このt()が言語ファイルを画面につなぐ基準になり、キーの打ち間違いで空の文が出る不具合もここで見つけられます。
const get = async (l) => await (await fetch(`/locales/${l}.json`)).json();
const packs = { ko: await get("ko"), en: await get("en"), ja: await get("ja") };
let lang = "ko";
function t(key, vars = {}) {
let s = packs[lang][key] ?? packs.ko[key] ?? key;
for (const [k, v] of Object.entries(vars)) {
s = s.replaceAll(`{${k}}`, v);
}
return s;
}
console.log(t("cta.start")); // 始める
console.log(t("cta.startX")); // cta.startXのまま → タイプミスの合図
06言語パックを通らない直書きテキスト
コード行をスキャンラインが順になめ、タグの間に直書きされたハングルの行だけが赤く固まり、他は素通りします。言語を切り替えても残る文字があるときや、AIが入れたラベルを点検するときに使います。
// タグの間の直書きハングル = 言語パックを通っていない文字
const between = />\s*[가-힣][^<]*</g;
const hits = [...new Set(
(document.body.innerHTML.match(between) || [])
.map((s) => s.slice(1, -1).trim())
)];
console.log(hits);
// → ["구매하기", "자주 묻는 질문", "배송 안내"]
07ないキーがそのまま画面に出る
画面カードに生キーwelcome.titleが出たあと、フォールバック文が下から押し上がって差し替わり、また生キーに戻る動きを繰り返して2つの状態を並べて見せます。ボタンの場所にheader.titleがそのまま見えるときや、フォールバック言語の規則を決めるときに使います。
const keyShape = /^[a-z0-9]+(\.[a-z0-9]+)+$/;
const leaks = [...document.querySelectorAll("button, a, h2, p, span")]
.filter((el) => el.children.length === 0)
.map((el) => el.textContent.trim())
.filter((s) => keyShape.test(s));
console.log(leaks); // → "welcome.title"のようにキーの形のまま出た文字
08言語ごとのURLとhreflang
/・/en/・/ja/の3ノードをリンクの軌跡が互いを指して巡回しますが、切れた一方向だけ赤い疑問符が浮かび、片方向リンクを捕まえます。Googleに英語版だけがインデックスされるときや、言語タブが404になるときの点検に使います。
const want = ["ko", "en", "ja"];
const have = new Set(
[...document.querySelectorAll('link[rel="alternate"][hreflang]')]
.map((t) => t.getAttribute("hreflang"))
);
const missing = want.filter((l) => !have.has(l));
console.log(missing); // → ["ja"]ならこのページからjaへの橋がない
09日付・数値の書式が言語ごとに違うとき
同じ日付カードがフリップパネルのようにko・en・jaの書式でめくれ、桁区切りも一緒に変わります。注文履歴の日付が変に見えるときや価格のカンマの位置がずれるときに使い、書式は手で組まずIntlに任せます。
const d = new Date(2026, 8, 11); // 9月11日(月は0始まり)
const price = 1234567;
for (const lang of ["ko", "en", "ja"]) {
const date = new Intl.DateTimeFormat(lang, { dateStyle: "long" }).format(d);
const num = new Intl.NumberFormat(lang).format(price);
console.log(lang, date, num);
}
どこで壊れるか — キーの形をしたドメインが誤検出を招く
07番の検査をこのサイト自身に走らせたときに起きたことです。画面に漏れ出た生キーは正しく捕まえましたが、リンク文字が「godrichstory.com」というだけの表示にも反応しました。ドットでつながる小文字の並びはキーと同じ形に見えるため、形だけで判定すると必ずいくつか誤検出が混ざります。対処は、引っかかった文字列を02番のflatten結果にある実際のキー一覧と突き合わせることです。一覧にあれば本物の漏れで、なければドメインやバージョン表記のようにもともとドット付きの英字にすぎません。zipを開くときのパスワードはfnn2hd5eで、画面に出た通りに入力すると9個のフォルダがまとめて開きます。08番にも対になる落とし穴があります — hreflangタグが各ページにあるかだけを見ていると、言語ごとのURLが互いを指し返しているかどうかを見落とします。このページからjaへ進めても、jaのページに戻るリンクがなければ、検索エンジンはこの2ページを対になっていないと判断します。
アクセシビリティ
9個すべてがprefers-reduced-motion: reduceでanimation: noneに落ち、止まった後も意味が読み取れる静止画をそれぞれ用意しています。01・02・04・06番はスキャンや点灯の動きだけが止まり、空いた枠や赤い残存・バッジはそのまま残るので、何が問題かが見えます。07番はフォールバック文が適用された状態で止まり、08番は切れた弧と疑問符が見える状態で止まります。09番はko書式のカードを静止画として見せます。この設定の仕組みはMDNのprefers-reduced-motionのページにまとまっています。
FAQ
コードが分からなくても使えますか?
はい、9個すべてをブラウザの開発者ツールのコンソールにそのまま貼り付ければ結果がすぐ表示されます。zipの中にはこの9個を1つのスクリプトにまとめたものも入っていて、サイトのURLを差し替えるだけで使えます。このサイトがデモとスクリプトをどう検証しているかは紹介ページにまとめてあります。
検査を走らせるとサイトが変わったり遅くなったりしますか?
いいえ、すべて画面やファイルを読むだけです。値を書き換えたりサーバーに送信したりする命令はありません。言語パックのJSONを取得するfetchも、ブラウザがすでに受け取ったファイルを読み直す程度なので体感速度は変わりません。
Reactで作ったサイトでもそのまま使えますか?
使えます。01〜04番は言語パックのJSONファイルだけを見るので、画面を何で作ったかとは無関係です。05番のt()は最小構成の例で、react-i18nextのようなライブラリを使っているなら、その関数を基準に同じ考え方を当てはめれば大丈夫です。ほかの検査記事はサイト内検索で「多言語」と調べると見つかります。