fix(i18n): parse nested Kotlin interpolation

This commit is contained in:
Vincent Koc
2026-07-12 11:15:31 +02:00
parent 569a5f44c0
commit 5b7f50c077
2 changed files with 96 additions and 9 deletions
+78 -9
View File
@@ -45,7 +45,6 @@ const ARRAY_RE = /<string-array\s+name="([A-Za-z0-9_]+)"[^>]*>([\s\S]*?)<\/strin
const ARRAY_ITEM_RE = /<item>([\s\S]*?)<\/item>/gu;
const FORMAT_RE = /%\d+\$[a-z]/giu;
const INVALID_APOSTROPHE_RE = /(?:&apos;|(?<!\\)')/u;
const INTERPOLATION_RE = /\$(?:[A-Za-z_][A-Za-z0-9_]*|\{[^{}]+\})/gu;
const GENERATED_HEADER = " <!-- Generated by scripts/android-app-i18n.ts. -->";
const GENERATED_KOTLIN_HEADER = "// Generated by scripts/android-app-i18n.ts. Do not edit.";
@@ -105,10 +104,75 @@ function decodeXml(value: string): string {
.replaceAll("\\\\", "\\");
}
type KotlinInterpolation = {
end: number;
start: number;
value: string;
};
function readKotlinInterpolations(source: string): KotlinInterpolation[] | null {
const interpolations: KotlinInterpolation[] = [];
for (let index = 0; index < source.length; index += 1) {
if (source[index] !== "$") {
continue;
}
const next = source[index + 1];
if (next !== "{" && !/[A-Za-z_]/u.test(next ?? "")) {
continue;
}
const start = index;
if (next !== "{") {
index += 2;
while (/[A-Za-z0-9_]/u.test(source[index] ?? "")) {
index += 1;
}
interpolations.push({ start, end: index, value: source.slice(start, index) });
index -= 1;
continue;
}
let depth = 1;
let quote: '"' | "'" | null = null;
let escaped = false;
for (index += 2; index < source.length; index += 1) {
const character = source[index];
if (escaped) {
escaped = false;
} else if (quote !== null && character === "\\") {
escaped = true;
} else if (character === quote) {
quote = null;
} else if (quote === null && (character === '"' || character === "'")) {
quote = character;
} else if (quote === null && character === "{") {
depth += 1;
} else if (quote === null && character === "}") {
depth -= 1;
if (depth === 0) {
const end = index + 1;
interpolations.push({ start, end, value: source.slice(start, end) });
break;
}
}
}
if (depth !== 0) {
return null;
}
}
return interpolations;
}
export function renderAndroidResourceValue(source: string, translated: string): string {
let rendered = translated;
const sourceTokens = [...source.matchAll(INTERPOLATION_RE)].map((match) => match[0]);
const translatedTokens = [...translated.matchAll(INTERPOLATION_RE)].map((match) => match[0]);
const sourceInterpolations = readKotlinInterpolations(source);
const translatedInterpolations = readKotlinInterpolations(translated);
if (sourceInterpolations === null || translatedInterpolations === null) {
throw new Error(
`Android translation has unbalanced interpolation placeholders: ${JSON.stringify(source)} -> ${JSON.stringify(translated)}`,
);
}
const sourceTokens = sourceInterpolations.map((interpolation) => interpolation.value);
const translatedTokens = translatedInterpolations.map((interpolation) => interpolation.value);
const tokenCounts = (tokens: readonly string[]) => {
const counts = new Map<string, number>();
for (const token of tokens) {
@@ -122,7 +186,6 @@ export function renderAndroidResourceValue(source: string, translated: string):
);
}
if (sourceTokens.length > 0) {
rendered = rendered.replaceAll("%", "%%");
const sourceIndices = new Map<string, number[]>();
for (const [index, token] of sourceTokens.entries()) {
const indices = sourceIndices.get(token) ?? [];
@@ -130,12 +193,18 @@ export function renderAndroidResourceValue(source: string, translated: string):
sourceIndices.set(token, indices);
}
const translatedOccurrences = new Map<string, number>();
rendered = rendered.replace(INTERPOLATION_RE, (token) => {
let cursor = 0;
rendered = "";
for (const interpolation of translatedInterpolations) {
rendered += translated.slice(cursor, interpolation.start).replaceAll("%", "%%");
const token = interpolation.value;
const occurrence = translatedOccurrences.get(token) ?? 0;
translatedOccurrences.set(token, occurrence + 1);
const index = sourceIndices.get(token)?.[occurrence];
return index ? `%${index}$s` : token;
});
rendered += index ? `%${index}$s` : token;
cursor = interpolation.end;
}
rendered += translated.slice(cursor).replaceAll("%", "%%");
}
return rendered
.replaceAll("\\", "\\\\")
@@ -837,8 +906,8 @@ function renderStringsXml(
for (const [key, entry] of [...generated].toSorted(([left], [right]) =>
compareText(left, right),
)) {
const formatted = INTERPOLATION_RE.test(entry.source) ? "" : ' formatted="false"';
INTERPOLATION_RE.lastIndex = 0;
const formatted =
(readKotlinInterpolations(entry.source)?.length ?? 0) > 0 ? "" : ' formatted="false"';
// Translation-memory text intentionally preserves technical tokens and source punctuation,
// so Android's English dictionary and typography suggestions do not apply to managed keys.
lines.push(
+18
View File
@@ -53,6 +53,24 @@ describe("Android app i18n resources", () => {
).toBe("%2$s Anbieter, davon %1$s bereit");
});
it("formats nested Kotlin interpolations as single Android arguments", () => {
expect(
renderAndroidResourceValue(
"${device.tokens.count { !it.revoked }}/${device.tokens.size} active tokens",
"${device.tokens.size} Token, ${device.tokens.count { !it.revoked }} aktiv",
),
).toBe("%2$s Token, %1$s aktiv");
});
it("balances braces inside nested interpolation strings", () => {
expect(
renderAndroidResourceValue(
'${if (connected) "{" else "}"} $count',
'$count · ${if (connected) "{" else "}"}',
),
).toBe("%2$s · %1$s");
});
it("rejects repeated translation placeholders that do not match the source", () => {
expect(() =>
renderAndroidResourceValue("$item then $item", "$item, $item und noch einmal $item"),