Getting it right

A translation can be word-perfect and still break the screen

Placeholders are the part of a string that is not language. A translator reads {count} as noise and works around it; the renderer reads it as a contract. Drop one and the sentence still reads beautifully in review, ships, and says “You have new messages” with the number missing — in the one language nobody on the team reads.

Published Updated

The mistake review cannot see

Every other kind of translation error is a judgement call — wrong register, wrong term, too long for the button. A lost placeholder is not. It has one right answer, and it is the only error a fluent, careful, native reviewer hands back approved.

what the reviewer reads
en   "You have {count} new messages"
de   "Sie haben neue Nachrichten"

     Fluent. Idiomatic. Nothing to
     hand back.
what the screen shows
en   You have 3 new messages
de   Sie haben neue Nachrichten

     No error, no stack trace, no
     failing test. Just a sentence
     that stopped counting.

Three reasons it survives, and none of them is anybody being careless:

  • It reads better. A sentence with the placeholder taken out is shorter and smoother than one with a brace in the middle. Removing it is what a careful writer does to a draft.
  • Your tests assert one language. The suite checks the copy it was written against — the source language, the one locale where no placeholder was ever at risk.
  • Nothing throws. An interpolation with nothing to interpolate is not an error to a renderer. It is a gap in a string, and it surfaces as prose in front of a user rather than as a log line.

Five notations, one job

The libraries in use disagree about how a placeholder is written and agree about what it is for. A check that knows one notation covers the projects that only ever use one, which is no project past its second dependency: a component library brings its own, and a message that references another key brings a third.

source
  Add {count} to {cart}

translation
  {anzahl} zum {cart} hinzufügen

  renamed     {count} → {anzahl}

A bare argument counts, so does a positional {0}
and a path {user.name}. So does a format:
{price, number, currency} renders differently from
{price}, so both halves have to survive.

Braces around prose are text. {this is a sentence}
is not a placeholder, and reporting it would raise
a warning about wording.

Note what is deliberately not a placeholder. Braces around prose are text, so nothing warns about wording. A doubled percent sign is an escaped one, so a discount in a headline stays prose. And a hash is a number only inside a plural — outside one it is an issue number, and counting it would flag every sentence that has one.

None of this depends on how the file around it is shaped: nested or flat changes the path to a key, not what lives inside its value.

When two are the same one

Comparing two lists of placeholders is easy. Deciding when two entries are the same placeholder is where the work is, and it is what separates a warning worth reading from a warning people switch off.

  • Whitespace does not count. A translator who tidies a filter expression has changed nothing, so identity ignores it.
  • A named placeholder repeats for free. Two {name} are one placeholder mentioned twice, because a translation may legitimately repeat it or say it once. Two bare printf conversions are two placeholders, because nothing but the count tells them apart.
  • Structure, not text. An ICU expression is its argument, its type and the placeholders inside its sub-messages. Reformat it and it is the same expression.
and when there is nothing to say
Both sides carry nothing

  source        ""
  translation   "Fertig"
  → no placeholder anywhere, so there is nothing
    to report and nothing to reassure about.

One side is empty

  source        "Total: {amount}"
  translation   ""
  → the comparison finds {amount} missing. Worth
    suppressing anyway: an empty value is a cell
    nobody filled in, and an empty source is a key
    with no original to have kept anything from.

Three calls worth making

These are the decisions that make the difference between a check people keep on and one they mute in the first week. Each one is a case where the technically correct answer is the wrong one.

A rename is one mistake, not two

Someone who writes a placeholder under a translated name broke one thing. Reporting it as a loss and a surplus is right and reads as two problems, so the pair belongs matched and reported once. It only works for placeholders that carry a name: a bare conversion has none, so nothing can pair it.

a name can be traded
source
  Deleted %s

translation
  %d gelöscht

  missing     %s
  unexpected  %d

%s carries no name, so nothing pairs the two: a
conversion traded for another conversion is a
placeholder lost and a different one gained, and
that is what it is called.

Plural categories are the language’s business

English has one and other; Polish has four forms. Comparing the category lists would report every translation into a language with more forms than the source — which is most of them. Compare the placeholders inside the sub-messages and leave the categories alone.

An empty side is not a deviation

No source text means no original to have kept anything from. No translation means a cell nobody has filled in. Telling either one that it fails to carry a placeholder describes the emptiness rather than a mistake, and it puts a warning on a row whose real state is “untranslated”.

Where Mergua fits

Mergua runs one comparison — the one whose verdicts are printed above; those samples are its output rather than a description of it — on every path that writes a value. A check that only runs in the editor is a check the pipeline walks past, so the unit it reports in is whatever the path deals in: a cell, a file, or a single machine-written string.

WhereUnitWhat a deviation does
The editorPer cell, as you typeA badge beside the field: Missing, Unexpected, Renamed — or “Placeholders match” when both sides carry one and they agree.
The upload previewPer file, before anything is writtenListed in the dialog under “Placeholders that do not match”. The import goes ahead; the dialog says so.
An upload over the API, main or branchPer fileplaceholderIssues in the response, beside a counter. The values are written.
What the sync script pushesPer fileThe same two fields, and a WARNING line per key in the job log.
The AI roundPer value the model returnedStored as a draft instead of a finished translation, and named in the response.

Nothing in the download path checks anything, and that is not a gap: by the time a value is downloaded it has already come in through one of the five above. On a branch the comparison runs against the branch’s source text rather than main’s — a branch may have edited the original, and for a key the branch added there is no main text at all.

in a pipeline log
$ curl -sf https://api.mergua.com/v1/mergua.sh | sh -s -- sync

  WARNING: 2 key(s) in de do not carry the placeholders their source text does.
    checkout.pay.total: missing {amount}
    cart.badge.items: renamed {count} -> {anzahl}
in the response
PUT /v1/{projectId}/sync-translations/de

{
  "locale": "de",
  "updated": 128,
  …
  "keysWithPlaceholderIssues": 1,
  "placeholderIssues": [
    {
      "key": "checkout.pay.total",
      "sourceValue": "Total: {amount}",
      "newValue": "Gesamt:",
      "missing": ["{amount}"],
      "extra": [],
      "renamed": []
    }
  ]
}

Each entry carries the source text alongside the value, because the warning is only actionable next to the original — and on a pipeline path the reader is a log with nothing to click through to. The counter beside the list is the list’s own length, so a job can branch on one number without parsing anything.

Loud, and never in the way

A deviation is reported and the value is written. Never rejected — not in the editor, not on an upload, not from a pipeline. Three reasons, and the third settles it:

  1. The same string is legal through the other door. A translator can type a value that drops a placeholder and it saves, with a badge. Refusing the identical string over the API would make one text legal in the app and illegal in a pipeline, for no difference in the text.
  2. There is nothing all-or-nothing to offer. An upload writes as it walks the file, so a refusal partway through a large one leaves the first half written. Doing it properly means checking everything before writing anything, which is a preflight endpoint rather than a flag on this one.
  3. A counter leaves the choice with the caller. The response names the count and the keys, so a job that wants a hard stop reads it and exits, and one pushing a half-finished locale does not have to. Refusing server-side is one policy for every project at once, decided by us.

So the pipeline is louder than the app rather than stricter. The one place the verdict changes an outcome is a machine-written value: a string the model returned without the placeholders it was given is stored as a draft rather than as a finished translation. It is kept, because nearly-right text is worth more to whoever opens the cell than a blank — and it is not retried, because a silent second attempt spends the account’s allowance on a fault the model may well repeat.

The feature tour shows the badge in place, Import & export covers what else an upload reports, and the CLI reference has the flags around the push.

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