i18n — Internationalization Guide
OmniRoute supports 66 languages with full dashboard UI translation, translated documentation, and RTL support for Arabic and Hebrew.
🌐 Languages: 🇺🇸 English | 🇸🇦 العربية | 🇦🇿 Azərbaycan dili | 🇧🇬 Български | 🇧🇩 বাংলা | 🇨🇿 Čeština | 🇩🇰 Dansk | 🇩🇪 Deutsch | 🇪🇸 Español | 🇮🇷 فارسی | 🇫🇮 Suomi | 🇫🇷 Français | 🇮🇳 ગુજરાતી | 🇮🇱 עברית | 🇮🇳 हिन्दी | 🇭🇺 Magyar | 🇮🇩 Bahasa Indonesia | 🇮🇹 Italiano | 🇯🇵 日本語 | 🇰🇷 한국어 | 🇮🇳 मराठी | 🇲🇾 Bahasa Melayu | 🇳🇱 Nederlands | 🇳🇴 Norsk | 🇵🇭 Filipino | 🇵🇱 Polski | 🇵🇹 Português (Portugal) | 🇧🇷 Português (Brasil) | 🇷🇴 Română | 🇷🇺 Русский | 🇸🇰 Slovenčina | 🇸🇪 Svenska | 🇰🇪 Kiswahili | 🇮🇳 தமிழ் | 🇮🇳 తెలుగు | 🇹🇭 ไทย | 🇹🇷 Türkçe | 🇺🇦 Українська | 🇵🇰 اردو | 🇻🇳 Tiếng Việt | 🇨🇳 中文 (简体) | 🇹🇼 中文 (繁體)
Translation pipeline (recommended — v3.8.0)
OmniRoute uses a hash-based incremental translator for docs, backed by an
OpenAI-compatible LLM endpoint (typically cx/gpt-5.4-mini through OmniRoute
Cloud):
# Run translations (incremental — only touches changed sources)
npm run i18n:run
# Limit to one locale
npm run i18n:run -- --locale=pt-BR
# Specific files (comma-separated, repo-relative paths)
npm run i18n:run -- --files=CLAUDE.md,docs/architecture/ARCHITECTURE.md
# Force retranslate everything (expensive)
npm run i18n:run -- --force
# Preview what would happen (no API calls, no writes)
npm run i18n:run:dry
# CI gate — exits non-zero if state is drifting (blocking in CI since 2026-09)
npm run i18n:checkA PR that edits one of the 22 core docs must bring its mirrors along: npm run i18n:run -- --files=<that doc> retranslates only the ## sections whose text changed (section cache in
.i18n-state.json); for a mechanical edit (counts, links, language bars) npm run i18n:run -- --adopt --files=<that doc> records the new source hash without translating.
Re-bootstrapping the state. npm run i18n:run -- --adopt rebuilds
.i18n-state.json from the mirrors already on disk — no API calls, no writes to
any .md. Use it after a source edit that needs no retranslation (a link list,
a count) or when the state file was lost; add --files=… / --locale=… to
adopt a subset and --dry-run to preview.
Source of truth. config/i18n.json lists every locale (UI + docs) plus
the RTL set and the docsExcluded codes. The runtime config in
src/i18n/config.ts is a thin adapter over that JSON.
Backend. Configured via env (set in .env, never committed):
| Variable | Purpose |
|---|---|
OMNIROUTE_TRANSLATION_API_URL | OpenAI-compatible base URL, e.g. …/v1 |
OMNIROUTE_TRANSLATION_API_KEY | bearer token (kept out of logs) |
OMNIROUTE_TRANSLATION_MODEL | model id, e.g. cx/gpt-5.4-mini |
OMNIROUTE_TRANSLATION_TIMEOUT_MS | optional, default 60000 |
OMNIROUTE_TRANSLATION_CONCURRENCY | optional, default 4 |
State tracking. .i18n-state.json (committed) keeps SHA-256 hashes per
source + per locale. Drift detection is automatic and deterministic — no API
calls in i18n:check.
Output shape. Each translated file gets a top-level # <heading> (<native>) line, a 🌐 Languages: … bar, an --- separator, and the
translated body. That layout matches what scripts/check/check-docs-sync.mjs
already enforces for llm.txt and CHANGELOG.md mirrors.
Legacy scripts (deprecated)
The older Python script (scripts/i18n/i18n_autotranslate.py) and the
Google-Translate-backed generator (scripts/i18n/generate-multilang.mjs)
still exist with a deprecation banner. They will be removed in v3.10. Do not
use generate-multilang.mjs messages to translate UI strings — the supported
path is the LLM backend:
# fills every missing/placeholder UI key in every locale catalog
npm run i18n:sync-ui -- --translate-markers --batch-size=40Only the readme mode (root README variants) has no replacement yet.
Quick Reference
| Task | Command |
|---|---|
| Add a language everywhere | npm run i18n:add-locale -- --code=el … (config → docs → CLI → site) |
| Translate docs (LLM) | npm run i18n:run (preferred — incremental, hash-based) |
| Translate UI strings | npm run i18n:sync-ui -- --translate-markers --batch-size=40 |
| Check translation drift | npm run i18n:check |
| Real-translation ratchet | npm run i18n:check-ratio (identical-to-English / placeholder %) |
| Regenerate 🌐 language bars | npm run i18n:sync-bars |
| Validate a locale | python3 scripts/i18n/validate_translation.py quick -l cs |
| Check code keys | python3 scripts/i18n/check_translations.py |
| Generate QA report | node scripts/i18n/generate-qa-checklist.mjs |
| Visual QA (Playwright) | node scripts/i18n/run-visual-qa.mjs |
Architecture
Source: diagrams/i18n-flow.mmd
Source of Truth
- UI strings:
src/i18n/messages/en.json(English source, ~2800 keys) - Locale files:
src/i18n/messages/{locale}.json(42 translations) - Framework:
next-intlwith cookie-based locale resolution - Config:
src/i18n/config.ts— defines all 42 locales, language names, flags
Runtime Flow
- User selects language →
NEXT_LOCALEcookie set src/i18n/request.tsresolves locale: cookie →Accept-Languageheader → fallbacken- Dynamic import loads
messages/{locale}.json - Components use
useTranslations("namespace")andt("key")
Supported Locales
| Code | Language | RTL | Google Translate Code |
|---|---|---|---|
am | አማርኛ | No | am |
ar | العربية | Yes | ar |
az | Azərbaycan dili | No | az |
bg | Български | No | bg |
bn | বাংলা | No | bn |
cs | Čeština | No | cs |
da | Dansk | No | da |
de | Deutsch | No | de |
el | Ελληνικά | No | el |
en | English | No | en |
es | Español | No | es |
et | Eesti | No | et |
fa | فارسی | Yes | fa |
fi | Suomi | No | fi |
fr | Français | No | fr |
ga | Gaeilge | No | ga |
gu | ગુજરાતી | No | gu |
ha | Hausa | No | ha |
he | עברית | Yes | iw |
hi | हिन्दी | No | hi |
hr | Hrvatski | No | hr |
hu | Magyar | No | hu |
hy | Հայերեն | No | hy |
id | Bahasa Indonesia | No | id |
ig | Igbo | No | ig |
it | Italiano | No | it |
ja | 日本語 | No | ja |
ka | ქართული | No | ka |
km | ខ្មែរ | No | km |
kn | ಕನ್ನಡ | No | kn |
ko | 한국어 | No | ko |
lt | Lietuvių | No | lt |
lv | Latviešu | No | lv |
ml | മലയാളം | No | ml |
mr | मराठी | No | mr |
ms | Bahasa Melayu | No | ms |
mt | Malti | No | mt |
my | မြန်မာ | No | my |
ne | नेपाली | No | ne |
nl | Nederlands | No | nl |
no | Norsk | No | no |
or | ଓଡ଼ିଆ | No | or |
pa | ਪੰਜਾਬੀ | No | pa |
phi | Filipino | No | tl |
pl | Polski | No | pl |
pt | Português (Portugal) | No | pt |
pt-BR | Português (Brasil) | No | pt |
ro | Română | No | ro |
ru | Русский | No | ru |
si | සිංහල | No | si |
sk | Slovenčina | No | sk |
sl | Slovenščina | No | sl |
sr | Српски | No | sr |
sv | Svenska | No | sv |
sw | Kiswahili | No | sw |
ta | தமிழ் | No | ta |
te | తెలుగు | No | te |
th | ไทย | No | th |
tr | Türkçe | No | tr |
uk-UA | Українська | No | uk |
ur | اردو | Yes | ur |
uz | Oʻzbekcha | No | uz |
vi | Tiếng Việt | No | vi |
yo | Yorùbá | No | yo |
zh-CN | 中文 (简体) | No | zh-CN |
zh-TW | 中文 (繁體) | No | zh-TW |
Adding a New Language
One command adds a locale to every surface (config, flag, dashboard catalog, docs mirrors, CLI catalog, README + indexes, language bars, and optionally the site):
# needs OMNIROUTE_TRANSLATION_API_URL / _API_KEY / _MODEL in .env
node scripts/i18n/add-locale.mjs --code=el --english=Greek --native=Ελληνικά --flag=🇬🇷
# Indian languages share the in.svg flag file:
node scripts/i18n/add-locale.mjs --code=kn --english=Kannada --native=ಕನ್ನಡ --flag=🇮🇳 --flag-file=in.svg
# preview only:
node scripts/i18n/add-locale.mjs --code=el --english=Greek --native=Ελληνικά --flag=🇬🇷 --dry-runThen validate:
node --import tsx/esm --test tests/unit/i18n-locale-surfaces-parity.test.ts
npm run i18n:check-ui-coverage && npm run i18n:check-ratio && npm run check:docs-all && npm run check:cli-i18nconfig/i18n.json is the only place a locale is declared; src/i18n/config.ts is a typed
adapter and must not be edited by hand. The Google-Translate generator
(generate-multilang.mjs) is deprecated and is not part of this flow.
Stale mirrors after an interrupted refresh
npm run i18n:check also lists stale targets: mirrors whose locale record in .i18n-state.json was written from an older version of the source than the top-level source_hash now records. That is what a refresh run interrupted halfway (an upstream quota 429, a killed runner) leaves behind — the source no longer reads as changed and the mirrors' own hashes still match, so without this check the gate would say PASS. Stale targets are a warning, not a failure: rerun npm run i18n:run -- --locale=<code> --files=<sources> for the listed locales (or _artifacts-style batch runner) and the list empties.
Keeping catalogs complete and retranslating English copies
Three gates guard the catalogs, and they see different things:
| Gate | Sees |
|---|---|
npm run i18n:check-ui-coverage | ≥ 80 % of leaves translated per locale |
npm run i18n:check-new-keys | a key the PR adds to en.json is translated in every locale — a __MISSING__: marker counts as absent (since 2026-09-17) |
npm run i18n:check-keys | every locale carries exactly the key set of en.json, whatever the age |
npm run i18n:check-keys:cli | the CLI catalogs carry exactly the key set of bin/cli/locales/en.json |
npm run i18n:check-ratio | share of leaves still identical to English may only fall (ratchet) |
Translating the keys a branch adds (parallel runner)
A branch that adds or changes keys in en.json must translate them before its PR opens — the
new-key gate rejects __MISSING__: markers (on 2026-09-16 eight feature PRs shipped 61 keys as
markers into all 65 locales and the ratio gate went red on the release tip for everybody).
scripts/i18n/translate-new-keys.sh translates every locale in parallel and is safe to detach:
# pinned-name guard first: keys a test pins to English go in scripts/i18n/untranslatable-keys.json
rg -n 'assert\.equal\(.*messages\.[a-zA-Z.]+, "' tests/unit
# needs OMNIROUTE_TRANSLATION_API_URL / _API_KEY / _MODEL in .env — refuses to start otherwise
npm run i18n:translate-new-keys # all locales, 5 workers
bash scripts/i18n/translate-new-keys.sh --locales=pt-BR,es # a subset
bash scripts/i18n/translate-new-keys.sh --catalog=cli --workers=3 # bin/cli/locales
# detached: the shell returns at once; poll _artifacts/i18n-new-keys/batch.rc (0 = done)
nohup setsid bash scripts/i18n/translate-new-keys.sh > _artifacts/i18n-new-keys/runner.out 2>&1 &Each locale gets three attempts of sync-ui-keys.mjs --translate-markers --batch-size=40;
per-locale logs and <code>.exit land in _artifacts/i18n-new-keys/, and the script exits
non-zero while any selected locale still carries a marker. Then
npm run i18n:check-keys && npm run i18n:check-ratio && npm run i18n:check-new-keys.
After every merge of the base into a locale branch, re-sync the locales the branch owns — the base keeps adding keys while a batch translates:
node scripts/i18n/sync-ui-keys.mjs --locale=km,kn,ml --translate-markers --batch-size=40
npm run i18n:check-keysRetranslating verbatim-English leaves (--retranslate-identical) turns every leaf that is
still byte-identical to en.json — outside scripts/i18n/untranslatable-keys.json — into a
__MISSING__: placeholder and translates it in the same run. Before a bulk run, put every key
that a test pins to its English value (product, engine and flag names — e.g. the Vietnamese
sidebar engines in dashboard-localization-contract.test.ts, the pt-BR label in
server-owned-tool-loop-flag.test.ts) into the allowlist first, then:
node scripts/i18n/sync-ui-keys.mjs --locale=es --retranslate-identical --translate-markers --batch-size=40
npm run i18n:check-ratio:update # tighten the baseline once the locale improved
npm run i18n:check-glossary # zh-CN / zh-TW / ko protected termsAuto-Translation Pipeline
generate-multilang.mjs (Google Translate)
Primary auto-translation engine — uses Google Translate free API to generate translations for UI strings, READMEs, and documentation.
node scripts/i18n/generate-multilang.mjs [messages|readme|docs|all]| Mode | What it does |
|---|---|
messages | Translates missing keys in src/i18n/messages/{locale}.json from en.json |
readme | Translates README.md into all locales as README.{code}.md in project root |
docs | Translates DOC_SOURCE_FILES into docs/i18n/{locale}/{docName} |
all | Runs all three modes |
Features:
- Text protection: Masks code blocks (
```), inline code (`), markdown links/images ([text](url)), HTML tags, tables, and ICU placeholders ({count},{value},{total}, etc.) before translation, then restores them - Chunked batching: Joins multiple strings with
__OMNIROUTE_I18N_SEPARATOR__delimiters to minimize API calls (max 1800 chars per request) - In-memory cache: Avoids redundant API calls for repeated strings within a session
- Retry logic: Exponential backoff (up to 5 attempts with 300ms × attempt delay) for 429/5xx errors
- Timeout: 20 seconds per request
- Skip existing: If target file already exists, it is NOT overwritten
Important behaviors:
docs/i18n/README.mdis regenerated each run — it's an auto-generated index of all docs- Root
README.{code}.mdfiles are only created if they don't exist (skips locales inEXISTING_README_CODES) - Language bars (
🌐 **Languages:** ...) are automatically inserted/updated in all translated docs
i18n_autotranslate.py (LLM-based)
Secondary translator — uses any OpenAI-compatible LLM API (including OmniRoute itself) to translate existing docs/i18n/ markdown files. Best for polishing or re-translating docs with better quality than Google Translate.
python3 scripts/i18n/i18n_autotranslate.py \
--api-url http://localhost:20128/v1 \
--api-key sk-your-key \
--model gpt-4oFeatures:
- Scans
docs/i18n/markdown files for English paragraphs - Skips code blocks, tables, and already-translated content
- Sends paragraphs to LLM with technical translation system prompt
- Supports all 42 languages
CLI i18n
The omniroute CLI has its own i18n layer separate from the Next.js dashboard.
How it works
- Every user-facing string in CLI commands goes through
t("module.key", vars)frombin/cli/i18n.mjs. - Catalogs are JSON files in
bin/cli/locales/— 42 ship out-of-the-box. - Locale falls back to
enfor any missing key, so partial translations are valid. - The source of truth for available locales is
config/i18n.json(shared with the dashboard).
Locale selection
Detection order (first match wins):
| Priority | Source | Example |
|---|---|---|
| 1 | --lang flag | omniroute --lang de status |
| 2 | OMNIROUTE_LANG env var | OMNIROUTE_LANG=ja omniroute providers |
| 3 | LC_ALL system env | auto-detected from terminal locale |
| 4 | LC_MESSAGES system env | auto-detected from terminal locale |
| 5 | LANG system env | auto-detected from terminal locale |
| 6 | Fallback | en |
Locale codes with underscores (pt_BR) are normalized to hyphen form (pt-BR).
Locale codes are validated against /^[a-zA-Z0-9-]+$/ — path traversal is rejected.
Saving a language preference
# Set language and save to ~/.omniroute/.env (persists across sessions)
omniroute config lang set pt-BR
# View current language
omniroute config lang get
# List all 42 available languages
omniroute config lang list
# JSON output
omniroute config lang list --output jsonThe saved preference is written atomically to ~/.omniroute/.env and is loaded by the
CLI bootstrap before any command runs.
One-time override
# Override for one command only (not persisted)
omniroute --lang de providers listNote: the --lang flag does NOT write to the env file — it only affects the current
invocation. Use config lang set to persist.
Available locales
42 locale files ship in bin/cli/locales/. Full translations: en, pt-BR.
Scaffold-only (all keys fall back to en): bn, gu, he, mr, ms, phi, sw, ta, te, ur.
All other 30 locales have common + program keys translated.
Adding a new CLI locale
- Add the locale entry to
config/i18n.json. - Run
node bin/cli/scripts/generate-locales.mjs— creates the locale file. - Translate the keys (or leave as
{}for en-fallback scaffold). - PRs must add strings to
en.jsonandpt-BR.json; other files are best-effort.
Validation & QA
validate_translation.py
Translation validator — compares any locale JSON against en.json and reports issues.
# Quick check (counts only)
python3 scripts/i18n/validate_translation.py quick -l cs
# Output:
# Missing: 0
# Untranslated: 0
# Ignored (UNTRANSLATABLE_KEYS): 236
# Detailed diff by category
python3 scripts/i18n/validate_translation.py diff common -l cs
python3 scripts/i18n/validate_translation.py diff settings -l cs
# Export to CSV
python3 scripts/i18n/validate_translation.py csv -l cs > report.csv
# Export to Markdown
python3 scripts/i18n/validate_translation.py md -l cs > report.md
# Full report (default)
python3 scripts/i18n/validate_translation.py -l csDetects:
- Missing keys — keys in
en.jsonbut not in locale file - Extra keys — keys in locale file but not in
en.json - Untranslated keys — keys where locale value equals English source (excluding allowlist)
- Placeholder mismatches — ICU placeholders that don't match between source and translation
Exit codes:
| Code | Meaning |
|---|---|
| 0 | OK |
| 1 | Generic error |
| 2 | Missing strings (hard error) |
| 3 | Untranslated warning (soft) |
Environment: Set TRANSLATION_LANG=cs or use -l cs flag.
check_translations.py
Code-to-JSON key checker — scans src/**/*.tsx and src/**/*.ts for useTranslations() calls and verifies all referenced keys exist in en.json.
# Basic check
python3 scripts/i18n/check_translations.py
# Verbose output
python3 scripts/i18n/check_translations.py --verbose
# Auto-fix (adds missing keys to en.json)
python3 scripts/i18n/check_translations.py --fixgenerate-qa-checklist.mjs
Static analysis QA — scans Next.js page files for i18n risk metrics and generates a Markdown report.
node scripts/i18n/generate-qa-checklist.mjsChecks:
- Fixed-width class usage (overflow risk)
- Directional left/right classes (RTL risk)
- Clipping-prone patterns
- Locale parity (missing/extra keys vs
en.json) - README language selector bars in priority locales (
es,fr,de,ja,ar)
Output: docs/reports/i18n-qa-checklist-{date}.md
run-visual-qa.mjs
Visual QA via Playwright — takes screenshots of all dashboard routes in multiple locales and viewports, then evaluates page health.
# Default: es, fr, de, ja, ar, zh-CN on localhost:20128
node scripts/i18n/run-visual-qa.mjs
# Custom base URL and locales
QA_BASE_URL=http://staging.example.com QA_LOCALES=de,fr node scripts/i18n/run-visual-qa.mjs
# Custom routes
QA_ROUTES=/dashboard/settings,/dashboard/providers node scripts/i18n/run-visual-qa.mjsDetects:
- Text overflow
- Element clipping
- RTL layout mismatches
Output: docs/reports/i18n-visual-qa-{date}.md + JSON report
Managing Untranslatable Keys
untranslatable-keys.json
File: scripts/i18n/untranslatable-keys.json
Allowlist of keys that should remain identical to English source. Used by validate_translation.py to avoid false-positive "untranslated" warnings.
{
"description": "Keys that should remain untranslated...",
"keys": [
"common.model",
"common.oauth",
"health.cpu",
...
]
}What belongs here:
- Brand/product names:
landing.brandName,common.social-github - Technical terms/acronyms:
health.cpu,mcpDashboard.pid,settings.ai - ICU/format strings:
apiManager.modelsCount,health.millisecondsShort - Placeholder values:
providers.openaiBaseUrlPlaceholder,cliTools.baseUrlPlaceholder - Protocol names:
common.http,common.oauth,providers.oauth2Label - Navigation sections:
sidebar.primarySection,sidebar.cliSection
To add a key: Edit the keys array in scripts/i18n/untranslatable-keys.json and re-run validation.
Terminology Glossary
Beyond key parity (check-ui-keys-coverage.mjs) and ICU validity
(validate_translation.py), a maintained per-locale terminology layer
catches semantic drift that those two gates cannot see — e.g. the same
English concept ("provider") rendered with two different, equally-valid
Chinese words (提供商 vs 提供者) across thousands of strings. #8038 added
this layer starting with zh-CN.
scripts/i18n/glossary/<locale>.json
Versioned glossary of recurring concepts (provider, connection, routing,
fallback, quota, context window, reasoning, tool call, cache, circuit
breaker…) for that locale. Each concept has a canonical translation and an
optional synonyms list — any synonym found in the catalog is flagged as
drift. A concept with an empty synonyms array is documented but not yet
enforced (the catalog still has legitimate mixed usage for it; normalizing
it is a follow-up, not this gate's job).
scripts/i18n/glossary/zh-CN.json seeds the ten concepts above; provider
is the only one currently enforced, following the one-shot
提供商→提供者 normalization pass (mirroring #8024's zh-TW pass) across
src/i18n/messages/zh-CN.json and bin/cli/locales/zh-CN.json.
scripts/i18n/glossary/protected-terms.json
Flat list of product/provider/model/protocol/CLI/env/identifier names
(OmniRoute, OAuth, MCP, A2A, DATA_DIR, …) that must appear verbatim
inside any translated value. This is a different granularity than
untranslatable-keys.json (which excludes whole KEYS from the parity/ICU
checks) — protected terms are checked wherever they show up inside any
value, keyed by concept rather than by key path.
check-glossary-consistency.mjs
# zh-CN by default
npm run i18n:check-glossary
# explicit locale, JSON report, or non-failing report mode
node scripts/i18n/check-glossary-consistency.mjs --locale=zh-CN
node scripts/i18n/check-glossary-consistency.mjs --locale=zh-CN --json
node scripts/i18n/check-glossary-consistency.mjs --locale=zh-CN --reportExports a pure checkGlossaryConsistency(localeMessages, glossary, protectedTerms) function (mirroring evaluateFileSizes in
check-file-size.mjs) returning { violations: [...] } — a
glossary-synonym violation for a non-canonical term, or a
protected-term-altered violation for a mistranslated protected name.
Wired into CI as the i18n-glossary-zhcn job (same gating as
i18n-ui-coverage: skips draft PRs, runs when i18n or code changes).
CI Integration
GitHub Actions (.github/workflows/ci.yml)
The CI pipeline validates all locales on every push and PR:
i18n-matrixjob — dynamically discovers all locale files (excludingen.json)i18njob — runsvalidate_translation.py quick -l '<lang>'for each locale in parallelci-summaryjob — aggregates results into a dashboard summary
# i18n-matrix: discovers languages
LANGS=$(ls src/i18n/messages/*.json | xargs -n1 basename | sed 's/.json$//' | grep -v '^en$')
# i18n: validates each language
python3 scripts/i18n/validate_translation.py quick -l '${{ matrix.lang }}'Dashboard output:
## 🌍 Translations
| Metric | Value |
|--------|------|
| Languages checked | 30 |
| Total untranslated | 0 |
✅ All translations completeFile Structure
src/i18n/
├── config.ts # Locale definitions (42 locales, RTL config)
├── request.ts # Runtime locale resolution
└── messages/
├── en.json # Source of truth (~2800 keys)
├── cs.json # Czech translation
├── de.json # German translation
└── ... # 30 locale files total
scripts/
├── i18n/
│ ├── generate-multilang.mjs # Auto-translation engine (Google Translate, 888 lines)
│ ├── generate-qa-checklist.mjs # Static analysis QA
│ ├── run-visual-qa.mjs # Playwright visual QA
│ └── untranslatable-keys.json # Allowlist for validation (236 keys)
├── validate_translation.py # Translation validator
├── check_translations.py # Code-to-JSON key checker
└── i18n_autotranslate.py # LLM-based doc translator
.github/workflows/
└── ci.yml # i18n validation in CI matrix
docs/
├── I18N.md # This file — i18n toolchain documentation
├── i18n/
│ ├── README.md # Auto-generated language index
│ ├── cs/ # Czech docs
│ │ └── docs/
│ │ ├── I18N.md # Czech translation of this file
│ │ └── ...
│ ├── de/ # German docs
│ └── ... # 30 locale directories
└── reports/
├── i18n-qa-checklist-*.md # Static analysis reports
└── i18n-visual-qa-*.md # Visual QA reportsBest Practices
When Editing Translations
- Always edit
en.jsonfirst — it's the source of truth - Run
generate-multilang.mjs messagesto propagate new keys to all locales - Review auto-translations — Google Translate is a starting point, not final
- Validate before committing —
python3 scripts/i18n/validate_translation.py quick -l <lang> - Update
untranslatable-keys.jsonif a key should remain in English
Placeholder Safety
- ICU placeholders (
{count},{value},{total},{seconds}) must be preserved exactly - Plural formats (
{count, plural, one {# model} other {# models}}) must maintain structure - The validator detects placeholder mismatches automatically
Adding New Translation Keys in Code
// Use namespaced keys
const t = useTranslations("settings");
t("cacheSettings"); // maps to settings.cacheSettings in JSON
// Run check_translations.py to verify keys exist
python3 scripts/i18n/check_translations.py --verboseRTL Considerations
- Arabic (
ar) and Hebrew (he) are RTL locales - Avoid hardcoded
left/rightCSS — usestart/endlogical properties - Visual QA catches RTL layout mismatches via
run-visual-qa.mjs
Known Issues & History
in.json → hi.json Fix
The generator originally used code: "in" (deprecated Google Translate code) for Hindi instead of the correct ISO 639-1 hi. This created an orphaned in.json duplicate of hi.json. Fixed by changing code: "in" to code: "hi" in generate-multilang.mjs and removing the orphaned file.
Retired (2026-09-02):
inoutlived that fix as a second Indonesian locale —config/i18n.jsoncalled it "Indonesian (Legacy)" while the README still labelled it Hindi. It was removed from every surface (config, catalogs,docs/i18n/in/, README, indexes, baselines).idnow declares"aliases": ["in"], so a savedNEXT_LOCALE=inorOMNIROUTE_LANG=inresolves toid.
docs/i18n/README.md Is Hand-Maintained
It used to be regenerated wholesale by generate-multilang.mjs docs. It no longer is: npm run i18n:add-locale inserts the new locale's row (and updates the counts sentence) in place, and every other edit is made by hand. tests/unit/i18n-locale-surfaces-parity.test.ts guards both directions — a configured docs locale must have a row, and a row must map back to a configured docs locale.
External Untranslatable Keys List
The untranslatable-keys.json allowlist was moved from an inline Python set in validate_translation.py to an external JSON file for easier maintenance. The validator loads it at runtime.
generate-multilang.mjs Hindi Code Fix
The generator originally used code: "in" (deprecated Google Translate code) for Hindi instead of the correct ISO 639-1 hi. This was introduced in upstream commit 952b0b22c by diegosouzapw. Fixed by changing code: "in" to code: "hi" in the LOCALE_SPECS array and removing the orphaned in.json file.
validate_translation.py Ignored Count Output
The quick check now displays the count of ignored keys from untranslatable-keys.json:
Missing: 0
Untranslated: 0
Ignored (UNTRANSLATABLE_KEYS): <varies per release>