feat(l10n): single-source localization pipeline

Add localization/ — one strings.json is the source of truth for every
user-facing string, and a Bun CLI generates the platform files:

  loc migrate   rebuild strings.json from existing platform files (one-time)
  loc validate  structural checks (plural `other`, arg refs, coverage)
  loc generate  emit .xcstrings (Apple) + per-language strings.xml (KMP)

Canonical `{name}` placeholders are converted to each platform's positional
printf tokens using declared arg types. Plurals use CLDR categories and emit
native forms: xcstrings plural variations and Compose <plurals> blocks.

Migration folds the KMP `transfer_file_count_one`/`_other` pair into a single
plural key, so `transferFileCountResource` now returns the plural resource and
callers resolve it with pluralStringResource.
This commit is contained in:
2026-07-20 10:15:28 +02:00
parent 9a3cfb55d0
commit cd6a2d66aa
19 changed files with 4882 additions and 1775 deletions

View File

@@ -0,0 +1,96 @@
/**
* Read/write Compose Multiplatform (Android-style) strings.xml.
* The existing file is flat and machine-friendly, so we use a targeted parser
* rather than a full DOM. Handles <string> and <plurals> with XML entities and
* Android backslash escapes.
*/
export interface AndroidString {
name: string;
value: string;
}
export interface AndroidPlural {
name: string;
items: Record<string, string>; // quantity -> value
}
export interface ParsedAndroid {
strings: AndroidString[];
plurals: AndroidPlural[];
}
function decodeEntities(s: string): string {
return s
.replace(/&#x([0-9a-fA-F]+);/g, (_m, h) => String.fromCodePoint(parseInt(h, 16)))
.replace(/&#(\d+);/g, (_m, d) => String.fromCodePoint(parseInt(d, 10)))
.replaceAll("&lt;", "<")
.replaceAll("&gt;", ">")
.replaceAll("&quot;", '"')
.replaceAll("&apos;", "'")
.replaceAll("&amp;", "&");
}
/** Reverse Android's leading/trailing-quote + backslash escapes into plain text. */
function unescapeAndroid(raw: string): string {
let s = raw.trim();
if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) {
s = s.slice(1, -1);
}
s = s
.replace(/\\n/g, "\n")
.replace(/\\t/g, "\t")
.replace(/\\'/g, "'")
.replace(/\\"/g, '"')
.replace(/\\@/g, "@")
.replace(/\\\\/g, "\\");
return decodeEntities(s);
}
export function parseAndroid(xml: string): ParsedAndroid {
const strings: AndroidString[] = [];
const plurals: AndroidPlural[] = [];
const stringRe = /<string\s+name="([^"]+)"\s*>([\s\S]*?)<\/string>/g;
for (const m of xml.matchAll(stringRe)) {
strings.push({ name: m[1]!, value: unescapeAndroid(m[2]!) });
}
const pluralRe = /<plurals\s+name="([^"]+)"\s*>([\s\S]*?)<\/plurals>/g;
const itemRe = /<item\s+quantity="([^"]+)"\s*>([\s\S]*?)<\/item>/g;
for (const m of xml.matchAll(pluralRe)) {
const items: Record<string, string> = {};
for (const it of m[2]!.matchAll(itemRe)) {
items[it[1]!] = unescapeAndroid(it[2]!);
}
plurals.push({ name: m[1]!, items });
}
return { strings, plurals };
}
function escapeXmlText(s: string): string {
return s.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
}
/** Android needs apostrophes and quotes escaped, and quotes on leading/trailing whitespace. */
function escapeAndroidValue(s: string): string {
let out = escapeXmlText(s).replace(/'/g, "\\'").replace(/\n/g, "\\n");
if (out !== out.trim()) out = `"${out}"`;
return out;
}
export function renderAndroid(parsed: ParsedAndroid): string {
const lines: string[] = ['<?xml version="1.0" encoding="utf-8"?>', "<resources>"];
for (const s of parsed.strings) {
lines.push(` <string name="${s.name}">${escapeAndroidValue(s.value)}</string>`);
}
for (const p of parsed.plurals) {
lines.push(` <plurals name="${p.name}">`);
for (const [q, v] of Object.entries(p.items)) {
lines.push(` <item quantity="${q}">${escapeAndroidValue(v)}</item>`);
}
lines.push(" </plurals>");
}
lines.push("</resources>", "");
return lines.join("\n");
}

View File

@@ -0,0 +1,98 @@
/**
* Canonical placeholder handling.
*
* Canonical form (in strings.json) uses named tokens: `{count}`, `{name}`.
* Platform output uses positional printf tokens, resolved from the entry's
* ordered `args`:
* - Apple (.xcstrings): string -> %N$@ int -> %N$d double -> %N$f
* - Android (.xml): string -> %N$s int -> %N$d double -> %N$f
* A literal percent is written `%%` on both platforms whenever the string
* carries args (printf-active); canonical text stores it as a bare `%`.
*/
import type { Arg, ArgType } from "../types";
export type Flavor = "apple" | "android";
function stringToken(flavor: Flavor): string {
return flavor === "apple" ? "@" : "s";
}
function tokenForType(type: ArgType, flavor: Flavor): string {
switch (type) {
case "string":
return stringToken(flavor);
case "int":
return "d";
case "double":
return "f";
}
}
/** Render canonical `{name}` text into a platform printf string. */
export function fromCanonical(
text: string,
args: Arg[],
flavor: Flavor,
): string {
const hasArgs = args.length > 0;
const index = new Map(args.map((a, i) => [a.name, { i: i + 1, type: a.type }]));
// Escape literal % first (only meaningful when printf-active).
let out = hasArgs ? text.replaceAll("%", "%%") : text;
out = out.replace(/\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_whole, name: string) => {
const found = index.get(name);
if (!found) {
throw new Error(
`placeholder {${name}} has no matching arg (${args.map((a) => a.name).join(", ") || "none"})`,
);
}
return `%${found.i}$${tokenForType(found.type, flavor)}`;
});
return out;
}
export interface ParsedString {
/** Canonical text with `{argN}` tokens. */
text: string;
/** Args discovered, ordered by first positional index. */
args: Arg[];
}
// Matches an escaped literal `%%` or a printf specifier (positional or bare).
const CONV = /%%|%(?:(\d+)\$)?([@sdif])/g;
function typeForConv(conv: string): ArgType {
if (conv === "@" || conv === "s") return "string";
if (conv === "d" || conv === "i") return "int";
return "double"; // f
}
/**
* Parse a platform printf string back into canonical `{argN}` form, inferring
* arg names (`arg1`, `arg2`, ...) and types from the conversion specifiers.
* Handles positional (`%1$@`), bare (`%@`), and escaped literals (`%%`).
*/
export function toCanonical(raw: string): ParsedString {
const byIndex = new Map<number, ArgType>();
let auto = 0;
const text = raw.replace(CONV, (whole, pos: string | undefined, conv: string) => {
if (whole === "%%") return "%"; // literal percent, not a specifier
const idx = pos ? Number(pos) : ++auto;
const type = typeForConv(conv);
const existing = byIndex.get(idx);
if (existing && existing !== type) {
throw new Error(`arg ${idx} used with conflicting types in "${raw}"`);
}
byIndex.set(idx, type);
return `{arg${idx}}`;
});
const args: Arg[] = [...byIndex.keys()]
.sort((a, b) => a - b)
.map((idx) => ({ name: `arg${idx}`, type: byIndex.get(idx)! }));
return { text, args };
}

