Pseudo-locale strings breaking ICU placeholders
A pseudolocalization pass that runs a character map over the raw catalogue produces messages that look right in a text editor and throw the moment they render:
FormatError: The intl string context variable "çôûñt" was not provided to the string "{çôûñt, plûràl, ôñé {# fìlé} ôthér {# fìléŝ}}"
The transform accented the argument name. The runtime is asked for a value called çôûñt, your code supplies count, and every message with an argument fails at once. This page explains why a character map cannot work, what the correct transform looks like, and how to prove it stayed correct.
Root cause: a message is structure, not a string
An ICU message is a small program. {count, plural, one {# file} other {# files}} contains exactly one span of human-readable text per branch; everything else — the argument name count, the type keyword plural, the category keywords one and other, the # token — is syntax that the runtime matches against values you pass in.
A character map has no way to tell the two apart, because both are just characters in a string. It accents all of them. The argument name stops matching your values, the type keyword stops being recognised as a plural argument, and the category keywords no longer correspond to anything Intl.PluralRules can return. Depending on the library you get a thrown error, a silently unformatted message, or — the worst case — a message that renders the literal text {çôûñt, plûràl, …} to a user.
The same reasoning applies to rich-text tags. In Hello <b>{name}</b>, the tag name b is a lookup key into the component map your renderer was given. Accenting it to <b́> breaks that lookup exactly the way accenting an argument name does.
Minimal reproducible example
Four lines are enough to reproduce it, which means the failure can be pinned in a unit test rather than in a browser.
import IntlMessageFormat from 'intl-messageformat';
const source = '{count, plural, one {# file} other {# files}}';
const naive = [...source].map((c) => ({ a:'à', c:'ç', e:'é', i:'ì', l:'l', n:'ñ',
o:'ô', s:'ŝ', u:'ù' }[c] ?? c)).join('');
new IntlMessageFormat(source, 'en').format({ count: 3 }); // → "3 files"
new IntlMessageFormat(naive, 'en').format({ count: 3 }); // → throws: "çôûñt" not provided
The fix: walk the AST, transform only literals
Parse the message, then map over the node list. Literal nodes are transformed; argument nodes are copied verbatim; plural, select and tag nodes are recursed into so their branch bodies get transformed while their names and keywords survive.
import { parse, TYPE } from '@formatjs/icu-messageformat-parser';
const ACCENT: Record<string, string> = {
a:'à', c:'ç', e:'é', i:'ì', n:'ñ', o:'ô', s:'ŝ', u:'ù', y:'ý',
A:'À', C:'Ç', E:'É', I:'Ì', N:'Ñ', O:'Ô', S:'Ŝ', U:'Ù', Y:'Ý',
};
const accent = (t: string) =>
[...t].map((ch) => ACCENT[ch] ?? ch).join('') + '·'.repeat(Math.ceil(t.length * 0.4));
function render(node: any): string {
switch (node.type) {
case TYPE.literal:
return accent(node.value); // the ONLY text we touch
case TYPE.argument:
return `{${node.value}}`; // name copied verbatim
case TYPE.pound:
return '#'; // runtime substitutes this
case TYPE.plural:
case TYPE.select: {
const kind = node.type === TYPE.plural ? 'plural' : 'select';
const offset = node.offset ? `, offset:${node.offset}` : '';
const branches = Object.entries(node.options)
.map(([key, opt]: any) => `${key} {${opt.value.map(render).join('')}}`)
.join(' ');
return `{${node.value}, ${kind}${offset}, ${branches}}`;
}
case TYPE.tag:
return `<${node.value}>${node.children.map(render).join('')}</${node.value}>`;
default:
// date, time, number: the argument and its skeleton are both structure
return `{${node.value}${node.style ? `, ${node.style}` : ''}}`;
}
}
export const pseudo = (msg: string) => `[!! ${parse(msg).map(render).join('')} !!]`;
Three details in that function matter. The offset on a plural node has to be carried through, or an offset message silently changes meaning. Branch keys including exact-match forms such as =0 are copied as keys, never as text. And the wrapper markers are applied once around the whole message rather than around each literal, so a marker appearing mid-sentence still means what it is supposed to mean — that the sentence was assembled from more than one catalogue entry.
Verification
Assert on structure, not on the accented output. Two properties are enough: the pseudo message parses, and its argument name set is identical to the source’s.
import { parse, TYPE } from '@formatjs/icu-messageformat-parser';
const argNames = (msg: string): string[] => {
const out: string[] = [];
const walk = (nodes: any[]) => nodes.forEach((n) => {
if (n.value && n.type !== TYPE.literal && n.type !== TYPE.pound) out.push(n.value);
if (n.options) Object.values(n.options).forEach((o: any) => walk(o.value));
if (n.children) walk(n.children);
});
walk(parse(msg));
return out.sort();
};
test.each(Object.keys(source))('%s keeps its arguments', (key) => {
expect(() => parse(pseudoCatalog[key])).not.toThrow();
expect(argNames(pseudoCatalog[key])).toEqual(argNames(source[key]));
});
Running that test over the whole catalogue takes well under a second and fails on the exact key that regressed, which is the property that makes it worth having in the pipeline described in localization testing and pseudolocalization.
When to escalate
If the argument sets match and messages still fail, the problem is no longer the transform. Three causes are worth checking in order.
The source catalogue may contain malformed ICU that the parse step is now surfacing for the first time. That is a real bug the pseudo pass found rather than caused, and it belongs in the ICU message format reference — most often an unescaped apostrophe or an unbalanced brace.
The renderer may be receiving a different value shape than the message expects. A message expecting {count} as a number will misbehave when handed a string in some libraries, and the pseudo pass has nothing to do with it.
Finally, the accent map may contain a glyph your shipped font subset does not include. The message is then structurally perfect and renders as boxes, which reads like a transform bug but is a font-loading bug. Restrict the accent map to characters your font actually carries.
Ordering: where the transform belongs in the build
Two pipeline positions look equivalent and are not.
Running the transform before compilation — on the same JSON the real translations are authored in — means the pseudo bundle passes through every subsequent step exactly as a real locale does. It is compiled by the same compiler, bundled by the same bundler, and loaded by the same loader. If any of those steps has a locale-specific bug, the pseudo run finds it.
Running the transform after compilation means operating on generated artifacts: Lingui’s compiled message functions, Angular’s inlined bundles, or a minified JSON blob. At that point the message is no longer a message; it is code, and a character map over code is a category error that produces broken JavaScript rather than broken text.
The same reasoning explains a subtler failure. If your build extracts messages, compiles them, and then generates the pseudo bundle from the extracted source, the pseudo bundle skips the compile step — and a compile-time bug in message handling stays invisible. Generate early, compile everything together, and the pseudo locale earns its keep as a canary for the whole pipeline rather than only for the transform.
One practical consequence: the transform must be idempotent-safe against accidental double application. Running it twice produces doubly-accented, doubly-padded text that still parses, so nothing fails — you simply get a bundle that over-reports expansion. Writing the output to a distinct locale tag, rather than in place, makes double application impossible by construction.
FAQ
Why not just skip anything inside braces?
Because braces nest. A plural message contains branch bodies inside braces, and those bodies are exactly the text you want to transform. A regular expression that skips brace-delimited spans either skips the text you need or fails on nesting; both outcomes are worse than parsing, which is a single dependency and a dozen lines.
Does this apply to gettext-style catalogues too?
Yes, with different syntax. In printf-style formats the tokens are %s, %1$d and friends, and a character map will happily accent them into meaninglessness. The rule generalises: identify the format’s substitution syntax, leave it alone, transform only what surrounds it — see PO and XLIFF format bridging for how those tokens are represented across formats.
Should the pseudo transform run on the compiled catalogue or the source?
On the source. Compiled catalogues — Lingui’s function output, for instance — have already turned the message into code, and transforming code is a different and much worse problem. Generate the pseudo bundle from the same source the real translations come from, then compile it through the identical pipeline so the compile step is exercised too.
Why did the transform work last month and break today?
Almost always because a new message introduced a syntax the transform does not handle. Rich-text tags, date and time skeletons, and nested selects each need their own branch in the renderer, and a transform written against a catalogue of simple strings will silently mishandle the first message that uses one. Making the renderer throw on an unknown node type, rather than falling through to a default that stringifies it, converts a silent corruption into a build failure that names the message.
Can the pseudo transform run in the browser instead of the build?
It can, and it is a reasonable development convenience — a query parameter that swaps the catalogue for a transformed copy gives instant feedback without a rebuild. It is not a substitute for the build-time bundle, because it skips exactly the pipeline steps that most often break: extraction, compilation and bundling. Use it to look at a screen quickly; use the generated bundle for anything that gates a merge.
Related
- Localization Testing & Pseudolocalization — the full testing approach this transform feeds.
- ICU Message Format Deep Dive — the grammar the transform must preserve.
- ICU nested select message escaping errors — the parse errors a pseudo pass tends to surface first.
- Complex plural syntax — offsets and exact matches the transform has to carry through.