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

{
  "nav": { "home": "Home", "cart": "Cart" },
  "checkout": {
    "summary": { "total": "Total", "items": "{count} items" },
    "pay": "Pay now",
    "error": { "declined": "Card declined" }
  }
}
{
  "nav": { "home": "Startseite", "cart": "Warenkorb" },
  "checkout": {
    "summary": { "items": "{anzahl} Artikel" },
    "pay": "",
    "error": { "declined": "Card declined" },
    "legacy": "Alte Zeichenkette"
  }
}
export function view(t) {
  return [t('checkout.summary.total'), t('checkout.summary.items'), t('checkout.shipping.eta')].join('\n');
}

Steps

  1. 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 6

    Five 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.

  2. 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 catches null and undefined and an empty string is neither.

  3. 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.

  4. 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.eta is 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

Sign: A screen shows checkout.summary.total and nobody files it.Cause: A key-as-fallback looks like a deliberate identifier, so reviewers read past it and snapshot tests record it as the expected value. Step 3 finds it mechanically: text that is lower-case words joined by dots is never a translation. Run the scan on every locale, not only on the one being reviewed.
Sign: A key counted as translated renders as an empty element.Cause: The value is an empty string. Step 2 shows why it survives: the usual fallback is key ?? default, and ?? only fires on null and undefined. Count a key as translated only when the trimmed value has length, which is what the coverage line in step 1 does.
Sign: A number or a name disappears from one language only.Cause: The placeholder was translated with the sentence, so {count} became {anzahl} and the interpolation finds nothing to replace. The key is present, the string is translated, and every count-based comparison reports full coverage. Compare the placeholder sets, as step 1 does.
Sign: Every catalogue agrees and a key still renders as itself.Cause: The key is used in the code and defined nowhere, so a catalogue-to-catalogue diff cannot see it. Step 4 reads the call sites instead. It also finds the opposite case, keys nobody calls, which is how catalogues grow for years.

What to check next

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.

basic9 minpublished updated Maks Verny