A deterministic CLI that lints your i18n/l10n translation files against a reference locale — missing keys, placeholder mismatches, incomplete plural forms (CLDR-aware), HTML tag drift and untranslated leftovers — with a coverage score, A–F grade and JSON/Markdown reports.
i18nlint is a zero-config command-line linter that compares all of your
locale files (en.json, ko.json, pl.json, …) against a reference locale and
reports every structural problem that breaks a translated UI — runs 100%
locally, no API key, no server.
Translation files are where localized apps quietly break:
- A missing key renders a blank label or falls back to English.
- A renamed variable —
{name}→{nom}— means the value is never interpolated at runtime; users see a literal{nom}. - Polish has four plural forms (
one/few/many/other); ship only two and the grammar is wrong for whole ranges of numbers. Arabic has six. - A dropped
<a>tag breaks a link; an injected tag can break layout. - A value left identical to English is an untranslated leftover.
These are mechanical, high-stakes, and easy to miss in review across 20 files.
They're also exactly the kind of cross-file, deterministic check an LLM gets
subtly wrong on large inputs — you want a repeatable tool you can gate a
deploy on. That's i18nlint.
- 🔑 Missing & orphan keys vs a reference locale.
- 🧩 Placeholder mismatch across
{x},{{x}},%s/%d,%(x)s,:x. - 🔢 CLDR-aware plural completeness — knows Polish needs
one/few/many/other, Korean needs onlyother, Arabic needs six. - 🏷️ HTML tag drift — flags lost or unexpected markup in translations.
- 🈳 Empty values and untranslated (same-as-source) detection.
- 📊 Coverage score + A–F grade per locale and overall.
- 📄 JSON & Markdown export, colored console output, CI gate exit codes.
- ⚙️ Config file to set the reference, ignore keys, tune severities.
- 🧱 Works with nested (i18next/react-intl) or flat JSON. Zero network calls.
# run without installing
npx @didrod2539/i18nlint scan ./locales
# or install
npm install -g @didrod2539/i18nlint # global CLI (provides `i18nlint`)
npm install -D @didrod2539/i18nlint # project dev-dependency (for CI)Node ≥ 18. ESM + CJS + TypeScript types.
i18nlint scan ./localesfr 56/100 (F) 75% coverage · 6/8 keys · locales/fr.json
✗ Missing key "cta"
→ Add "cta" to fr.
⚠ Empty value for "cart.empty"
→ Provide a translation for "cart.empty", or remove the key.
✗ Incomplete plural in "cart.items" — missing `many`
→ Add the many plural branch(es) for fr.
ℹ "app.logout" looks untranslated (same as en)
pl 69/100 (D) 100% coverage · 8/8 keys · locales/pl.json
⚠ Orphan key "legacy.old" not in reference
✗ Placeholder mismatch in "cart.total" (missing `amount`, unexpected `kwota`)
✗ Incomplete plural in "cart.items" — missing `few`, `many`
Overall 75/100 (C) ref en, 92% avg coverage, 4 error(s), 2 warning(s), 1 info
i18nlint scan [...paths] # lint locale files / directories
i18nlint report <input.json> # re-render a saved JSON report as Markdown
i18nlint init # scaffold i18nlint.config.json
i18nlint --help
i18nlint --versionscan options:
| Option | Description |
|---|---|
--config <file> |
Path to a config file (otherwise auto-detected) |
--reference <locale> |
Reference locale code (default: auto / en) |
--json <file> |
Write a JSON report |
--md <file> |
Write a Markdown report |
--min-coverage <n> |
Exit non-zero if avg coverage < n (CI gate) |
--max-errors <n> |
Exit non-zero if total errors > n (CI gate) |
--quiet |
Hide info-level issues in the console |
Locale codes are taken from file names (en.json → en, pt-BR.json →
pt-BR). Point scan at a directory and it finds every *.json recursively.
A full report for the bundled sample locales lives in
examples/sample-report.md and
examples/sample-report.json.
📸 Screenshot / demo GIF placeholder:
./docs/screenshot.png— record the terminal runningnpx @didrod2539/i18nlint scan examples/locales.
Create i18nlint.config.json (or run i18nlint init):
{
"reference": "en",
"untranslated": "warning",
"minCoverage": 90,
"ignoreKeys": ["legacy.*"],
"allowUntranslated": ["app.title"],
"disableRules": [],
"ruleSeverity": { "extra-keys": "error" }
}| Field | Meaning |
|---|---|
reference |
Reference locale code, or null to auto-detect (en, else most keys) |
untranslated |
Severity for same-as-source values: "off", "info", "warning", "error" |
minCoverage |
CI gate threshold (overridable with --min-coverage) |
ignoreKeys |
Keys to skip — exact, or trailing-* prefix wildcard |
ignoreLocales |
Locale codes to skip |
allowUntranslated |
Keys allowed to equal the source (brand names, etc.) |
disableRules |
Rule ids to turn off entirely |
ruleSeverity |
Override severity per rule id |
Rule ids: missing-keys, extra-keys, empty-value, placeholder-mismatch,
plural-incomplete, html-mismatch, untranslated.
- Block broken translations in CI. Add
i18nlint scan ./locales --min-coverage 95 --max-errors 0to your pipeline. A PR that drops a key or renames a{variable}fails before it merges. - Onboard a new language safely. Drop in
pl.json, runi18nlint scanand instantly see the missing keys, the Polish plural forms you still owe, and any placeholders you mistyped. - Audit a translation vendor's delivery. Run
i18nlint scan ./delivery --md audit.mdand hand back a precise, per-key Markdown report instead of eyeballing diffs.
import { lint, loadLocales, toMarkdown } from "@didrod2539/i18nlint";
const report = lint(loadLocales(["./locales"]), { config });
console.log(report.summary.coverage, report.summary.grade);
await fs.writeFile("report.md", toMarkdown(report));- YAML and
.properties(Java/Android) locale formats. - Namespaced / directory-per-locale layouts (
locales/en/common.json). - Source-code scan to detect keys used in code but missing from locales.
- ICU
select/selectordinaldeep validation. --fixto scaffold missing keys and plural branches.- A GitHub Action wrapper that comments coverage on PRs.
Does it send my files anywhere?
No. i18nlint runs entirely on your machine — no API key, no telemetry, no
uploads. It makes zero network calls.
Which i18n libraries does it work with?
Any that store messages as JSON: i18next, react-intl/FormatJS, vue-i18n, LinguiJS,
Polyglot, and plain JSON. It understands ICU plural and the common placeholder
styles. (YAML/.properties are on the roadmap.)
How does it know Polish needs four plural forms?
It ships a curated subset of the Unicode CLDR cardinal plural rules mapping
each language to its required categories. Unknown languages default to
one/other; see src/plural.ts.
Won't the plural/HTML checks have false positives?
The plural scan is a deterministic regex over ICU branches and the HTML check
compares tag-name sets — both documented and conservative. Anything you disagree
with can be silenced via disableRules, ignoreKeys, or ruleSeverity.
Is the "coverage score" official?
No — it's a transparent metric (translated keys ÷ reference keys, minus
correctness penalties) so you can track and gate it. The math lives in
src/score.ts.
Contributions welcome! Each check is a small, self-contained rule in
src/rules/. See CONTRIBUTING.md and the
Code of Conduct.
git clone https://github.com/didrod205/i18nlint.git
cd i18nlint
npm install
npm test
npm run build
node dist/cli.js scan examples/localesMIT © i18nlint contributors
i18nlint is free, MIT-licensed, and built in spare time. If it caught a bug before your users did, please consider supporting it:
- ⭐ Star this repo — free, and it helps others find it.
- 🍋 Sponsor via Lemon Squeezy — one-time or recurring.
Where your support goes: YAML/.properties support, namespaced layouts,
source-code key scanning, ICU select validation, a --fix mode, a PR-commenting
GitHub Action, and fast issue responses.