Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

locale-fallback-simulator

See the string every locale actually renders — and catch the silent gaps where a translation quietly falls back to the wrong language.

When you ship in more than one language, most keys are covered by a fallback chain: fr-CA borrows from fr, es-MX borrows from es, and everything ultimately lands on a base language like en. That machinery is great — until it hides a problem. A missing fr-CA string that falls back to fr is fine. A missing fr-CA string that falls back all the way to English is a bug your users see and your tests never do.

locale-fallback-simulator loads your translation catalogs and a fallback configuration, then computes — for every configured locale and every key — exactly which locale supplied the string the user sees, and at what fallback level. It separates the harmless region-to-language fallbacks from the silent cross-language leaks that are the real gaps.

  • Zero runtime dependencies. Pure Node 18+ (Intl-free, fs + built-ins only).
  • Library + CLI. Import the engine, or run the reporter over a directory of catalogs.
  • CI-ready. Non-zero exit when any locale exceeds a gap budget you choose.

Install

This tool is distributed by cloning the repo (it is intentionally not published to npm). There is nothing to compile and nothing to install:

git clone https://github.com/i18n-l10n/locale-fallback-simulator.git
cd locale-fallback-simulator
node bin/fallback-sim.js examples/catalogs --config examples/fallback.config.json

Requires Node.js 18 or newer. There are no dependencies, so npm install is not even necessary — but npm test runs the suite if you want to.


The core idea: not all fallbacks are equal

Given a locale, the simulator walks its fallback chain and classifies the result of every key into one of five categories:

Category Meaning A gap?
exact Served by the requested locale itself (level 0) no
region Same language, different region (fr-CAfr) no
macrolanguage Linked via a macrolanguage map (nbno) no
cross-language A different language (fr-CAen) — a silent leak yes
missing No locale in the chain had the key at all

A gap is specifically a cross-language fallback: the user asked for one language and silently got another. That region-to-language nuance is the whole point — a coverage percentage alone will happily report "100%" for a locale that is quietly showing English to French-Canadian users. See Setting up graceful fallback chains for missing strings and Region-to-language fallback without a 404 for the design principles this tool checks.


CLI usage

node bin/fallback-sim.js [options] <catalogs-dir>

Options:
  --config <path>     Fallback config JSON (default: <catalogs-dir>/fallback.config.json if present)
  --format <fmt>      Output format: pretty | json | csv        (default: pretty)
  --only <locale>     Report a single locale only
  --max-gaps <n>      Exit non-zero if any locale has MORE than <n> cross-language gaps.
                      Use 0 in CI to fail on any silent base-language leak.
                      (default: disabled — always exit 0)
  -h, --help          Show this help

Every *.json file in <catalogs-dir> is loaded as a catalog whose filename is the locale (fr-CA.jsonfr-CA). Keys may be nested or flat — nested objects are flattened to dot-delimited keys ({"app":{"title":"…"}}app.title). The fallback.config.json file is never treated as a catalog.

Pretty report (the default)

Running the bundled example:

$ node bin/fallback-sim.js examples/catalogs --config examples/fallback.config.json
locale-fallback-simulator — 9 locale(s), 10 key(s)
config: examples/fallback.config.json   default=en

en      en                  ████████████ 100%   in-language  · 0 gaps
fr      fr → en             ██████████░░ 80%    in-language  ⚠ 2 gap(s)
fr-CA   fr-CA → fr → en     ██████████░░ 80%    in-language  ⚠ 2 gap(s)
es      es → en             ████████████ 100%   in-language  · 0 gaps
es-MX   es-MX → es → en     ████████████ 100%   in-language  · 0 gaps
pt-BR   pt-BR → pt → en     ██████████░░ 80%    in-language  ⚠ 2 gap(s)
de-AT   de-AT → de → en     ███████░░░░░ 60%    in-language  ⚠ 4 gap(s)
nb      nb → no → en        ███████████░ 90%    in-language  ⚠ 1 gap(s)
en-CA   en-CA → en-GB → en  ████████████ 100%   in-language  · 0 gaps

