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.
en "You have {count} new messages"
de "Sie haben neue Nachrichten"
Fluent. Idiomatic. Nothing to
hand back.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.source
Signed in as {{ user.email }}
translation
Angemeldet als {{user.email}}
no deviation
Identity ignores whitespace, so {{ value | date }}
and {{value|date}} are the same placeholder. What
sits between the braces travels with it — a pipe, a
filter, an expression — and has to come back.source
%1$s invited %2$s
translation
%2$s wurde von %1$s eingeladen
no deviation
%s, %d and %.2f count, and the precision is part of
it: %.2f and %f do not print the same number. The
positions above are numbered, so the word order is
free.
%% is an escaped percent sign, so "50% off" is
prose.source
Almost done. $t(cart.checkout)
translation
Fast fertig. $t(cart.kasse)
renamed $t(cart.checkout) → $t(cart.kasse)
A reference pulls another key into the message, so
losing one loses a whole sentence rather than a
word.
The key is the reference and the options are
arguments to it: $t(key, { count: 1 }) still points
at key.source
{count, plural, one {# file} other {# files}}
translation
{count, plural, one {# plik} few {# pliki}
many {# plików} other {# pliku}}
no deviation
Compared structurally: the argument and its type,
plus the placeholders inside the sub-messages —
here the # in each of them.
English one/other against Polish
one/few/many/other is a correct translation, so the
categories are not compared at all.
# is a placeholder only inside a plural. Outside
one a hash is a hash: "#1", "issue #42".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.
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.
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.
| Where | Unit | What a deviation does |
|---|---|---|
| The editor | Per cell, as you type | A badge beside the field: Missing, Unexpected, Renamed — or “Placeholders match” when both sides carry one and they agree. |
| The upload preview | Per file, before anything is written | Listed in the dialog under “Placeholders that do not match”. The import goes ahead; the dialog says so. |
| An upload over the API, main or branch | Per file | placeholderIssues in the response, beside a counter. The values are written. |
| What the sync script pushes | Per file | The same two fields, and a WARNING line per key in the job log. |
| The AI round | Per value the model returned | Stored 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.
$ 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}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:
- 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.
- 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.
- 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.