Translation Tool
The DeepL batch translator that fills every target locale from English — multi-key rotation, resume, placeholder protection, and scoped runs.
Luminaux ships a Node script — tools/translate/translate.mjs, run via npm run i18n:translate —
that fills every target locale from the English source using the DeepL API. It rotates across
multiple API keys, resumes after interruption, protects i18next {{placeholders}}, streams live
progress, and can be scoped to specific languages or namespaces. A companion script —
tools/translate/check-usage.mjs (npm run i18n:usage) — reports each key's remaining monthly
character quota.
Overview
English (src/locales/en/*.json) is the single source of truth. The translator reads every English
namespace file and produces the matching file for each target language —
fr / nl / de / es / pt (→ PT-BR) / zh / ja — writing to src/locales/<lng>/<ns>.json. It is
incremental (a hash cache skips unchanged strings), resumable (interruptions don't lose finished
work), and safe (interpolation variables pass through untouched).
Key properties:
- Multi-key rotation. Supply one or many DeepL keys; the tool round-robins requests across them to spread load (avoiding HTTP 429 rate limits) and automatically retires a key that hits its monthly quota, continuing on the rest.
- Incremental + resumable. Every English value is hashed per language; unchanged strings are skipped on re-run. The cache is flushed after each completed file and on Ctrl-C, so an interrupted run continues where it left off.
- Placeholder-safe. i18next tokens like
{{name}}/{{count}}are masked before translation and restored after, so DeepL never translates a variable name (which would break runtime interpolation). - Live verbose output. Each translation streams as it happens, with a per-language header and a running counter.
- Scoped runs.
--langand--nsflags let you translate one language or one namespace at a time, turning a large job into small, manageable runs.
Billing
Characters are billed by DeepL per source character, once per target language. The English source is
~75,200 characters, so a full from-scratch run costs ~526,000 characters (75.2k × 7 languages).
Checking usage (getUsage) costs nothing.
Architecture & files
| File | Responsibility |
|---|---|
tools/translate/translate.mjs | The translator — key pool + rotation, hash cache, incremental save/resume, placeholder masking, live logging, CLI filters. |
tools/translate/check-usage.mjs | Read-only usage/balance report across all configured keys (npm run i18n:usage). |
tools/translate/.translation-cache.json | Hash cache — metadata[namespace][language][key] = md5(englishValue). Auto-generated; safe to delete. |
src/locales/en/*.json | The source English namespaces (flat "key": "value" JSON). |
src/locales/<lng>/*.json | The generated target locales (one flat JSON per namespace per language). |
.env | DEEPL_API_KEY / DEEPL_API_KEY_1..N — read via node --env-file=.env. Git-ignored. |
package.json | i18n:translate and i18n:usage scripts. |
The npm scripts:
{
"i18n:translate": "node --env-file=.env tools/translate/translate.mjs",
"i18n:usage": "node --env-file=.env tools/translate/check-usage.mjs"
}Target languages are declared in translate.mjs as TARGET_LANGUAGES (folder → DeepL code) and
TARGET_DIRS:
const TARGET_LANGUAGES = {fr: 'FR', nl: 'NL', de: 'DE', es: 'ES', pt: 'PT-BR', zh: 'ZH', ja: 'JA'}Running the tool
Configure keys
Copy .env.example to .env and add one or more DeepL keys. Free-tier keys end in :fx and allow
500,000 characters/month each; Pro keys have no :fx suffix. Two interchangeable formats (combine
freely):
# a) comma/space-separated list on one var
DEEPL_API_KEY=key-one:fx,key-two:fx,key-three:fx
# b) numbered vars
DEEPL_API_KEY_1=key-one:fx
DEEPL_API_KEY_2=key-two:fx
DEEPL_API_KEY_3=key-three:fxCheck remaining quota (optional, free)
npm run i18n:usageDeepL usage — 3 key(s)
key#1(…4d9:fx): 4,549 / 1,000,000 used (0.5%) — 995,451 left
key#2(…f3d:fx): 4,494 / 1,000,000 used (0.4%) — 995,506 left
key#3(…973:fx): 4,704 / 1,000,000 used (0.5%) — 995,296 left
Pool total: 13,747 / 3,000,000 used — 2,986,253 characters remaining across 3 key(s)
Full translation run ≈ 526,386 chars (75,198 source × 7 languages). Remaining pool COVERS a full from-scratch run.
Translate
# everything — all languages, all namespaces
npm run i18n:translate
# also keep a log while watching live
npm run i18n:translate 2>&1 | tee translate.logLive output looks like:
▶ widgets → FR (214 to translate, 0 cached)
ok 1/214 key#1(…4d9:fx) kpiTitle: "KPI tiles" → "Tuiles KPI"
ok 2/214 key#2(…f3d:fx) kpiSubtitle: "Eight stat-tile styles…" → "Huit styles…"
...
⤷ saved widgets.json — 214 translated, 0 cached, 0 removed
If a run is interrupted (Ctrl-C, crash, or kill), just run the same command again — it resumes
from the last saved point and re-translates only what wasn't finished.
CLI options
Flags are passed through npm with --. No flags means every language, every namespace (the original
behavior). Values may be comma- or space-separated; both --lang fr and --lang=fr work; unknown values
are warned and skipped.
| Flag | Alias | Effect |
|---|---|---|
--lang <codes> | --langs, -l | Limit to these target languages (fr,de,ja). Must be in TARGET_LANGUAGES. |
--ns <names> | --only, -n | Limit to these namespaces / "products" (dashboard,demo). Must be an English file name. |
npm run i18n:translate -- --lang fr # only French, all namespaces
npm run i18n:translate -- --lang fr,de,es # three languages
npm run i18n:translate -- --ns dashboard,demo # all languages, only those files
npm run i18n:translate -- --lang ja --ns forms # Japanese, only the forms namespaceSplitting a full run into one language at a time turns ~526k characters into seven ~75k runs — smaller, faster, and each independently resumable. Every scoped run still uses the shared hash cache, so work is never duplicated.
Configuration & customization
Adding / removing a target language
- Add the entry to
TARGET_LANGUAGES(andTARGET_DIRS) intranslate.mjs— key = folder name, value = the DeepL API language code (see the DeepL docs for valid codes, e.g.PT-BR,EN-GB). - Register it in the app too — see Internationalization → Adding a language.
- Run the tool; the new folder is created automatically.
Tuning rotation / retries
Constructor options live in the KeyPool
(new deepl.Translator(key, {maxRetries: 5, minTimeout: 1000})) — DeepL's SDK retries transient 429s
internally before the tool rotates keys. The all-keys-rate-limited backoff runs up to maxRounds
(default 4) passes with linear backoff. These are sensible defaults; change them only if you hit
persistent throttling.
Resetting the cache
Delete tools/translate/.translation-cache.json to force a full re-translation on the next run (e.g.
after changing how source strings are pre-processed). The target files are overwritten as they're
regenerated.
How it works
Key rotation
Requests round-robin across the configured keys. On a per-request error:
QuotaExceededError/AuthorizationError→ that key is retired for the run; the request immediately falls through to the next key.TooManyRequestsError(a 429 that survived the SDK's own retries) → rotate to the next key. If an entire pass over the active keys is rate-limited, the tool backs off and retries the pass (up tomaxRounds).- Every key retired → the string is left untranslated (runtime falls back to English) and retried on the next run.
At startup the tool probes each key with getUsage(), logs remaining quota, and pre-retires any key
already at its limit. At the end it prints a per-key character tally + remaining quota.
Incremental cache & resume
Each English value is hashed with MD5 and stored per language as metadata[namespace][language][key].
A string is skipped (no API call, no billing) only when the target already has a good translation and
the cached hash matches and a cache entry exists. The cache is persisted:
- after each completed
(namespace × language)file write, and - on
SIGINT/SIGTERM(Ctrl-C / kill) via a handler that flushes and prints a resume message, and - once more at the very end.
So an interrupted run keeps everything finished so far; re-running skips it and continues.
Per-language hashing matters
An earlier version stored one hash per key shared across all languages, so a changed English string re-translated only the first language and left the rest stale. Hashing per language fixes that — editing English re-translates that key for all languages on the next run.
Placeholder protection
Before a string is sent to DeepL, every i18next placeholder ({{name}}, {{count}}, {{- html}}, …) is
masked to a neutral sentinel (%%0%%, %%1%%, …) that DeepL leaves untouched; the sentinels are
restored to the exact original tokens in the result. This prevents DeepL from translating the variable
name (e.g. {{year}} → {{année}}), which would break interpolation because i18next substitutes by
exact name at runtime. Restore matches by index, so DeepL reordering placeholders is harmless.
Pruning & manifest
Keys removed from an English file are pruned from each target file. manifest.json is copied verbatim
into each active target folder (it is never translated).
Examples
Language-by-language rollout
npm run i18n:usage # confirm quota first
npm run i18n:translate -- --lang fr
npm run i18n:translate -- --lang de
npm run i18n:translate -- --lang nl
npm run i18n:translate -- --lang es
npm run i18n:translate -- --lang pt
npm run i18n:translate -- --lang zh
npm run i18n:translate -- --lang jaRe-translate a single changed namespace, all languages
# after editing src/locales/en/dashboard.json
npm run i18n:translate -- --ns dashboardOnly the keys whose English text changed are re-sent (hash cache skips the rest).
One targeted cell
npm run i18n:translate -- --lang ja --ns formsBest practices
- Edit English first, then regenerate. English is the source; other languages are generated or fall back to it.
- Check quota before a big run with
npm run i18n:usage, especially on Free keys (500k/month each). - Prefer many small keys over one. Rotation spreads load and multiplies your monthly budget; three Free keys ≈ 1.5M characters/month.
- Go language-by-language for large jobs (
--lang) — smaller runs are easier to monitor and each is resumable. - Never hand-edit generated locale files you plan to regenerate — a later run overwrites
changed-source keys. To keep a hand-verified language (e.g.
ja), back up its folder or temporarily remove it fromTARGET_LANGUAGES. - Keep placeholders intact in English source —
{{brand}},{{count}}, same braces, same name. - Keep
NAMESPACESandmanifest.jsonaligned when adding a namespace (see the i18n guide). .envis git-ignored — never commit keys.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| "Missing DEEPL_API_KEY" and it exits | No keys in .env | Add DEEPL_API_KEY or DEEPL_API_KEY_1..N; the script runs via node --env-file=.env. |
| The run seems frozen / no output | Watching an old build, or output buffered | Current builds stream a live line per translation; run in a terminal (TTY) or pipe to tee. |
| Interrupted run re-does everything on re-run | Cache was never written (older tool) or was deleted | Current tool saves incrementally + on Ctrl-C; ensure .translation-cache.json exists. |
A {{variable}} shows literally in the UI in some language | Placeholder was translated before protection existed | Delete .translation-cache.json and re-run so those keys regenerate with masking. |
| One key stops working mid-run | Quota reached or key invalid | The tool retires it and continues on the others; npm run i18n:usage shows which. |
| Everything is rate-limited (429) | Too few keys for the volume | Add more keys; the tool spreads load and backs off, but more keys = less throttling. |
| A language stays English | That language/namespace not generated yet | Run the tool (optionally scoped with --lang / --ns); confirm the folder + file exist. |
| A hand-verified language got overwritten | It's in TARGET_LANGUAGES and the source changed | Back up its folder or remove it from the tool's targets before running. |
FAQ
Does checking usage cost characters? No — getUsage() (npm run i18n:usage) is free; only
translateText bills.
Are keys billed once per language? Yes — each source string is sent once per target language, so a full run ≈ source size × number of languages (~526k for all 7).
Can I run just one language? Yes — npm run i18n:translate -- --lang fr. Same for one namespace with
--ns.
What happens on Ctrl-C? The tool flushes the cache and prints a resume message; re-running continues from the last saved point.
Will editing an English string re-translate it everywhere? Yes — hashes are per language, so a changed source key is re-translated for all target languages on the next run.
Free vs Pro keys? Auto-detected from the :fx suffix by the DeepL SDK — no code change needed. Free
keys are 500k/month each.
Notes for designers & content editors
- All copy lives in JSON. Edit
src/locales/en/<namespace>.json, then regenerate other languages. No code changes needed for English copy. - Keep placeholders intact —
{{year}},{{brand}}, etc. The tool now protects them automatically during translation, but they must be correct in the English source. - English is the master. Editing a non-English file directly works, but a later tool run overwrites keys whose English source changed.
- Don't translate the brand or proper nouns — leave them literal or interpolate them.
Related
Was this page helpful?
