Machine-Translation Pre-fill Workflows
Machine-translation pre-fill workflows pipe newly extracted keys through DeepL, Google, or Azure before a human ever sees them, writing each result back as needs-review (never translated) so untrusted MT output can never silently ship to production. The classic failure mode is a pre-filled {count, plural, ...} block where the engine “translated” the ICU keyword one into the target language, and the build crashes at runtime with Expected plural argument selector, got "uno". This page shows how to wire MT pre-fill into your sync pipeline as part of Translation Workflows & CI/CD Pipeline Sync while protecting placeholders, enforcing glossaries, and gating every machine string behind human post-edit.
Prerequisites
Concept & spec — why MT output is “fuzzy”, not “translated”
Machine translation produces a candidate, not a commitment. Every serious localization format encodes this distinction with an explicit per-string state, and your pre-fill job must set it. In gettext PO, the state is the #, fuzzy flag — gettext runtime treats a fuzzy entry as untranslated and falls back to the source, exactly the behaviour you want for unreviewed MT. In XLIFF 2.1 (OASIS XLIFF Version 2.1, §segment state) the corresponding values are initial, translated, reviewed, and final; MT pre-fill writes translated only inside a <target> whose segment state stays initial or carries an mtype="mt" marker, never final.
#: src/checkout.tsx:42
#, fuzzy
msgid "Your cart has {count} items"
msgstr "Votre panier contient {count} articles"
The fuzzy flag is the contract between the MT engine and the human reviewer: it says “a machine wrote this, do not trust it.” This mirrors how the fallback chain treats a missing string — an unreviewed fuzzy entry should resolve to the source locale at runtime, not display raw MT to users. Pre-fill belongs to the broader Translation Workflows & CI/CD Pipeline Sync discipline: it sits between extraction and human review, never replacing the latter.
Step-by-step implementation
1. Detect only the new keys
Run your extractor and diff against the last committed source file so you translate the delta, not the whole catalog — this is the single biggest cost lever. Emit a JSON array of {key, source} for keys present in the new source but absent from the target locale.
# emit new English keys missing from the French target
i18next-parser --config i18next-parser.config.js
node scripts/diff-new-keys.mjs locales/en.json locales/fr.json > /tmp/new-keys.json
Skip any key that already has a non-fuzzy target value. Re-translating reviewed strings burns characters and silently overwrites human work.
2. Mask placeholders and ICU structure before sending
Never hand raw ICU to an MT engine. Replace every placeholder and every ICU control token with an opaque sentinel the engine will pass through untouched, then restore after. DeepL also accepts inline <x> tags you mark as tag_handling=xml.
one, other, #); send only the human-readable sub-messages. Restoration then yields parseable ICU instead of the build-breaking got "un".// protect.ts — mask {vars}, ICU keywords, and HTML before MT
const ICU_KEYWORDS = /\b(plural|select|selectordinal|one|other|few|many|two|zero|#)\b/g;
const PLACEHOLDER = /\{[^{}]+\}|%[sd]|<[^>]+>/g;
export function protect(src: string) {
const slots: string[] = [];
const masked = src
.replace(PLACEHOLDER, (m) => `${slots.push(m) - 1}`); // FSI/PDI wrap
// ICU keywords are kept verbatim by only translating the human-text segments
return { masked, slots };
}
export const restore = (s: string, slots: string[]) =>
s.replace(/(\d+)/g, (_, i) => slots[+i]);
For ICU plural/select messages, translate only the human-readable sub-messages, leaving keywords (one, other, =0) and the {count, plural, skeleton fixed. The dedicated DeepL pre-translation quality gate covers validating that restoration produced parseable ICU before write-back.
3. Call the engine with the glossary attached
Send masked text in batches. Pass the engine’s native glossary so brand and domain terms resolve deterministically instead of being paraphrased.
import * as deepl from "deepl-node";
const t = new deepl.Translator(process.env.DEEPL_AUTH_KEY!);
const glossary = await t.createGlossary(
"app-fr", "en", "fr",
new deepl.GlossaryEntries({ entries: { "Workspace": "Espace de travail", "seat": "licence" } })
);
const out = await t.translateText(maskedBatch, "en", "fr", {
glossaryId: glossary.glossaryId,
tagHandling: "xml",
formality: "prefer_more",
});
Glossary entries must stay in sync with your CI-enforced terms; see enforcing glossary terms in CI.
4. Write back as needs-review and never as final
Restore placeholders, parse the result as ICU to confirm structure survived, then write the target value with fuzzy/needs-review set. If the ICU parse throws, drop the candidate and leave the key untranslated rather than committing a build-breaking string.
import { parse } from "@formatjs/icu-messageformat-parser";
for (const { key, masked } of batch) {
const candidate = restore(out[key], slots[key]);
try {
parse(candidate); // throws on broken ICU -> skip
target[key] = candidate;
meta[key] = { state: "needs-review", origin: "mt", engine: "deepl" };
} catch {
meta[key] = { state: "untranslated", origin: "mt-rejected" };
}
}
5. Gate every machine string behind a human
Open the pre-filled values as a pull request, but make the merge gate require that no state: needs-review (or #, fuzzy) entry remains for shipping locales. Reviewers post-edit in your TMS — pushing this PR into Crowdin or Weblate surfaces each fuzzy string for approval before it flips to translated.
# CI gate: fail if any unreviewed MT string targets a shipping locale
node scripts/assert-no-fuzzy.mjs locales/fr.json --max-fuzzy 0
Configuration reference
| Option | Type | Description / default |
|---|---|---|
engine |
"deepl" | "google" | "azure" |
MT provider. Default deepl (best formality + glossary control for EU langs). |
prefillState |
string |
Write-back state for new MT strings. Default needs-review (PO fuzzy, XLIFF initial). |
protectPlaceholders |
boolean |
Mask {vars}, %s, ICU keywords, HTML before send. Default true. Never disable. |
glossaryId |
string |
Engine glossary applied per language pair. No default — unset means no term enforcement. |
skipReviewed |
boolean |
Skip keys with a non-fuzzy target. Default true (protects human work + cuts cost). |
maxCharsPerRun |
number |
Hard character budget per CI run; abort over it. Default 50000. |
formality |
"prefer_more" | "prefer_less" | "default" |
DeepL/Azure tone hint. Default default. |
failOnIcuError |
boolean |
Drop candidates that fail ICU parse after restore. Default true. |
Framework variants
React / Next.js (formatjs): run formatjs extract to produce the source catalog, then feed the extracted defaultMessage strings into the pre-fill script. Keep ICU intact by translating only message text; write pre-filled values into lang/fr.json with a parallel lang/fr.meta.json carrying needs-review flags so formatjs compile can exclude unreviewed entries from the production bundle.
Vue / Nuxt (vue-i18n): vue-i18n has no native fuzzy concept, so store review state in a sidecar and have your build filter it. Pre-fill into locales/fr.json, keep locales/fr.review.json, and let the runtime fall back to the base locale for any key still flagged — aligned with vue-i18n’s fallbackLocale.
Angular (@angular/localize): Angular uses XLIFF natively, so set state="initial" and mtype on the <target>. extract-i18n regenerates messages.fr.xlf; your pre-fill step fills empty <target> nodes only and leaves state="final" segments untouched.
Node.js backend (i18next): for server strings, pre-fill locales/fr/translation.json plus a _fuzzy namespace. Configure i18next saveMissing: false in production and returnNull: false so unreviewed keys resolve through the fallback chain instead of leaking MT to API consumers.
Verification
Assert that no unreviewed machine string can reach a shipping locale and that every pre-filled string still parses as ICU.
# 1. structural check: every target value is valid ICU
node scripts/validate-icu.mjs locales/fr.json
# expected: "✓ 412 keys parsed, 0 ICU errors"
# 2. review-state gate: zero fuzzy entries for shipping locales
node scripts/assert-no-fuzzy.mjs locales/fr.json --max-fuzzy 0
# expected exit 0; non-zero with "12 needs-review strings remain" blocks merge
# .github/workflows/i18n.yml — gate fragment
- name: Block unreviewed MT
run: |
node scripts/validate-icu.mjs locales/*.json
node scripts/assert-no-fuzzy.mjs locales/fr.json --max-fuzzy 0
This gate composes with broader GitHub Actions i18n CI gates that also fail builds on untranslated keys.
Controlling cost and choosing what to pre-fill
Machine translation is priced per character, which makes an unconstrained pre-fill job a bill that scales with your catalogue rather than with your changes. The first control is therefore scope: pre-fill only units that are genuinely new or genuinely changed, which means diffing against the last successful run rather than resending the catalogue.
The second control is the translation memory. Any unit with a high fuzzy match already has a better starting point than a machine can produce, because that starting point was written by a human for this product. Query the memory first, accept matches above your leverage threshold, and send only what remains to the machine. On a mature catalogue this routinely removes most of the volume, and the units it removes are the ones a machine would have translated worst — product-specific phrasing the model has never seen.
The third control is selectivity by surface. Not every string benefits equally from a draft. Long-form marketing copy and legal text are poor candidates: the machine output needs so much post-editing that it can be slower than translating from scratch, and it anchors the translator to a phrasing they would not have chosen. Short, factual, high-volume strings — labels, status messages, table headers — are excellent candidates, because the machine gets them substantially right and the review is quick. Encoding that distinction as a per-namespace policy is more effective than any per-character optimisation.
Formality, glossaries, and the provider knobs that matter
Most providers expose controls that materially change output quality, and leaving them at defaults is the most common reason a pre-fill pipeline produces drafts nobody wants to edit.
Formality is the first. German, Japanese, Korean, Spanish and several other languages encode a register distinction that English does not, and a provider left to guess will pick one — usually inconsistently across a catalogue. Setting formality explicitly, per target language, according to your product’s voice, removes an entire class of review comments. The setting belongs in configuration next to the locale list, not in whichever script happens to call the API.
Glossaries are the second. Every provider worth using accepts a term list that forces particular source terms to particular target terms. This is the same data as your termbase, and keeping the two in sync — generating the provider glossary from the termbase as a build step — means a bound term is enforced during translation rather than caught afterwards by a checker. Enforcement is strictly better than detection: it produces a correct draft instead of a rejected one.
Tag handling is the third. Providers differ in how they treat markup and placeholders, and the safe default is to assume nothing survives. Masking placeholders before the call and restoring them afterwards is more work than enabling a provider’s tag-handling mode, but it is the only approach that behaves identically across providers — which matters the day you switch one.
Common pitfalls
- MT translates ICU keywords. Sending
{count, plural, one {...} other {...}}raw lets the engine “translate”one/other, breaking the parser. Mask the skeleton; validate with the DeepL pre-translation quality gate. - Placeholders get reordered or dropped.
Hello {name}becomesBonjourwith the variable gone. Always mask-and-restore, then assert every original slot reappears exactly once. - Pre-fill overwrites approved human translations. Skip any key whose target is already non-fuzzy — re-translation silently destroys reviewed work.
- Fuzzy strings ship. Without a merge gate,
needs-reviewMT reaches users. Enforce--max-fuzzy 0on shipping locales. - Unbounded cost. Translating the full catalog every run, not the delta, multiplies the bill. Diff for new keys and cap
maxCharsPerRun. - Glossary drift. An engine glossary that diverges from your TM produces inconsistent terms; keep both fed from one source via translation memory & glossary management.
FAQ
Should machine-translated strings ever be marked “translated”?
No. MT output is a candidate, not a commitment. Always write it as fuzzy (gettext) or with a non-final segment state (XLIFF), so the runtime falls back to the source locale and the string stays visible to a human reviewer until they post-edit and approve it.
How do I stop the MT engine from mangling ICU and placeholders?
Mask everything that isn’t human-readable text — {vars}, %s, HTML tags, and ICU control tokens (plural, select, one, other, #) — with opaque sentinels before sending, then restore afterward and re-parse the result as ICU. Drop any candidate that fails to parse rather than committing it.
How do I keep machine-translation costs under control?
Translate only newly extracted keys, not the full catalog; skip keys that already have a reviewed target; cache results so identical strings aren’t re-sent; and set a hard per-run character budget that aborts the job when exceeded. Diffing the delta typically cuts character volume by an order of magnitude.
Does applying a glossary remove the need for human review?
No. A glossary enforces term consistency but does not guarantee correct grammar, tone, or meaning. Glossary application and human post-edit gating are complementary: the glossary reduces obvious term errors, the human gate catches everything else before a string ships.
Which engine should I pick — DeepL, Google, or Azure?
DeepL generally gives the best formality control and glossary fidelity for major European languages; Google Cloud Translation has the broadest language coverage; Azure Translator integrates cleanly with custom Translator glossaries. Choose per language pair, and keep the write-back contract identical across engines.
Related
- MT glossary not applied to ICU branches — why terminology holds in one plural branch and not the next.
- DeepL pre-translation quality gate — validating restored ICU and placeholders before pre-filled strings are written back.
- Translation Memory & Glossary Management — the shared term source that feeds both your TM and the MT engine glossary.
- Crowdin Integration for Dev Teams — surfacing pre-filled fuzzy strings for human post-edit inside a TMS.
- Weblate Self-Hosted Setup — self-hosted review of machine-suggested translations with built-in MT add-ons.
- GitHub Actions i18n CI Gates — the CI layer that blocks merges while unreviewed MT strings remain.