diff --git a/scripts/i18n/lib/translate-backend.mjs b/scripts/i18n/lib/translate-backend.mjs index 444d2a3545..77e4ca7d9e 100644 --- a/scripts/i18n/lib/translate-backend.mjs +++ b/scripts/i18n/lib/translate-backend.mjs @@ -119,6 +119,32 @@ export const TRANSLATION_SYSTEM = (englishName, native) => `Keep punctuation and trailing whitespace identical to the source.`, ].join(" "); +/** + * Restores the ICU literal escape the backends drop around angle placeholders. + * + * English writes `''`: those single quotes are ICU's escape, so the span + * renders as the literal text ``. Translations come back as a bare + * ``, which ICU then parses as an (unclosed) tag and the message fails to + * compile — every locale in the first batch shipped two of these. + * + * Only messages whose English side quotes EVERY angle span are touched: when the + * source mixes real markup (``) with a literal span there is no safe way to + * tell which is which, so the translation is left exactly as it came back. An + * apostrophe inside the span is doubled, otherwise it closes the literal early. + */ +export function preserveIcuLiteralQuotes(englishValue, translated) { + if (typeof englishValue !== "string" || typeof translated !== "string") return translated; + if (!englishValue.includes("'<")) return translated; + // Every "<" in the source must be the start of an escaped span. + for (let i = 0; i < englishValue.length; i++) { + if (englishValue[i] === "<" && englishValue[i - 1] !== "'") return translated; + } + return translated.replace( + /(?]*)>(?!')/g, + (_m, inner) => `'<${inner.replace(/'/g, "''")}>'` + ); +} + export async function translateString(englishValue, localeEntry, backend) { const englishName = localeEntry.english ?? localeEntry.name; const native = localeEntry.native ?? localeEntry.name; @@ -127,7 +153,7 @@ export async function translateString(englishValue, localeEntry, backend) { { role: "user", content: englishValue }, ]; const out = await callChat(messages, backend); - return out.trim(); + return preserveIcuLiteralQuotes(englishValue, out.trim()); } // ----- Batch mode ---------------------------------------------------------- @@ -198,8 +224,14 @@ export async function translateBatch(entries, localeEntry, backend) { { role: "user", content: JSON.stringify(payload) }, ]; const out = await callChat(messages, backend); - return parseBatchResponse( + const parsed = parseBatchResponse( out, entries.map((e) => e.id) ); + for (const entry of entries) { + if (typeof parsed[entry.id] === "string") { + parsed[entry.id] = preserveIcuLiteralQuotes(entry.text, parsed[entry.id]); + } + } + return parsed; } diff --git a/tests/unit/i18n-translate-backend-icu-quotes.test.ts b/tests/unit/i18n-translate-backend-icu-quotes.test.ts new file mode 100644 index 0000000000..c976cbfc7e --- /dev/null +++ b/tests/unit/i18n-translate-backend-icu-quotes.test.ts @@ -0,0 +1,43 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { preserveIcuLiteralQuotes } from "../../scripts/i18n/lib/translate-backend.mjs"; + +// The English catalog escapes angle placeholders for ICU: the single quotes in +// '' make the span literal text. Translation backends drop them, and the +// message then parses as an unclosed ICU tag — every locale added in batch 1 +// shipped two such strings before CI caught them. +test("re-escapes an angle span the translation left bare", () => { + assert.equal( + preserveIcuLiteralQuotes( + "Regenerate ~/.claude/profiles/''/settings.json", + "Regenerar ~/.claude/profiles//settings.json" + ), + "Regenerar ~/.claude/profiles/''/settings.json" + ); +}); + +test("doubles an apostrophe inside the span, which would close the literal early", () => { + assert.equal( + preserveIcuLiteralQuotes("''", ""), + "''" + ); +}); + +test("leaves a translation that already carries the quotes untouched", () => { + const already = "''"; + assert.equal(preserveIcuLiteralQuotes("''", already), already); +}); + +test("does not touch messages whose English has a real unquoted tag", () => { + // here is markup the translation must keep as markup, not literal text. + const translated = "Clique em Salvar e em ''"; + assert.equal(preserveIcuLiteralQuotes("Click Save and ''", translated), translated); +}); + +test("leaves messages without angle spans alone", () => { + assert.equal(preserveIcuLiteralQuotes("Settings", "Nastavitve"), "Nastavitve"); + assert.equal( + preserveIcuLiteralQuotes("Restricted to {count} endpoints", "Omejeno na {count} točk"), + "Omejeno na {count} točk" + ); +});