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?
en.jsonis the canonical source. Every key the product surfaces is defined here.{locale}.jsonis the baseline — a Google-translated copy. It's good enough for soft-launch but the quality is uneven (literal phrasing, missed idioms, ambiguous pronouns). Free to run, so we re-run it whenever a non-trivial chunk of EN keys are added.i18n_overridesis the improvement layer. Two writers:
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:
- Reads
messages/en.jsonas the source of truth. - Skips keys whose existing override is
< 7 daysold (idempotent — safe to re-run). - Skips a translation that came back identical to EN.
- Auto-detects
:fx-suffixed keys → DeepL Free endpoint; everything else → DeepL Pro. - Exits cleanly (status 0) with a log line if
DEEPL_API_KEYis unset, so it's safe to schedule unconditionally.
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:
- Every
messages/{locale}.json(server-side). - The full
i18n_overridestable. - 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.json → node 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
lib/i18n.tsuses an in-memory cache per locale, busted byclearI18nCache()on every admin edit. Process restart also clears it. The DeepL script does not call the cache-bust endpoint — the override row updates take effect the next timeloadMessages()misses cache (typically the next deploy or container restart). Force a refresh by hitting any admin translations save.- The
i18n_overridesschema (migration 0037) is(lang, key, value, updated_by, updated_at)with PK(lang, key). The DeepL script writeslang+key+valueonly;updated_byis left NULL for system-generated rows.