All lessons

Lesson 18·Unit 6 · Shipping translations·Externalization, file formats & TM·Lv 101·~9 min

The translation pipeline: strings, files & memory

A string in your repo becomes a record a translator can work on: a key, a source message, its placeholders, and a context note. This demo follows that record through the three interchange formats (gettext PO, XLIFF 2.0, ICU JSON) and into the translation memory that decides what each sentence costs.

XLIFFgettext POtranslation memorytermbase

By the end

  • ·Turn a hardcoded string into something a translator can work on: key, ICU source, placeholders, context.
  • ·Read a plural entry in a gettext PO file and say what the Plural-Forms header is deciding.
  • ·Find where plurals, placeholders, and context live in gettext PO, XLIFF 2.0, and ICU JSON.
  • ·Read a translation-memory match score and know what exact, fuzzy, and new each cost you.

The problem

Translation feels like a black box because most engineers only see its two ends: strings leave, translated strings come back. The middle is a supply chain with its own data formats, and the code side sets its ceiling. A sentence assembled by concatenation never reaches a translator as a sentence; a string shipped without context gets translated by guesswork (Archive: the noun or the verb?); a source catalog that rewords the same message five ways pays for five translations per language.

The costs compound quietly. Every avoidable fuzzy match is billed per word, per target language, per release. Every context-free string becomes a translator query, a screenshot request, or a wrong guess found in QA. The fixes are all upstream, in code review, which is why this pipeline is worth one lesson of an engineer's time.

How it works

The string leaves your repo

Translation starts with a handoff, and the handoff has a shape. What travels is a record per message, never your code: a stable key (cart.summary), the source string with its variable parts as placeholders, and a context note saying where the string appears and what the ambiguous words mean. Everything downstream (file export, translation memory, review, import) operates on that record. A string that never becomes a record, because code assembles it from fragments at runtime, is invisible to the whole pipeline.

