How to check for missing translations
Flatten both catalogues to dotted keys and compare the sets. node i18n-diff.mjs reported one key missing, one empty string, one value identical to English, one placeholder renamed and one key nobody uses. Then render the page and look for text that is a key rather than a sentence.
Why check this
A missing key almost never throws. The common fallback returns the key itself, so the interface shows checkout.summary.total where a word belongs, and that string passes every smoke test, every snapshot that was taken after the defect landed, and most screenshots at a glance. An empty translation shows nothing at all and is quieter still.
Run this on every translation pull request and in the pipeline that builds a release candidate. It catches the case where a key was renamed in the source language and the translated files still carry the old one, which leaves the new key untranslated and the old key shipped forever.
Prerequisites
- Node 22. No dependencies are needed for any script here.
- Two catalogues.
i18n/en.jsonis the reference.
{
"nav": { "home": "Home", "cart": "Cart" },
"checkout": {
"summary": { "total": "Total", "items": "{count} items" },
"pay": "Pay now",
"error": { "declined": "Card declined" }
}
}
i18n/de.jsonis the translation under test. It carries five separate defects on purpose.
{
"nav": { "home": "Startseite", "cart": "Warenkorb" },
"checkout": {
"summary": { "items": "{anzahl} Artikel" },
"pay": "",
"error": { "declined": "Card declined" },
"legacy": "Alte Zeichenkette"
}
}
- One source file that calls the translation function,
src/checkout.js, for the last step.
export function view(t) {
return [t('checkout.summary.total'), t('checkout.summary.items'), t('checkout.shipping.eta')].join('\n');
}
Steps
- Step 1.
Compare the two catalogues as flat key sets. Save this as
i18n-diff.mjs.import { readFileSync } from 'node:fs'; const flat = (o, p = '') => Object.entries(o).flatMap(([k, v]) => v !== null && typeof v === 'object' ? flat(v, `${p}${k}.`) : [[`${p}${k}`, v]] ); const ph = (s) => (String(s).match(/\{\w+\}/g) ?? []).sort().join(' '); const source = new Map(flat(JSON.parse(readFileSync('i18n/en.json', 'utf8')))); const target = new Map(flat(JSON.parse(readFileSync('i18n/de.json', 'utf8')))); const say = (label, keys) => console.log(`${label} (${keys.length})${keys.map((k) => `\n ${k}`).join('')}`); say('missing', [...source.keys()].filter((k) => !target.has(k))); say('empty', [...target].filter(([, v]) => String(v).trim() === '').map(([k]) => k)); say('same as source', [...target].filter(([k, v]) => source.get(k) === v).map(([k]) => k)); say('placeholder mismatch', [...target] .filter(([k, v]) => source.has(k) && ph(source.get(k)) !== ph(v)) .map(([k, v]) => `${k} ${ph(source.get(k))} -> ${ph(v)}`)); say('extra', [...target.keys()].filter((k) => !source.has(k))); console.log(`\ntranslated ${[...source.keys()].filter((k) => target.get(k)).length} of ${source.size}`);missing (1) checkout.summary.total empty (1) checkout.pay same as source (1) checkout.error.declined placeholder mismatch (1) checkout.summary.items {count} -> {anzahl} extra (1) checkout.legacy translated 4 of 6Five findings from six keys. The coverage line counts only keys with a non-empty value, which is why it says 4 of 6 rather than 5 of 6.
- Step 2.
Render three of those keys through the fallback a translation library usually ships. Save as
fallback.mjs.import { readFileSync, writeFileSync } from 'node:fs'; const de = JSON.parse(readFileSync('i18n/de.json', 'utf8')); const t = (key) => key.split('.').reduce((o, k) => (o === undefined ? o : o[k]), de) ?? key; const rows = ['nav.cart', 'checkout.summary.total', 'checkout.pay']; for (const k of rows) console.log(`${k.padEnd(22)} renders "${t(k)}"`); writeFileSync( 'render.html', '<!doctype html><meta charset="utf-8"><title>Checkout</title>' + rows.map((k) => `<p>${t(k)}</p>`).join('') );nav.cart renders "Warenkorb" checkout.summary.total renders "checkout.summary.total" checkout.pay renders ""The missing key renders as its own name. The empty one renders as nothing, because
??only catchesnullandundefinedand an empty string is neither. - Step 3.
Find both defects in the rendered page, with no access to the catalogue. Save as
dom-scan.mjs.import { resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; import { open } from '../../scripts/browser/session.mjs'; const s = await open(); try { await s.goto(pathToFileURL(resolve('render.html')).href); console.log(JSON.stringify(await s.page.evaluate(() => { const leaves = [...document.body.querySelectorAll('*')].filter((e) => e.children.length === 0); return { keyShaped: leaves.filter((e) => /^[a-z][\w-]*(\.[a-z][\w-]*)+$/i.test(e.textContent.trim())) .map((e) => `${e.tagName.toLowerCase()}: ${e.textContent.trim()}`), empty: leaves.filter((e) => e.textContent.trim() === '').map((e) => `${e.tagName.toLowerCase()}: (empty)`), }; }), null, 2)); } finally { await s.close(); }{ "keyShaped": [ "p: checkout.summary.total" ], "empty": [ "p: (empty)" ] }Text made of lower-case words joined by dots is a key that escaped. This runs against any page, including one built from a repository you cannot read.
- Step 4.
Compare the keys the code asks for against the keys the reference defines. Save as
used-keys.mjs.import { readFileSync, readdirSync } from 'node:fs'; const flat = (o, p = '') => Object.entries(o).flatMap(([k, v]) => v !== null && typeof v === 'object' ? flat(v, `${p}${k}.`) : [`${p}${k}`] ); const defined = new Set(flat(JSON.parse(readFileSync('i18n/en.json', 'utf8')))); const used = new Set(); for (const f of readdirSync('src')) { const code = readFileSync(`src/${f}`, 'utf8'); for (const m of code.matchAll(/\bt\(\s*['"`]([^'"`]+)['"`]/g)) used.add(m[1]); } console.log('used in code but not in en.json:', [...used].filter((k) => !defined.has(k))); console.log('in en.json but never used :', [...defined].filter((k) => !used.has(k)));used in code but not in en.json: [ 'checkout.shipping.eta' ] in en.json but never used : [ 'nav.home', 'nav.cart', 'checkout.pay', 'checkout.error.declined' ]checkout.shipping.etais missing from every language, including the source one, and no comparison between catalogues could have found it.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| A key under missing | The translator never received it, or it was renamed after the file was sent | Send the key for translation. Fail the pipeline on a non-empty missing list. |
| A key under empty | The value exists and is a blank string | Treat blank as missing. A nullish fallback passes it straight through to the interface. |
| A key under same as source | The value was copied, not translated | Check by hand. Product names and OK are legitimate matches, prose is not. |
| A placeholder mismatch | The variable name was translated along with the text | Restore the original name. The interpolation will not find {anzahl} and the number vanishes. |
| A key under extra | The source removed it and the translation kept it | Delete it. Nothing renders it, and it grows every bundle that ships. |
| Dotted text in the rendered page | A key reached the screen | Trace it back to the catalogue with step 1. |
Common mistakes
What to check next
- How to test pluralization: the keys this check counts once and a plural language needs four of.
- How to check accept language header: which catalogue a visitor is served in the first place.
- How to test text expansion in translations: the next defect after a key is filled in, when the German string is half again as long.
- How to check for garbled characters from the wrong encoding: what a catalogue saved in the wrong encoding does to text that is present and correct.
FAQ
How do I find missing i18n keys without running the app?
Steps 1 and 4 are static. One compares catalogues, the other compares the code against the reference catalogue. Both run in a pipeline and need no browser.
Should a missing key fall back to English or to the key?
Fall back to the source language for users and to the key in development builds. A visible key is a defect report; an English word inside a German page is a smaller one.
How do I count translation coverage?
Count keys whose trimmed value is non-empty, divided by the number of keys in the reference. Counting present keys reports the blank string in step 1 as translated.
Is a translation identical to the source always a defect?
No. Product names, OK, Email and many technical terms match across languages. Treat the list as a review queue rather than a failure.
Verified
Verified by Maks Vernynode 22.23.2Chrome 152.0.7977.76
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
basic9 minpublished updated Maks Verny