You add one string to en.json, ship it, and move on.
Three weeks later a French user emails a screenshot: a raw auth.errors.locked
sitting where a translated message should be. fr.json never got the key. Nor
did zh.json. And nobody noticed, because a missing translation doesn’t throw —
it just quietly renders the key.
Locale drift is the classic absence bug: the failure isn’t an error you can
catch, it’s a translation that was never written. Your test suite is green, your
build passes, and the gap only shows up in production, in a language you don’t
read.
Why I didn’t just use an existing tool
There are i18n tools out there, but every one I reached for had a catch:
-
Framework-locked.
i18next-clionly understands i18next. Most “find missing translations” tools assume one specific library’s runtime. - Account required. Locize and friends want you to push your strings to their platform first.
- Heavy. AST-based linters that need config and a plugin per framework, when all I wanted to know was “which keys are missing from which file.”
And the deal-breaker: most naive “diff two locale JSONs” scripts get plurals
wrong.
The plural problem (this is the interesting part)
Here’s the trap. English has two plural forms: items_one and items_other.
So the obvious rule is “every other locale must have both forms too.”
That rule is wrong.
Chinese has one plural category — other. A zh.json with only
items_other is correct. Flagging it as “missing items_one” is a false
positive, and false positives are how a linter ends up ignored.
Meanwhile Russian has four categories (one, few, many, other). A
ru.json that copied English’s two-form shape is genuinely broken — and a
parity-with-English check would never catch it, because English doesn’t have
few or many to compare against.
So the same items_other-only shape is:
en base: items_one + items_other
zh.json → items_other only ✓ in sync (Chinese needs only `other`)
fr.json → items_other only ✗ missing {one}
ru.json → items_one + _other ✗ missing {few, many}
Enter fullscreen mode Exit fullscreen mode
To get this right you have to resolve the required CLDR plural categories from
the target language, not from the base file. So that’s what localediff does:
it carries a conservative table of CLDR cardinal categories, infers each file’s
language from its name (fr.json → fr, pt-BR.json → pt), and checks
completeness against that language’s rules. Unknown language? It falls back to
parity with the base, so you never get a confidently-wrong answer.
How it works
Point it at a folder. It treats en.json as the base and checks everything else:
npx localediff ./locales
Enter fullscreen mode Exit fullscreen mode
✗ fr.json
missing (1): auth.errors.locked
plural cart.items — has {other}, missing {one}
empty (1): footer.copyright
extra (1): legacy.banner
✓ zh.json — in sync
✗ 1 of 2 file(s) drifted — 1 missing, 1 plural gap(s), 1 empty, 1 extra
Enter fullscreen mode Exit fullscreen mode
Four things it reports:
Check What it means missing a key in the base the target never translated plural a pluralized key missing a CLDR form the target language needs empty a key that exists in the target but the value is blank — the slot’s there, nobody filled it extra a leftover key the base dropped (usually after a rename)It’s structural, so it doesn’t care what framework you use — next-intl,
react-intl, i18next, vue-i18n, or a plain JSON catalog all work. Nested objects
flatten to dot-paths (auth.errors.locked); arrays get indexed.
It exits non-zero on drift, so it drops straight into CI:
- run: npx localediff ./locales
Enter fullscreen mode Exit fullscreen mode
exit 0 every checked file is in sync
exit 1 something drifted
exit 2 error (file not found, bad JSON)
Enter fullscreen mode Exit fullscreen mode
There’s a --format json for machine output, --lang to force a language when
file names aren’t locale codes, and --ignore-plural / --ignore-extra /
etc. when you want to narrow the gate.
Install
It ships on both registries with byte-for-byte identical behavior, because
half the projects I work on are Node and half are Python:
npx localediff ./locales # Node — zero deps, stdlib only
pip install localediff # Python — pure stdlib
Enter fullscreen mode Exit fullscreen mode
Zero dependencies on purpose: no install bloat, nothing to audit, and npx /
pip install is the whole setup.
A few design notes
-
Zero deps means JSON only. Supporting YAML or
.propertieswould mean pulling in a parser, which defeats the point. Convert to JSON, or tell me I’m wrong about the tradeoff. -
_otheris the tell. A key only counts as a plural group if it has an_otherform (the one i18next always requires). That keeps ordinary keys likestep_one/step_twofrom being mistaken for plurals. - Conservative on unknown languages. Better to miss a gap than to invent one. Confidently-wrong i18n linting is worse than no linting.
Try it / break it
Code, issues, and the full README:
It’s MIT and tiny. I’d love to know where the plural table is wrong for your
language, or what your locale files do that I didn’t think of.
How do you currently catch a translation that silently went missing — CI check,
review checklist, or a user emailing you a screenshot?
답글 남기기