From fa9fb98548c41ea413e9ea0b0d246b84f665b5d7 Mon Sep 17 00:00:00 2001 From: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:59:07 -0400 Subject: [PATCH] Preserve ICU arguments and selectors during pseudo-localization Signed-off-by: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com> --- engineering/engineering-i18n-engineer.md | 28 +++++++++++++++++------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/engineering/engineering-i18n-engineer.md b/engineering/engineering-i18n-engineer.md index cdb53ef3..46a07742 100644 --- a/engineering/engineering-i18n-engineer.md +++ b/engineering/engineering-i18n-engineer.md @@ -126,16 +126,28 @@ new Intl.ListFormat(locale, { type: 'conjunction' }).format(['Ana', 'Luis', 'Mei ### Pseudo-Localization in CI: Catch It Before Translators Do ```javascript -// Pseudo-locale transform: "Save changes" → "[!!! Šàvé çhàñĝéš one two !!!]" -// - Accented chars expose encoding bugs -// - +40% padding exposes truncation and fixed-width layouts -// - Brackets expose concatenation (fragments render as separate bracketed chunks) -// - Untransformed text on screen = hardcoded string, fail the check +// Transform literal AST nodes, never ICU arguments, selectors or skeletons. +import { parse, TYPE } from '@formatjs/icu-messageformat-parser'; +import { printAST } from '@formatjs/icu-messageformat-parser/printer.js'; + export function pseudoLocalize(message) { const map = { a: 'à', e: 'é', i: 'î', o: 'ö', u: 'ü', c: 'ç', n: 'ñ', s: 'š', g: 'ĝ' }; - const swapped = message.replace(/[aeioucnsg]/g, (ch) => map[ch] ?? ch); - const padding = ' one two three'.slice(0, Math.ceil(message.length * 0.4)); - return `[!!! ${swapped}${padding} !!!]`; + function transform(elements) { + for (const element of elements) { + if (element.type === TYPE.literal) { + const swapped = element.value.replace(/[aeioucnsg]/g, (ch) => map[ch] ?? ch); + const extra = Math.ceil(element.value.length * 0.4); + element.value = swapped + ' ~'.repeat(Math.ceil(extra / 2)).slice(0, extra); + } else if (element.type === TYPE.select || element.type === TYPE.plural) { + for (const option of Object.values(element.options)) transform(option.value); + } else if (element.type === TYPE.tag) { + transform(element.children); + } + } + } + const ast = parse(message); + transform(ast); + return `[!!! ${printAST(ast)} !!!]`; } ```