IAMScouting docs

Internationalisation (i18n) — Network surfaces

IAMScouting Network ships in 12 languages (en, de, es, fr, it, pt, nl, tr, ru, ar, zh, ja). The translation pipeline has three layers, each cheaper to edit than the one above it:

| Layer | Source | Edit cost | Engine |

|-------|---------------------------------------|-----------|-----------------------|

| 1 | messages/en.json | manual | (canonical source) |

| 2 | messages/{locale}.json | rebuild | Google Translate baseline (free) |

| 3 | i18n_overrides table (DB) | live | Manual + DeepL (paid) |

At runtime, lib/i18n.ts reads the JSON file for the active locale then layers the matching rows from public.i18n_overrides on top. The override layer wins on conflict.

Why three layers?

1. Admin manual edits via /admin/translations (single key, takes effect on next render).

2. The weekly DeepL pass (scripts/retranslate-with-deepl.mjs) — higher quality, paid.

The override table also dodges a real problem with editing the JSON directly: the Dockerfile bakes messages/ into the standalone image (COPY . .). File writes inside the container are ephemeral and lost on rebuild. DB rows survive.

Translation engines

Baseline — Google (free, automatic)

scripts/translate-network.mjs — calls Google's unofficial translate_a/single endpoint, no key required. Run from a developer machine when you've added a meaningful number of EN keys:

node scripts/translate-network.mjs

It writes back to messages/{locale}.json for every non-EN locale. Commit + redeploy.

Placeholders ({pins}, {year}, etc.) are escaped as __PH<N>__ before the API call and restored after — DeepL and Google both leave them alone that way.

Improvement — DeepL (paid, weekly cron)

scripts/retranslate-with-deepl.mjs — uses the DeepL /v2/translate endpoint when DEEPL_API_KEY is set. Writes into i18n_overrides (NOT back to disk), so improvements layer over the JSON baseline without touching the build.

Key behaviour:

Locale → DeepL target mapping is inline in the script. AR (Arabic) is attempted but DeepL may reject — the script tolerates per-language failure and moves on.

Cron schedule

A weekly run is installed on the production VPS:

0 6 * * 0 cd /opt/iamscouting && grep -q ^DEEPL_API_KEY=.\+ .env && node scripts/retranslate-with-deepl.mjs >> /var/log/iams-i18n-deepl.log 2>&1

Sundays 06:00 UTC. The grep -q ^DEEPL_API_KEY= precondition means the cron is a no-op until the key is wired into /opt/iamscouting/.env. To enable:

ssh myserver
echo "DEEPL_API_KEY=<your-key>" >> /opt/iamscouting/.env
# Optional dry-run:
cd /opt/iamscouting && node scripts/retranslate-with-deepl.mjs
# Tail the cron log on Sundays:
tail -f /var/log/iams-i18n-deepl.log

Free-tier keys end in :fx; the script auto-routes them at api-free.deepl.com.

Admin editor

/admin/translations shows all keys × all locales in a dense grid. The page reads:

  1. Every messages/{locale}.json (server-side).
  2. The full i18n_overrides table.
  3. Layers (2) on top of (1) so the visible value matches what users see.

Each non-EN cell is inline-editable. onBlur POSTs to /api/admin/translations which upserts into i18n_overrides and clears the in-memory cache (lib/i18n.ts::clearI18nCache) so the next render reflects the edit.

The DeepL re-translate pass and the admin UI write to the same table — manual edits made via the UI will be respected by the DeepL cron until they age past the 7-day skip window.

When to use which

| Situation | Action |

|-------------------------------------------------|-------------------------------------------------|

| Adding new EN keys | Edit en.jsonnode scripts/translate-network.mjs → commit |

| Wholesale quality upgrade | Wire DEEPL_API_KEY into prod .env |

| Fixing one bad string | /admin/translations (live, no rebuild) |

| Re-translating after a major copy refactor | Wait for next Sunday cron, OR run script manually |

Implementation notes