File formats

The agency wants XLIFF. Your repository should not

Locale files stay JSON where your build needs them; a language leaves as one bilingual document and comes back as the same one. What that document carries, which of it is a claim rather than a fact, and the two sentences of brief that decide whether it imports cleanly.

Published Updated

Why an agency asks for it

Your locale files are JSON because that is what your app loads at runtime. A translation agency does not run your app. It runs a CAT tool — computer-assisted translation — and that tool wants a file that carries the source beside the target, a status per string, a note the translator can read, and somewhere to put the length limit. JSON carries none of that. XLIFF carries all of it, which is the entire reason it exists.

So the two formats are not competing, and the choice between them is not a choice. XLIFF is the transport: one language leaves as a document, comes back as the same document with the target side filled in, and the repository never sees it. The files under version control stay the JSON they were, which is the property that makes a translation a reviewable diff rather than a database export.

The common mistake is the one that follows from getting that backwards: committing the .xlf files, or worse, pointing the runtime at them. Nothing loads XLIFF at runtime — not i18next, not Transloco, not react-intl — and a repository full of bilingual documents has one file per language pair, so eight languages is eight documents that all restate the English.

One document, one language

An XLIFF 2.0 document names its languages once, on the root element, and everything below inherits them. That single detail decides most of what a handoff feels like.

de.xlf
<?xml version="1.0" encoding="UTF-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:2.0"
       version="2.0" srcLang="en" trgLang="de">
  <file id="checkout" original="checkout.json">
    <unit id="checkout.pay.total" name="checkout.pay.total">
      <notes>
        <note>Button under the order summary. Max 24 characters.</note>
      </notes>
      <segment state="translated">
        <source>Pay {total}</source>
        <target>{total} bezahlen</target>
      </segment>
    </unit>
  </file>
</xliff>

Three things are worth reading off that. The languages sit on <xliff>, so a document is one source language and one target language and there is no place to put a second — four languages is four documents, four briefs and four files coming back. The <file> element is the source file the strings came from, which is where a project split across several locale files keeps that split: several <file> elements inside the one document, not several documents. And a <unit> is one string, addressed by name and id, with the note and the status attached to it rather than to a parallel structure somewhere else in the file.

A unit may hold more than one <segment>, and this is the part that surprises people. Tools that segment by sentence split a paragraph into a segment each, so a document that left with one segment per unit can come back with three — and if the three disagree about their status, the honest reading of the unit is the least confident of them. A paragraph whose middle sentence was machine-translated is not a reviewed paragraph.

The three states

2.0 has three core states, down from ten in 1.2. They are attached to the segment, and they are the only part of the document that is a claim rather than a fact:

stateWhat the file is claiming
initialNobody has worked on this segment. It is where every unit starts.
translatedA translation exists. Nobody has confirmed it, and nothing says who wrote it.
finalSomebody signed this off. It is the strongest thing the format can say.

Three is fewer than most workflows have, so the spec adds subState for the refinement: a vendor-prefixed string qualifying the state it sits on. acme:machine-translated and acme:signed-off-by-pm are both translated with a qualifier, and that is the whole design.

Which gives you the one rule worth taking from this page. A status you cannot interpret has to be read downwards, never upwards. Those two example qualifiers are the same opaque string to anything that does not know the vendor, and only one of them is a translation somebody stands behind. Treating an unknown qualifier as plain translated promotes machine output into work a reviewer has walked past; treating it as unconfirmed costs one bulk review. The asymmetry is not close, and it is the reason a file coming back from a tool you have never seen before should land as something a person still has to confirm.

The same asymmetry runs the other way when you write a document. A segment with no translation yet is initial with no <target> at all, rather than an empty target — an empty string is a translation somebody made, and a missing one is not.

What nobody may translate

XLIFF has an inline vocabulary for the parts of a string that are not words: <ph> for a standalone code, <pc> for a paired one, <sc> and <ec> for the halves of a pair that spans a segment boundary. A CAT tool renders those as tags the translator can move but not edit, which is exactly what you want for markup and for a placeholder.

It has nothing for ICU. A plural message is one string as far as the format is concerned, so it travels as text:

in the target segment
<target>{count, plural,
  one {# Artikel}
  other {# Artikel}
}</target>

The alternative — splitting the message into a segment per plural category — is lossy in both directions, because the categories English has are not the categories Polish has, and reassembling a message from them is guesswork. Verbatim is the only arrangement where the string that comes back is the string you can use, so verbatim is what every sane exporter does.

The price lands on the translator, and it is worth paying out loud rather than discovering: they see {count, plural, one {# item} other {# items}} as literal text in a field, with no tag protecting it, and they have to leave the syntax standing while translating the words inside it. Put that in the brief. It is two sentences, and it is the difference between a file that imports cleanly and one where a helpful linguist has translated the word other.

The same brief should say what translate="no" means where you have used it: the value is not to be translated, which is not the same as the key being absent. Product names, units and format strings are the usual carriers. Checking that the placeholders came back is the backstop for when the brief is not read, and it should not be the first line of defence.

1.2 and 2.0

They share a name and very little else, and the version you have is usually not the one you chose. 1.2 is still the more common file in the wild: ng extract-i18n writes it unless asked for --format=xlf2, and several translation platforms export it by default.

What changed between them is not cosmetic. <trans-unit> became <unit> with segments inside it. The languages moved from the <file> element to the root, which is what makes a 2.0 document one language pair. The ten states collapsed into the three above, and the approved attribute that carried sign-off in 1.2 is gone, replaced by final. Length limits moved out into a separate module with its own namespace.

So a reader for one is not most of a reader for the other, and a tool that accepts “XLIFF” without saying which version is telling you less than it sounds like. Ask the agency which one their tool writes back — not which one it reads — because that is the file you have to import.

Where Mergua fits

Mergua reads and writes XLIFF 2.0, and only 2.0. A 1.2 file is refused with a message naming the version rather than being half-read, which for an Angular team is the likely first attempt: the file ng extract-i18n wrote is the one this reader turns away.

A document is one language pair, so every surface that hands one out hands out one language. The download dialog offers XLIFF when a single locale is selected and not when all of them are; the editor's overflow menu downloads the language you are looking at. A project split across several files stays one document with a <file> per file, rather than a zip. The Public API answers format=xliff per locale as XML; the sync script does not carry the format at all, because its job is to write the files your runtime loads and this is not one of them.

The status mapping is exact in both directions. A value with no translation is initial; a draft is translated with a vendor subState, because a draft is a translation that happened and initial would tell the translator's tool otherwise; a reviewed value is final. Coming back, the reading falls downwards exactly as argued above: final is reviewed, a bare translated is translated, and anything else — a missing state, an undefined value, any qualifier at all — is a draft somebody still has to confirm.

What Mergua has no column for travels back untouched, per unit, the way ARB attributes already do. The documented exception is at the container level: attributes on <file> and <group> are not kept, because a file grouping has no row in this database. That loss is reported in the import dialog rather than left for you to find in the next export, alongside the inline codes and the do-not-translate marks the reader could not map.

The XLIFF section of the import and export docs has the document Mergua writes, the state table as a reference, and the exact wording of every refusal.

Keep reading

Try it on one branch.

Upload the locale files you already have and see the whole loop — branch, translate, review, sync — on the free tier.

Create your account