---
title: "i18n — Internationalization Guide"
version: 3.8.40
lastUpdated: 2026-06-28
---

# i18n — Internationalization Guide

OmniRoute supports **43 languages** with full dashboard UI translation, translated documentation, and RTL support for Arabic and Hebrew.

🌐 **Languages:** 🇺🇸 [English](./I18N.md) | 🇧🇷 [Português (Brasil)](../i18n/pt-BR/docs/guides/I18N.md) | 🇪🇸 [Español](../i18n/es/docs/guides/I18N.md) | 🇫🇷 [Français](../i18n/fr/docs/guides/I18N.md) | 🇩🇪 [Deutsch](../i18n/de/docs/guides/I18N.md) | 🇮🇹 [Italiano](../i18n/it/docs/guides/I18N.md) | 🇷🇺 [Русский](../i18n/ru/docs/guides/I18N.md) | 🇨🇳 [中文 (简体)](../i18n/zh-CN/docs/guides/I18N.md) | 🇯🇵 [日本語](../i18n/ja/docs/guides/I18N.md) | 🇰🇷 [한국어](../i18n/ko/docs/guides/I18N.md) | 🇸🇦 [العربية](../i18n/ar/docs/guides/I18N.md) | 🇮🇳 [हिन्दी](../i18n/hi/docs/guides/I18N.md) | 🇹🇭 [ไทย](../i18n/th/docs/guides/I18N.md) | 🇹🇷 [Türkçe](../i18n/tr/docs/guides/I18N.md) | 🇺🇦 [Українська](../i18n/uk-UA/docs/guides/I18N.md) | 🇻🇳 [Tiếng Việt](../i18n/vi/docs/guides/I18N.md) | 🇧🇬 [Български](../i18n/bg/docs/guides/I18N.md) | 🇩🇰 [Dansk](../i18n/da/docs/guides/I18N.md) | 🇫🇮 [Suomi](../i18n/fi/docs/guides/I18N.md) | 🇮🇱 [עברית](../i18n/he/docs/guides/I18N.md) | 🇭🇺 [Magyar](../i18n/hu/docs/guides/I18N.md) | 🇮🇩 [Bahasa Indonesia](../i18n/id/docs/guides/I18N.md) | 🇲🇾 [Bahasa Melayu](../i18n/ms/docs/guides/I18N.md) | 🇳🇱 [Nederlands](../i18n/nl/docs/guides/I18N.md) | 🇳🇴 [Norsk](../i18n/no/docs/guides/I18N.md) | 🇵🇹 [Português (Portugal)](../i18n/pt/docs/guides/I18N.md) | 🇷🇴 [Română](../i18n/ro/docs/guides/I18N.md) | 🇵🇱 [Polski](../i18n/pl/docs/guides/I18N.md) | 🇸🇰 [Slovenčina](../i18n/sk/docs/guides/I18N.md) | 🇸🇪 [Svenska](../i18n/sv/docs/guides/I18N.md) | 🇵🇭 [Filipino](../i18n/phi/docs/guides/I18N.md) | 🇨🇿 [Čeština](../i18n/cs/docs/guides/I18N.md)

## 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):

```bash
# 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
npm run i18n:check
```

**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. The
`messages` and `readme` modes of `generate-multilang.mjs` (UI strings + root
README variants) are not yet handled by the new pipeline and are still used.

## Quick Reference

| Task                    | Command                                                    |
| ----------------------- | ---------------------------------------------------------- |
| Translate docs (LLM)    | `npm run i18n:run` (preferred — incremental, hash-based)   |
| Translate UI strings    | `node scripts/i18n/generate-multilang.mjs messages`        |
| Check translation drift | `npm run i18n:check`                                       |
| 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

![Incremental hash-based i18n pipeline](../diagrams/exported/i18n-flow.svg)