Silent cross-language fallbacks (base-language leaks)
  fr     checkout.tax_note  → en     "Taxes calculated at checkout"
  fr     errors.network     → en     "Network connection lost"
  fr-CA  checkout.tax_note  → en     "Taxes calculated at checkout"
  fr-CA  errors.network     → en     "Network connection lost"
  pt-BR  checkout.tax_note  → en     "Taxes calculated at checkout"
  pt-BR  errors.network     → en     "Network connection lost"
  de-AT  app.tagline        → en     "Ship in every language"
  de-AT  checkout.button    → en     "Pay now"
  de-AT  checkout.tax_note  → en     "Taxes calculated at checkout"
  de-AT  errors.network     → en     "Network connection lost"
  nb     checkout.tax_note  → en     "Taxes calculated at checkout"

Missing entirely: none

Read the report like this:

  • es-MX is 100% in-language with 0 gaps even though it defines only two of its own strings — every other key correctly borrows from es. That is a healthy region → language fallback.
  • fr-CA is also "80% in-language" but has 2 gaps: checkout.tax_note and errors.network are missing from both fr-CA and fr, so users see English. The bar looks fine; the gap list is what you act on.
  • nbno is a macrolanguage fallback (Norwegian Bokmål borrowing from the broader Norwegian catalog), so those six keys are counted as in-language, not gaps — only the one key missing everywhere leaks to English.

JSON output (for pipelines)

--format json prints the full resolution matrix — the union of keys, each locale's resolved chain, and a per-key cell with value, resolvedLocale, level, category, and the boolean flags:

$ node bin/fallback-sim.js examples/catalogs --config examples/fallback.config.json --only fr-CA --format json
{
  "keys": [ "app.tagline", "app.title", "checkout.button", ... ],
  "locales": [ "fr-CA" ],
  "byLocale": {
    "fr-CA": {
      "locale": "fr-CA",
      "chain": [ "fr-CA", "fr", "en" ],
      "cells": {
        "app.tagline": {
          "value": "Déployez dans toutes les langues",
          "resolvedLocale": "fr-CA",
          "level": 0,
          "category": "exact",
          "isFallback": false,
          "isMissing": false,
          "isGap": false
        },
        "checkout.tax_note": {
          "value": "Taxes calculated at checkout",
          "resolvedLocale": "en",
          "level": 2,
          "category": "cross-language",
          "isFallback": true,
          "isMissing": false,
          "isGap": true
        }
      },
      "stats": { "total": 10, "exact": 2, "region": 6, "crossLanguage": 2, "gaps": 2, ... }
    }
  },
  "totals": { "gaps": 11, ... }
}

CSV output

--format csv emits one row per locale × key — ideal for a spreadsheet triage:

$ node bin/fallback-sim.js examples/catalogs --config examples/fallback.config.json --only es-MX --format csv
locale,key,resolvedLocale,level,category,isGap,isMissing,value
es-MX,app.title,es,1,region,false,false,Panel de Acme
es-MX,checkout.button,es-MX,0,exact,false,false,Pagar
es-MX,nav.logout,es-MX,0,exact,false,false,Salir
es-MX,nav.settings,es,1,region,false,false,Configuración
...

Failing a CI build on silent leaks

Set a gap budget with --max-gaps. With 0, any cross-language leak fails the build; the tool prints the pretty report to stdout and the offenders to stderr, then exits 1:

$ node bin/fallback-sim.js examples/catalogs --config examples/fallback.config.json --max-gaps 0
... (report) ...
FAIL: fr has 2 cross-language gap(s) (max 0)
FAIL: fr-CA has 2 cross-language gap(s) (max 0)
FAIL: pt-BR has 2 cross-language gap(s) (max 0)
FAIL: de-AT has 4 cross-language gap(s) (max 0)
FAIL: nb has 1 cross-language gap(s) (max 0)
$ echo $?
1

This slots directly into a translation CI gate. For the wider pattern, see GitHub Actions i18n CI gates and Failing the build on untranslated keys.

# .github/workflows/i18n.yml
- name: Check for silent cross-language fallbacks
  run: node bin/fallback-sim.js locales --config locales/fallback.config.json --max-gaps 0

Configuration reference

The config file is JSON. Every field is optional; an empty {} config falls back to automatic BCP-47 truncation with en as the base.

