diff --git a/scripts/android-app-i18n.ts b/scripts/android-app-i18n.ts index 1dab0e44e080..51c3ad216963 100644 --- a/scripts/android-app-i18n.ts +++ b/scripts/android-app-i18n.ts @@ -45,7 +45,6 @@ const ARRAY_RE = /]*>([\s\S]*?)<\/strin const ARRAY_ITEM_RE = /([\s\S]*?)<\/item>/gu; const FORMAT_RE = /%\d+\$[a-z]/giu; const INVALID_APOSTROPHE_RE = /(?:'|(?"; 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(); 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(); 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(); - 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( diff --git a/test/scripts/android-app-i18n.test.ts b/test/scripts/android-app-i18n.test.ts index 4a66349528ba..7a400095d3e5 100644 --- a/test/scripts/android-app-i18n.test.ts +++ b/test/scripts/android-app-i18n.test.ts @@ -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"),