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,55 @@
/** Structural checks on strings.json. Exits non-zero on errors (warnings don't fail). */
import { STRINGS_JSON } from "../config";
import type { StringsFile } from "../types";
const TOKEN = /\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
export async function validate() {
const doc = JSON.parse(await Bun.file(STRINGS_JSON).text()) as StringsFile;
const errors: string[] = [];
const warnings: string[] = [];
for (const [key, entry] of Object.entries(doc.strings)) {
const where = `"${key}"`;
const argNames = new Set((entry.args ?? []).map((a) => a.name));
if (entry.translations && entry.plural) {
errors.push(`${where}: has both "translations" and "plural" (pick one).`);
}
if (!entry.translations && !entry.plural) {
warnings.push(`${where}: no translations yet.`);
}
if (!entry.context?.trim() || entry.context.startsWith("TODO")) {
warnings.push(`${where}: missing/placeholder context.`);
}
const texts: string[] = [];
if (entry.translations) texts.push(...Object.values(entry.translations));
if (entry.plural) {
for (const [lang, forms] of Object.entries(entry.plural)) {
if (!forms.other?.trim()) errors.push(`${where} [${lang}]: plural missing required "other".`);
texts.push(...Object.values(forms));
}
}
for (const text of texts) {
for (const m of text.matchAll(TOKEN)) {
if (!argNames.has(m[1]!)) {
errors.push(`${where}: uses {${m[1]}} but no matching arg is declared.`);
}
}
}
// Missing-language coverage (warn only).
for (const lang of doc.supportedLanguages) {
const present = entry.plural ? !!entry.plural[lang] : entry.translations?.[lang] !== undefined;
const anyTranslations = entry.translations || entry.plural;
if (anyTranslations && !present) warnings.push(`${where}: missing "${lang}".`);
}
}
for (const w of warnings) console.warn(`warn ${w}`);
for (const e of errors) console.error(`error ${e}`);
console.log(`\n${Object.keys(doc.strings).length} keys — ${errors.length} error(s), ${warnings.length} warning(s).`);
if (errors.length) process.exit(1);
}