Translating Lingui catalogs with an LLM
How to machine-translate Lingui .po catalogs with an LLM, what breaks (placeholders, plurals, tone) and the checks to run before a catalog ships.
An LLM can draft every missing string in a Lingui .po catalog, but the result is a draft. Before it ships, run mechanical checks (every message filled, placeholders and tags identical, every plural form the language needs) and have someone who reads the language review it. I built a small CLI, lingui-translate-ai, for the drafting; this post covers the whole loop.
TL;DR
- Drafting is the easy part: lingui-translate-ai sends empty
msgstrvalues to an LLM, previews the run, keeps.bakcopies and tries to repair dropped placeholders.- Breakage is silent: a wrong placeholder or a missing Polish plural form renders wrong text and fails nothing.
- Checks first, then a human: tests for tokens and plural forms, a reader for every language you can review, and an honest label on the rest.
Why machine-translate catalogs at all?
Linkoglot, my own language-learning app, ships in 30 languages, so every new screen means new strings in 30 catalogs. An LLM script in its repo did the drafting, and that script grew into lingui-translate-ai (opens in a new tab), an MIT-licensed CLI on npm (version 1.1.0 at the time of writing). The app itself is in how I shipped Linkoglot alone.
This site is smaller: three catalogs, English as the source.
Drafting those strings is the easy part. The hard part is knowing which drafts are broken.
What does a catalog look like to the model?
A Lingui catalog is a gettext PO file. Each entry has the source text in msgid, the translation in msgstr and a reference to where the message is used. This one is from this site's Polish catalog:
#: src/components/layout/SiteHeader.astro:52
msgid "{name}, home"
msgstr "{name}, strona główna"
The model sees short strings, mostly without context, full of tokens it must not touch. That is where things go wrong.
What goes wrong?
Placeholders and tags
{name} must come back exactly as {name}, and so must the numbered tags Lingui generates for rich text inside Trans, such as <0> and </0>.
A broken placeholder crashes nothing. With @lingui/core 6.9.0, a Polish string that says {imie} instead of {name} renders as ", strona główna", and a dropped placeholder just loses the value. Users see the gap; the build doesn't.
Plurals
In the Unicode CLDR rules English has two plural categories, one and other. Polish has four: one (1), few (2-4, 22-24), many (0, 5-19, 100) and other (fractions such as 1.5). A model that mirrors the English message returns two:
msgid "{count, plural, one {# file} other {# files}}"
msgstr "{count, plural, one {# plik} other {# plików}}"
Nothing fails here either. Missing categories fall back to other, so with @lingui/core 6.9.0 this renders "2 plików" and "1,5 plików", where Polish needs "2 pliki" and "1,5 pliku". Lingui's plural guide is clear that the target language's forms are the translator's job. When the translator is a model, check that job.
Tone and context
UI strings are short and ambiguous: "Open" is a verb or an adjective, "Save" a button or a discount. Lingui macros accept a comment and a context, and both are extracted into the catalog. Pass them to the model; they are often the only context it gets.
The ICU user guide adds one more rule: write full sentences inside plural and select branches instead of gluing fragments, because word order and agreement change between languages.
Copied English and drifting terms
Models sometimes return the English source unchanged, or translate one product term three ways across a catalog. Both hide well in a long diff.
How does lingui-translate-ai handle the draft?
You run it from a Lingui project root. It looks for package.json, a Lingui dependency or a lingui.config.* file, and locale folders with PO files, and exits if the folder doesn't look like a Lingui project.
npx lingui-translate-ai init # creates lingui-translate-ai.json
npx lingui-translate-ai translate --only-empty --dry-run
npx lingui-translate-ai translate --only-empty
- 01 Find the gaps Empty msgstr values in every locale, or all of them in repair mode.
- 02 Preview Model, locales, strings and requests, then a yes or no prompt.
- 03 Draft Batches go to the model through OpenRouter, inside your caps.
- 04 Check tokens Placeholders and tags must survive; a plural must stay valid ICU.
- 05 Patch the file A .bak copy first, then only the changed msgstr blocks.
What the diagram leaves out:
- The key:
OPENROUTER_API_KEY, from the shell or a.envfile. - Repair mode: with
onlyEmptyTranslations: falseit also sends existing translations, so the model can fix copied English, inconsistent terms, placeholder mistakes and overly literal UI text. - Caps:
maxTranslationsPerRequest,maxRequestsPerRunandmaxTranslationsPerRunlimit the size of a run. - Repairs: if the model drops a placeholder, the CLI tries to put it back; a fix it can't repair safely is ignored and logged.
Plurals got their own handling in 1.1.0. The prompt tells the model to return every CLDR category the target language needs. The CLI then rejects a plural that comes back broken: a different argument or keyword, unbalanced braces, no other branch or a lost #. It does not check that few and many are actually there. That check is yours.
Which checks should run before a catalog ships?
On this site a small Vitest file, src/locales/catalogs.test.ts, reads each .po file and fails when:
- the Polish or Spanish catalog lacks a message from the English source;
- any
msgstris empty; - the
{placeholders}or numbered tags differ from the source; - a translation contains a raw no-break space or an em or en dash (a house style rule).
The placeholder check is one regular expression:
const tokens = (text: string) =>
[...text.matchAll(/\{\w+\}|<\/?\d+>/g)].map((m) => m[0]).sort();
for (const entry of list) expect(tokens(entry.str), entry.id).toEqual(tokens(entry.id));
The production build is the second net: failOnMissing: "catalog" in the Lingui Vite plugin fails it when a Polish or Spanish message is missing. How Lingui runs inside a static Astro build shows that setup.
This site has no plural messages right now, so its test doesn't look at categories. If your catalogs have plurals, add the check. Intl.PluralRules already knows which categories each locale needs:
const CATEGORY = /\b(zero|one|two|few|many|other)\s*\{/g;
export function missingPlurals(locale: string, msgstr: string) {
if (!/,\s*plural\s*,/.test(msgstr)) return [];
const needed = new Intl.PluralRules(locale).resolvedOptions().pluralCategories;
const found = new Set([...msgstr.matchAll(CATEGORY)].map((m) => m[1]));
return needed.filter((category) => !found.has(category));
}
missingPlurals("pl", "{count, plural, one {# plik} other {# plików}}");
// ["few", "many"]
It is deliberately naive: it reads flat messages, not plurals nested in selects. It also flags Spanish, where CLDR defines a many category for numbers like 1,000,000, so decide per language whether falling back to other is fine. How the two big libraries treat plurals is in Lingui vs i18next.
What does one round look like?
- Run
lingui extractafter the code changes, so new messages appear as empty entries. - Run
translate --only-empty --dry-runand read the preview: locales, strings, requests. - Run it for real. The
.bakfiles are your undo button. - Run the catalog tests and a production build. Missing messages, changed placeholders and (with the check above) incomplete plurals stop here.
- Read the diff for every language you can read, and label the rest as unreviewed.
- Commit the catalogs and delete the
.bakfiles.
Steps 4 and 5 make the output trustworthy. Skip them and a broken {name} reaches users.
Why does a human still read it?
Mechanical checks catch broken tokens and missing forms. They don't catch a wrong register, a sentence that is correct but odd, or a word no native speaker would use.
On this site nobody has read the Spanish yet, and the site says so twice. The catalog header carries X-Review-Status: unreviewed (AI translation), guarded by a test. Every Spanish page says it next to the language switch.
Thirty languages, as in Linkoglot, is more than one person can proofread. That is the real limit of this workflow, and the reason to say plainly which languages a human has read.
Limits
- The output is not deterministic. Two runs can differ, so the dry run, the
.bakfiles and a reviewed diff before commit are your controls. - Cost depends on the model and the size of the catalog. Try one locale with
--max-requests 1before a full run. - Context is thin by nature. Comments and contexts in your macros give the model, and any human reviewer, more to work with.
lingui-translate-ai sits on my work page next to Linkoglot, the app it grew out of.
Sources
- lingui-translate-ai on npm (opens in a new tab) (accessed )
- lingui-translate-ai README (GitHub) (opens in a new tab) (accessed )
- lingui-translate-ai v1.1.0 release notes (GitHub) (opens in a new tab) (accessed )
- lingui-translate-ai source: src/commands/translate.ts (GitHub) (opens in a new tab) (accessed )
- Unicode CLDR: Language Plural Rules (opens in a new tab) (accessed )
- MDN: Intl.PluralRules.prototype.resolvedOptions() (opens in a new tab) (accessed )
- Lingui: Pluralization (opens in a new tab) (accessed )
- Lingui: Macros reference (opens in a new tab) (accessed )
- Lingui: ICU MessageFormat (opens in a new tab) (accessed )
- Lingui: Vite plugin (opens in a new tab) (accessed )
- ICU User Guide: Formatting Messages (opens in a new tab) (accessed )