That is why concatenation is the core failure here, not a style nit. "You have " + count + " item" + (count === 1 ? "" : "s") contains no translatable sentence: an extraction tool finds four shards, the plural logic is English-only, and the fragment order is frozen into code that German or Japanese would need to rearrange. The externalized version moves all of that into the string (You have {count, plural, one {# item} other {# items}} in your cart.), where a translator can rewrite grammar and order without touching your program.

Context travels too, because the translator sees your strings without your screen. Archive alone could be a noun (a folder) or a verb (a button). The note "button label, verb" is the difference between a correct translation and a QA bug three weeks later. Every string change after handoff re-opens files in every target language, so teams declare a string freeze before a release goes out for translation. Teams batch late wording edits instead of trickling them.

key:          cart.summary
source:       You have {count, plural, one {# item} other {# items}} in your cart.
placeholders: count (number, drives the plural)
context:      Cart badge on the checkout header. "item" = a product,
              not a menu item. Screenshot: checkout-header.png
The unit of work: what the translator receives for one externalized message.

How it works

Files and memory

Three formats carry that record, one per era of the problem. gettext PO is the Unix veteran: a bilingual text file of msgid (source) and msgstr (translation), with context in #. extracted comments and #: source references. Plurals get msgid_plural, indexed msgstr[0], msgstr[1], and so on, plus a Plural-Forms: header that declares how many forms the language has and which index a count maps to. German declares nplurals=2; plural=n != 1, so 1 fills msgstr[0] and everything else msgstr[1]. A Russian PO file declares more forms and more indexes. The grammar knowledge lives in the file header, outside your code.

XLIFF 2.0, an OASIS standard, is the tool-interchange format: xliff → file → unit → segment → source/target, built so any translation tool can open any exporter's strings. Context gets a first-class element (<notes><note>), and inline codes sit inside <ph> placeholders so a translator cannot edit them by accident. What it has no room for is plurals. The 2.0 core defines no plural element, so an ICU plural message either travels whole inside <source> or the tool splits it into one unit per category. ICU JSON is the other shape you will meet, the runtime's own: the message carries its grammar inline ({count, plural, …}), but plain JSON has nowhere to put a comment, so context needs a sidecar convention. That is why so many pipelines export JSON to XLIFF for the translator round-trip, then import the result back.

The translation memory is why any of this bookkeeping pays. A TM stores every segment pair ever translated, and the tool scores each new source segment against it. An identical segment is an exact match: the tool reuses the stored translation, typically billed at a small fraction of the new-word rate. A close one is a fuzzy match: the tool pre-fills the stored translation for editing at a graduated discount (gettext's #, fuzzy flag is the same move in PO form). Below roughly 75, editing a bad suggestion costs more than translating fresh, so it counts as new words. The thresholds are industry convention, and no standard fixes them. Every CAT tool computes its own score. The economics are universal: reworded sentences cost money in every target language, and consistent source English is a budget decision.

"Plural-Forms: nplurals=2; plural=n != 1;\n"

#, c-format
msgid "%d file uploaded"
msgid_plural "%d files uploaded"
msgstr[0] "%d Datei hochgeladen"
msgstr[1] "%d Dateien hochgeladen"
A German plural entry in gettext PO: the header decides what the indexes mean.

See it yourself

Try these, in order

Each step triggers a specific failure you should recognize on sight.

  1. 1
    In "1 · Externalize first", select "Hardcoded" and read the "What the translator sees" pane. Four fragments, none of them a sentence. The plural logic is an English-only ternary and the fragment order is fixed in code: there is nothing a translator can work on.
  2. 2
    Switch to "Externalized". The pane becomes a record: key, source string, placeholder list, context note. This record, not your code, is what every downstream tool operates on.
  3. 3
    In "2 · One message, three file formats", open "gettext PO" and find the Plural-Forms line in the header. nplurals=2; plural=n != 1: for German, count 1 fills msgstr[0] and every other count fills msgstr[1]. The header decides how many indexed translations a language needs, and the file structure stays the same.
  4. 4
    Switch to "XLIFF 2.0" and look for a plural construct. There is none: XLIFF 2.0 core has no plural element. The ICU plural message travels intact inside <source>, and the Plurals callout under the sample says what tools do instead.
  5. 5
    In "3 · Translation memory prices repetition", click "Near repeat". Score 80, fuzzy band: one word changed out of five against "Your file has been uploaded." The stored German translation appears pre-filled for editing: billable, but discounted.
  6. 6
    Type your own sentence into "Candidate string" and watch the Score column in the table. The demo scores every stored segment, and the best match drives the band. Exact 100 reuses, 75–99 edits, and below 75 is new words at full rate. That is why rewording an existing sentence has a price.

1 · Externalize first

A string a translator never receives is a string that never gets translated. Toggle between the two versions and watch the “What the translator sees” pane.

The code
// cart-summary.tsx
const label =
  "You have " + count + " item" +
  (count === 1 ? "" : "s") +
  " in your cart.";

What the translator sees

Nothing usable. The sentence exists only at runtime. An extraction tool finds four fragments: "You have ", " item", "s", " in your cart."

None of the fragments is a sentence, the plural logic is English-only, and the fragment order is fixed in code that German and Japanese would need to rearrange.

The externalized entry (key, source, placeholders, context) is the unit of work. Every downstream step (file export, translation memory, review, import) operates on this record, never on your code.

2 · One message, three file formats

The same two strings (a greeting with a placeholder and a counted message) as they travel to a German translator in each interchange format. Same content, three different answers to where plurals, placeholders, and context live.

de.po · target catalog, German
msgid ""
msgstr ""
"Language: de\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"

#. Greeting on the account page. %s = first name.
#: src/account.c:52
#, c-format
msgid "Hello, %s!"
msgstr "Hallo, %s!"

#. Shown when an upload finishes.
#: src/upload.c:118
#, c-format
msgid "%d file uploaded"
msgid_plural "%d files uploaded"
msgstr[0] "%d Datei hochgeladen"
msgstr[1] "%d Dateien hochgeladen"

Plurals

msgid_plural plus indexed msgstr[0], msgstr[1], … The Plural-Forms header declares how many forms the language has and which index each count maps to. German: nplurals=2; plural=n != 1.

Placeholders

Plain printf directives (%s, %d) inside the string. The #, c-format flag tells tools to check that the translation keeps them intact.

Context

#. extracted comments (written by the developer, exported by xgettext) and #: source references. The #, fuzzy flag marks entries a tool pre-filled from an old, similar msgid.

3 · Translation memory prices repetition

A translation memory stores every segment pair ever translated. The tool scores each new source segment against it. The score decides whether the translator reuses, edits, or starts fresh, and what the word costs. The score below is this tool's similarity score (word-level edit distance, 0–100). Every CAT tool computes its own. The bands are an industry-typical convention. No standard defines them.

80Fuzzy match · 75–99best match: Your file has been uploaded.

Suggested translation Ihre Datei wurde hochgeladen.(pre-filled for editing: check what changed)

The tool pre-fills the stored translation for the translator to edit, the same move as gettext's #, fuzzy flag. Vendors typically bill it at a graduated discount that shrinks as the score drops.

Stored source (en)Stored target (de)Score
Your file has been uploaded.Ihre Datei wurde hochgeladen.80
Your file could not be uploaded.Ihre Datei konnte nicht hochgeladen werden.33
Delete this file?Diese Datei löschen?0
Your password has been changed.Ihr Passwort wurde geändert.60
Payment failed. Please try again.Zahlung fehlgeschlagen. Bitte versuchen Sie es erneut.0
Settings saved.Einstellungen gespeichert.0

This is why consistent source English is a budget decision. Every needless rewording (“Your file was uploaded” vs “Your file has been uploaded.”) turns a free exact match into billable fuzzy work, multiplied by every target language.

If you remember one thing

A translator can only work on what leaves your repo: one externalized message per sentence, with placeholders and a context note. Keep source wording consistent: repetition is the only discount the pipeline gives you.

What to do about it

  • Externalize every user-facing string into a catalog with a stable key. No concatenation, no fragments: one message per sentence, placeholders for the variable parts, ICU plural/select for the grammar.
  • Ship context with the string: a description of where it appears and what the ambiguous words mean, plus a screenshot reference. In PO that is the #. extracted comment; in XLIFF, the <note> element; in plain JSON you need a sidecar convention, so decide one early.
  • Let the interchange format carry the work. PO and XLIFF exist so that any translation tool can open your strings, protect the placeholders, and return the same structure. Do not invent a CSV.
  • Treat source wording as a cost surface. Reuse exact sentences where the meaning is identical; every needless rewording downgrades a free 100% match to billable fuzzy work in every target language.
  • Declare a string freeze before a release goes to translation, and batch late changes; each post-freeze edit re-opens files across all languages.

Use this with

Stakeholders

EngineeringLocalization vendorsProduct

Moments

  • ·Code review of user-facing strings
  • ·Choosing catalog and interchange formats for a new project
  • ·Reading a translation vendor quote
  • ·Planning a release's string freeze

Field note

The GNU gettext manual dedicates a section to this failure. Its own example builds "Replace X with Y?" out of four strcpy/strcat fragments, then requires the rewrite to a single format string. The reasons: the translator receives an entire sentence, and in some languages "the translator might want to swap the order" of the two objects. Fragment-glued code makes that reordering impossible. The fix has to happen in the source, before extraction.

GNU gettext manual: No string concatenation

Quick check

3 questions · pass at 2+

  1. Question 1/3

    A PO file translates "%d files uploaded" into German. Where does the plural translation live?

  2. Question 2/3

    A segment scores a 100% match against the translation memory. What does that tell you?

  3. Question 3/3

    Where does translator context (what the string means, where it appears) live in XLIFF 2.0, and in a plain ICU JSON catalog?

Words you'll hear

Where to read more

Related lessons