1,104 mirrors rewritten over five passes of run-translation on the 22-source core set: the 14 sources edited since their translation, the 322 mirrors that were still English copies, and the frontmatter the old extractor leaked into the newer locales' bodies. The pipeline now caches per-`## `-section hashes and retranslates only changed sections, never reuses a section that is still English, rebuilds English-copy / leaked mirrors even when the source is unchanged, merges the state on save (parallel runs), and the drift gate (scoped to the core set) is blocking. Final audit: 0 stale, 0 English copies, 0 leaked frontmatter across 1,430 core mirrors. ⚠️ base-red inherited: #12732
32 KiB
title, version, lastUpdated
| title | version | lastUpdated |
|---|---|---|
| i18n — Internationalization Guide | 3.8.40 | 2026-09-02 |
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:check
A 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=40
Only 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-run
Then 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-i18n
config/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.
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 reached every locale |
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) |
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-keys
Retranslating 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 terms
Auto-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-4o
Features:
- 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 json
The 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 list
Note: 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 cs
Detects:
- 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 --fix
generate-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.mjs
Checks:
- 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.mjs
Detects:
- 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 --report
Exports 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 complete
File 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 reports
Best 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 --verbose
RTL 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>