fix(i18n): keep the ICU literal escape around angle placeholders when translating

English writes '<name>' — the single quotes are ICU's escape, so the span
renders as literal text. Every backend drops them, and the translated message
then parses as an unclosed ICU tag: the nine locales of the first batch each
shipped two such strings and only CI caught them.

translateString and translateBatch now restore the quoting, doubling an
apostrophe inside the span so it does not close the literal early (Estonian
OmniRoute'i, Irish d'eochair). Messages whose English mixes real markup with a
literal span are left untouched, since there is no safe way to tell the two
apart.
This commit is contained in:
diegosouzapw
2026-09-10 12:39:15 -03:00
parent 81bf3cc36e
commit e89a4da2b0
2 changed files with 77 additions and 2 deletions

View File

@@ -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 `'<name>'`: those single quotes are ICU's escape, so the span
* renders as the literal text `<name>`. Translations come back as a bare
* `<nome>`, 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 (`<b>`) 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;
}

View File

@@ -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
// '<name>' 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/'<name>'/settings.json",
"Regenerar ~/.claude/profiles/<nome>/settings.json"
),
"Regenerar ~/.claude/profiles/'<nome>'/settings.json"
);
});
test("doubles an apostrophe inside the span, which would close the literal early", () => {
assert.equal(
preserveIcuLiteralQuotes("'<your OmniRoute API key>'", "<teie OmniRoute'i API-võti>"),
"'<teie OmniRoute''i API-võti>'"
);
});
test("leaves a translation that already carries the quotes untouched", () => {
const already = "'<seu token>'";
assert.equal(preserveIcuLiteralQuotes("'<your token>'", already), already);
});
test("does not touch messages whose English has a real unquoted tag", () => {
// <b> here is markup the translation must keep as markup, not literal text.
const translated = "Clique em <b>Salvar</b> e em '<nome>'";
assert.equal(preserveIcuLiteralQuotes("Click <b>Save</b> and '<name>'", 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"
);
});