View File

@@ -0,0 +1,45 @@
/** Read/write Apple String Catalog (.xcstrings) — a single JSON file, all languages nested. */
export type XcState = "new" | "translated" | "needs_review" | "stale";
export interface XcStringUnit {
state: XcState;
value: string;
}
export interface XcVariations {
plural?: Record<string, { stringUnit: XcStringUnit }>;
}
export interface XcLocalization {
stringUnit?: XcStringUnit;
variations?: XcVariations;
}
export interface XcEntry {
comment?: string;
extractionState?: string;
localizations?: Record<string, XcLocalization>;
}
export interface XcCatalog {
sourceLanguage: string;
strings: Record<string, XcEntry>;
version: string;
}
export function parseXcstrings(json: string): XcCatalog {
return JSON.parse(json) as XcCatalog;
}
/** Stable, Xcode-compatible serialization: 2-space indent, keys sorted, trailing newline. */
export function renderXcstrings(cat: XcCatalog): string {
const sortDeep = (v: unknown): unknown => {
if (Array.isArray(v)) return v.map(sortDeep);
if (v && typeof v === "object") {
const out: Record<string, unknown> = {};
for (const k of Object.keys(v as Record<string, unknown>).sort()) {
out[k] = sortDeep((v as Record<string, unknown>)[k]);
}
return out;
}
return v;
};
return JSON.stringify(sortDeep(cat), null, 2) + "\n";
}