Field Type Purpose
defaultLocale string The ultimate base language appended to every automatic chain. Default "en".
locales string[] Which locales to simulate. Defaults to the union of catalog files and chains keys.
chains { [locale]: string[] } Explicit fallback chains, used verbatim and taking precedence over truncation.
macrolanguages { [lang]: string } Insert a macrolanguage at the language level ({"nb":"no"}nbno … default).

The bundled examples/fallback.config.json:

{
  "defaultLocale": "en",
  "locales": ["en", "fr", "fr-CA", "es", "es-MX", "pt-BR", "de-AT", "nb", "en-CA"],
  "chains": {
    "en-CA": ["en-CA", "en-GB", "en"],
    "pt-BR": ["pt-BR", "pt", "en"]
  },
  "macrolanguages": { "nb": "no", "nn": "no" }
}

Note how en-CA uses an explicit chain to prefer British spelling (en-GB) before the base en — something plain truncation (en-CAen) would skip. Chain construction and the trade-offs between explicit chains, truncation, and macrolanguage hints are covered in Fallback chain configuration and Locale negotiation strategies.


Library API

The engine is a plain ES module you can import directly:

import {
  buildFallbackChain,
  resolveKey,
  classifyResolution,
  simulate,
  flatten,
  normalizeLocale,
} from './src/index.js';

buildFallbackChain('de-AT', { defaultLocale: 'en' });
// => ['de-AT', 'de', 'en']

buildFallbackChain('nb', { defaultLocale: 'en', macrolanguages: { nb: 'no' } });
// => ['nb', 'no', 'en']

const catalogs = {
  en: { 'checkout.tax_note': 'Taxes calculated at checkout' },
  fr: { /* no tax_note here */ },
};
resolveKey('checkout.tax_note', 'fr-CA', catalogs, { defaultLocale: 'en' });
// => { value: 'Taxes calculated at checkout', resolvedLocale: 'en', level: 1,
//      isFallback: true, isMissing: false }
Function Returns
buildFallbackChain(locale, options) string[] — the normalized, de-duplicated fallback chain (index 0 = the locale itself).
resolveKey(key, locale, catalogs, config) { value, resolvedLocale, level, isFallback, isMissing }.
classifyResolution(locale, resolution, config) 'exact' | 'region' | 'macrolanguage' | 'cross-language' | 'missing'.
simulate(catalogs, config) { keys, locales, byLocale, totals } — the full matrix and per-locale stats.
flatten(obj) Flatten a nested catalog into dot-delimited keys.
normalizeLocale(tag) Canonicalize casing/separators (pt_brpt-BR).

catalogs passed to resolveKey/simulate use flat keys; use flatten() first if your catalogs are nested (the CLI does this for you). Empty-string entries are treated as untranslated so an empty placeholder never masks a real gap.

Per-locale stats include exact, region, macrolanguage, crossLanguage, missing, gaps (an alias for crossLanguage), coveragePct (has any value), inLanguagePct (exact + region + macrolanguage), and exactPct.


Exit codes

Code Meaning
0 Success. No locale exceeded --max-gaps (or the flag was not used).
1 At least one locale's cross-language gap count exceeded --max-gaps.
2 Usage error (bad option, missing/invalid directory, unparseable JSON).

Examples & tests

  • examples/catalogs/ — thirteen realistic partial catalogs (base en, plus fr/fr-CA, es/es-MX, pt/pt-BR, de/de-AT, Norwegian nb/no, and en-GB/en-CA).
  • examples/fallback.config.json — the configuration used in every example above.
  • test/ — a node:test suite covering chain building (truncation, explicit chains, macrolanguage maps, normalization), resolveKey levels, gap classification (region vs cross-language vs macrolanguage), the simulation math, and the CLI end-to-end.

Run the suite:

npm test        # or: node --test
ℹ tests 35
ℹ pass 35
ℹ fail 0

Further reading

Deeper background on the concepts this tool exercises, from the i18n & l10n Pipelines reference:

License

MIT © 2026 i18n-l10n


Built as an open tool for the i18n & l10n Pipelines reference.

About

Zero-dependency simulator that reveals which locales silently leak the base language through their translation fallback chains

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages