PVR Tech Studio
Translation tool

Translation Tool

The DeepL batch translator that fills every target locale from English — multi-key rotation, resume, placeholder protection, and scoped runs.

9 min read
Updated July 15, 2026

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. --lang and --ns flags let you translate one language or one namespace at a time, turning a large job into small, manageable runs.

Architecture & files

FileResponsibility
tools/translate/translate.mjsThe translator — key pool + rotation, hash cache, incremental save/resume, placeholder masking, live logging, CLI filters.
tools/translate/check-usage.mjsRead-only usage/balance report across all configured keys (npm run i18n:usage).
tools/translate/.translation-cache.jsonHash cache — metadata[namespace][language][key] = md5(englishValue). Auto-generated; safe to delete.
src/locales/en/*.jsonThe source English namespaces (flat "key": "value" JSON).
src/locales/<lng>/*.jsonThe generated target locales (one flat JSON per namespace per language).
.envDEEPL_API_KEY / DEEPL_API_KEY_1..N — read via node --env-file=.env. Git-ignored.
package.jsoni18n: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:fx

Check remaining quota (optional, free)

npm run i18n:usage
DeepL 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.log

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

FlagAliasEffect
--lang <codes>--langs, -lLimit to these target languages (fr,de,ja). Must be in TARGET_LANGUAGES.
--ns <names>--only, -nLimit 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 namespace

Splitting 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

  1. Add the entry to TARGET_LANGUAGES (and TARGET_DIRS) in translate.mjs — key = folder name, value = the DeepL API language code (see the DeepL docs for valid codes, e.g. PT-BR, EN-GB).
  2. Register it in the app too — see Internationalization → Adding a language.
  3. 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 to maxRounds).
  • 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.

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 ja

Re-translate a single changed namespace, all languages

# after editing src/locales/en/dashboard.json
npm run i18n:translate -- --ns dashboard

Only the keys whose English text changed are re-sent (hash cache skips the rest).

One targeted cell

npm run i18n:translate -- --lang ja --ns forms

Best 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 from TARGET_LANGUAGES.
  • Keep placeholders intact in English source — {{brand}}, {{count}}, same braces, same name.
  • Keep NAMESPACES and manifest.json aligned when adding a namespace (see the i18n guide).
  • .env is git-ignored — never commit keys.

Troubleshooting

SymptomLikely causeFix
"Missing DEEPL_API_KEY" and it exitsNo keys in .envAdd DEEPL_API_KEY or DEEPL_API_KEY_1..N; the script runs via node --env-file=.env.
The run seems frozen / no outputWatching an old build, or output bufferedCurrent builds stream a live line per translation; run in a terminal (TTY) or pipe to tee.
Interrupted run re-does everything on re-runCache was never written (older tool) or was deletedCurrent tool saves incrementally + on Ctrl-C; ensure .translation-cache.json exists.
A {{variable}} shows literally in the UI in some languagePlaceholder was translated before protection existedDelete .translation-cache.json and re-run so those keys regenerate with masking.
One key stops working mid-runQuota reached or key invalidThe tool retires it and continues on the others; npm run i18n:usage shows which.
Everything is rate-limited (429)Too few keys for the volumeAdd more keys; the tool spreads load and backs off, but more keys = less throttling.
A language stays EnglishThat language/namespace not generated yetRun the tool (optionally scoped with --lang / --ns); confirm the folder + file exist.
A hand-verified language got overwrittenIt's in TARGET_LANGUAGES and the source changedBack 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.

Was this page helpful?