My site ships in five languages. The calculator results go through a dictionary:
function T(s) {
var d = window.CH_CALC; // { "English label": "translation" }
return (d && Object.prototype.hasOwnProperty.call(d, s)) ? d[s] : s;
}
Look up the English, fall back to the English. It is about as simple as translation gets, and for most of the site it works.
Then I read a CPM calculator row on the Spanish page:
Impresiones 20,000
Coste por impresión 0.0025
Impressions per 50.00 20,000
One row in English. Not a missing translation — I went and checked, and there was no key to add. There is no key that could be added.
The string does not exist until the reader types
{ label: 'Impressions per ' + fmt(cost, 2), value: int(imp) }
Impressions per 50.00. Change the campaign cost to 75 and the label is Impressions per 75.00. The dictionary would need an entry for every number anyone ever enters.
This is a different category from "not translated yet", and I had been filing it under the same heading. Untranslated is a backlog. This is a defect, and no amount of work on the dictionaries would ever have closed it.
How many
I did not want to guess, and I could not eyeball it — 112 calculators. But the engines are pure functions of an input object, so I could run all of them and read what came out:
const strings = r => [
r.primary && r.primary.label,
...(r.rows || []).map(x => x && x.label),
r.tag && r.tag.text,
r.note,
].filter(Boolean);
The trick is telling the two categories apart. Run each calculator twice with different numbers: a label that changes between the runs is being assembled; a label that stays put is a fixed string that is simply missing.
const nudge = s => {
const n = parseFloat(s);
return Number.isFinite(n) ? String(n === 0 ? 7 : n * 2 + 3) : s;
};
reader-facing strings fixed: 397 built from input: 29
The first count was wrong
29 was too many, and the reason is worth the detour.
BMI at one weight says Healthy weight. At double that weight it says Overweight. The string changed between runs, so my sweep called it assembled — but it is not assembled at all. It is a categorical label from a fixed set of four, and every one of them is sitting in the dictionary already, translated.
The nudge did not reveal interpolation. It revealed a threshold.
So the discriminator needs a second condition: the label must also contain a digit.
for (const s of [...varying]) {
if (!/\d/.test(s)) { varying.delete(s); fixed.add(s); }
}
reader-facing strings fixed: 405 built from input: 21
21, across 18 calculators. And now the numbers say something:
es: missing but fixable: 15 unreachable by lookup: 21 (all of them)
fr: missing but fixable: 16 unreachable by lookup: 21 (all of them)
pt: missing but fixable: 14 unreachable by lookup: 21 (all of them)
zh: missing but fixable: 10 unreachable by lookup: 21 (all of them)
The fixed strings are about 96% translated — an ordinary, healthy backlog of a dozen or so. The assembled ones are 0% translated in all four languages, without exception.
That is not a coincidence you have to interpret. A 96% column next to a 0% column is a mechanism, not a backlog. If it were an effort problem the two would look alike. When one category is perfect and the neighbouring one is empty, something is preventing the work rather than nobody doing it.
The fix is to translate the shape
function TF(tpl) {
var args = Array.prototype.slice.call(arguments, 1);
return T(tpl).replace(/\{(\d)\}/g, function (m, i) {
var a = args[Number(i)];
return a === undefined ? m : String(a);
});
}
{ label: TF('Impressions per {0}', fmt(cost, 2)), value: int(imp) }
The key is now Impressions per {0}, which is a constant. Twenty-three of them across the site.
The a === undefined ? m : ... matters more than it looks. Templates get edited in dictionaries, by people who are not looking at the call site, months later. If a translator adds a {1} that no argument feeds, the honest outcome is a visible {1} — not the word undefined sitting in a financial result where a number belongs.
And because an untranslated template falls back to English exactly like an untranslated string, this converts one label at a time with nothing to coordinate.
Verifying it end to end
Not "does TF work" — of course TF works. The question is whether a Spanish reader now sees Spanish, which means running the real calculators against the real dictionary:
function render(dict) {
delete require.cache[require.resolve(CALC)];
global.window = dict ? { CH_CALC: dict } : undefined;
return require(CALC).ENGINES;
}
percentage-calculator "200.00 plus 15%" -> "200.00 más 15%"
discount-calculator "You save 30.00" -> "Ahorras 30.00"
roi-calculator "Profit 3,000.00" -> "Beneficio 3,000.00"
zakat-calculator "Zakat due (2.5%)" -> "Zakat a pagar (2.5%)"
paypal-fee-calculator "Fee (2.9% + 0.30)" -> "Comisión (2.9% + 0.30)"
cpm-calculator "Impressions per 50.00" -> "Impresiones por 50.00"
emergency-fund-calculator "13,000.00 to go" -> "Faltan 13,000.00"
...
20 labels now reach a Spanish reader in Spanish that could not before.
Doing this also flushed out that T() reached straight for window. The engines had never called it — only the renderer did — so no Node test had ever touched that line. Making labels translatable inside the engines was what dragged the browser into a test suite that had happily run without one.
The two assertions I would keep
The mechanical ones are the ones that survive:
// Every TF template must exist in all four dictionaries. A template nobody
// translated is the same English string with extra steps.
const templates = [...src.matchAll(/TF\('([^']*)'/g)].map(m => m[1]);
check(lang + ': all templates translated', missing, []);
// And no translation may drop a {0}. That does not look wrong in review -
// it looks like a slightly shorter sentence - and it deletes the number
// from a financial result.
check(lang + ': every placeholder survives', dropped, []);
The template list is read out of the source rather than written down again in the test. A hand-maintained copy would drift, and it would drift silently, in the direction of passing.
What I would take from it
The + in a label is the whole bug. It is invisible in review because it is the most ordinary line of code in the world, and it does not fail — it produces exactly the right English string.
If your product speaks more than one language, the pattern to grep for is a string literal next to a + inside anything a reader will see. And if you already have translation coverage numbers, look at whether they cluster: a column at 96% and a column at 0% are not the same project running at different speeds. One of them is a wall.
I build Utilorax, a set of free browser-based tools. This came out of the CPM calculator, which now says "Impresiones por 50.00" to people reading it in Spanish.
Top comments (0)