> Source: [diagrams/i18n-flow.mmd](../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` (30 translations)
- **Framework**: `next-intl` with cookie-based locale resolution
- **Config**: `src/i18n/config.ts` — defines all 30 locales, language names, flags

### Runtime Flow

1. User selects language → `NEXT_LOCALE` cookie set
2. `src/i18n/request.ts` resolves locale: cookie → `Accept-Language` header → fallback `en`
3. Dynamic import loads `messages/{locale}.json`
4. Components use `useTranslations("namespace")` and `t("key")`

### Supported Locales

| Code    | Language             | RTL | Google Translate Code |
| ------- | -------------------- | --- | --------------------- |
| `ar`    | العربية              | Yes | `ar`                  |
| `bg`    | Български            | No  | `bg`                  |
| `cs`    | Čeština              | No  | `cs`                  |
| `da`    | Dansk                | No  | `da`                  |
| `de`    | Deutsch              | No  | `de`                  |
| `es`    | Español              | No  | `es`                  |
| `fi`    | Suomi                | No  | `fi`                  |
| `fr`    | Français             | No  | `fr`                  |
| `he`    | עברית                | Yes | `iw`                  |
| `hi`    | हिन्दी               | No  | `hi`                  |
| `hu`    | Magyar               | No  | `hu`                  |
| `id`    | Bahasa Indonesia     | No  | `id`                  |
| `it`    | Italiano             | No  | `it`                  |
| `ja`    | 日本語               | No  | `ja`                  |
| `ko`    | 한국어               | No  | `ko`                  |
| `ms`    | Bahasa Melayu        | No  | `ms`                  |
| `nl`    | Nederlands           | No  | `nl`                  |
| `no`    | Norsk                | No  | `no`                  |
| `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`                  |
| `sk`    | Slovenčina           | No  | `sk`                  |
| `sv`    | Svenska              | No  | `sv`                  |
| `th`    | ไทย                  | No  | `th`                  |
| `tr`    | Türkçe               | No  | `tr`                  |
| `uk-UA` | Українська           | No  | `uk`                  |
| `vi`    | Tiếng Việt           | No  | `vi`                  |
| `zh-CN` | 中文 (简体)          | No  | `zh-CN`               |
| `zh-TW` | 中文 (繁體)          | No  | `zh-TW`               |

## Adding a New Language

### 1. Register the Locale

Edit `src/i18n/config.ts`:

```ts
// Add to LOCALES array
"xx",
// Add to LANGUAGES array
{ code: "xx", label: "XX", name: "Language Name", flag: "🏳️" },
```

### 2. Add to Generator

Edit `scripts/i18n/generate-multilang.mjs` — add entry to `LOCALE_SPECS`:

```js
{
  code: "xx",
  googleTl: "xx",
  label: "XX",
  flag: "🏳️",
  languageName: "Language Name",
  readmeName: "Language Name",
  docsName: "Language Name",
},
```

### 3. Generate Initial Translation

```bash
node scripts/i18n/generate-multilang.mjs messages
```

This creates `src/i18n/messages/xx.json` auto-translated from `en.json` via Google Translate.

### 4. Review & Fix Auto-Translations

Auto-translations are a starting point. Review manually for:

- Technical accuracy
- Context-appropriate terminology
- Proper handling of placeholders (`{count}`, `{value}`, etc.)

### 5. Validate

```bash
python3 scripts/i18n/validate_translation.py quick -l xx
python3 scripts/i18n/validate_translation.py diff common -l xx
```

### 6. Generate Translated Documentation

```bash
node scripts/i18n/generate-multilang.mjs docs
```

## 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.

```bash
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.md` is **regenerated** each run — it's an auto-generated index of all docs
- Root `README.{code}.md` files are only created if they don't exist (skips locales in `EXISTING_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.

```bash
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 43 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)` from `bin/cli/i18n.mjs`.
- Catalogs are JSON files in `bin/cli/locales/` — 43 ship out-of-the-box.
- Locale falls back to `en` for 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

```bash
# 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

```bash
# 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

43 locale files ship in `bin/cli/locales/`. Full translations: `en`, `pt-BR`.
Scaffold-only (all keys fall back to `en`): `bn`, `gu`, `he`, `in`, `mr`, `ms`, `phi`, `sw`, `ta`, `te`, `ur`.
All other 29 locales have `common` + `program` keys translated.

### Adding a new CLI locale

1. Add the locale entry to `config/i18n.json`.
2. Run `node bin/cli/scripts/generate-locales.mjs` — creates the locale file.
3. Translate the keys (or leave as `{}` for en-fallback scaffold).
4. PRs must add strings to `en.json` and `pt-BR.json`; other files are best-effort.

## Validation & QA

### validate_translation.py

**Translation validator** — compares any locale JSON against `en.json` and reports issues.

```bash
# 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.json` but 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`.

```bash
# 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.

```bash
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.

```bash
# 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.

```json
{
  "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

```bash
# 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:

1. **`i18n-matrix` job** — dynamically discovers all locale files (excluding `en.json`)
2. **`i18n` job** — runs `validate_translation.py quick -l '<lang>'` for each locale in parallel
3. **`ci-summary` job** — aggregates results into a dashboard summary

```yaml
# 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 (30 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

1. **Always edit `en.json` first** — it's the source of truth
2. **Run `generate-multilang.mjs messages`** to propagate new keys to all locales
3. **Review auto-translations** — Google Translate is a starting point, not final
4. **Validate before committing** — `python3 scripts/i18n/validate_translation.py quick -l <lang>`
5. **Update `untranslatable-keys.json`** if 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

```tsx
// 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`/`right` CSS — use `start`/`end` logical 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.

> ⚠️ **Audit (2026-05-13):** The `docs/i18n/in/` directory still exists on disk (full duplicate of `hi/`). Translation generator no longer writes to it, but the historical tree was not pruned. Safe to delete with `rm -rf docs/i18n/in/` after confirming no external links reference the old path.

### `docs/i18n/README.md` Is Auto-Generated

The `docs/i18n/README.md` file is completely regenerated by `generate-multilang.mjs docs`. Any manual edits will be lost. Use `docs/guides/I18N.md` (this file) for hand-written documentation that should persist.

### 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>
```
