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,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";
}