mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-14 02:42:24 +03:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
43
tests/unit/i18n-translate-backend-icu-quotes.test.ts
Normal file
43
tests/unit/i18n-translate-backend-icu-quotes.test.ts
Normal 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"
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user