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,97 @@
/**
* Generate platform files from strings.json:
* - Apple: one Localizable.xcstrings (all languages nested).
* - KMP: one values[-lang]/strings.xml per supported language.
*/
import { mkdirSync } from "node:fs";
import { join } from "node:path";
import {
APPLE_XCSTRINGS,
kmpValuesDir,
KMP_RESOURCES,
STRINGS_JSON,
} from "../config";
import { renderAndroid, type ParsedAndroid } from "../lib/android-xml";
import { fromCanonical, type Flavor } from "../lib/placeholders";
import { renderXcstrings, type XcCatalog, type XcEntry } from "../lib/xcstrings";
import { targetsOf, type StringEntry, type StringsFile } from "../types";
function render(entry: StringEntry, text: string, flavor: Flavor): string {
return fromCanonical(text, entry.args ?? [], flavor);
}
// ---- Apple ------------------------------------------------------------------
function buildXcstrings(doc: StringsFile): XcCatalog {
const strings: Record<string, XcEntry> = {};
for (const [key, entry] of Object.entries(doc.strings)) {
if (!targetsOf(entry).includes("apple")) continue;
const xc: XcEntry = { extractionState: "manual" };
if (entry.context) xc.comment = entry.context;
const localizations: XcEntry["localizations"] = {};
for (const lang of doc.supportedLanguages) {
const state = lang === doc.sourceLanguage ? "translated" : "needs_review";
if (entry.plural) {
const forms = entry.plural[lang];
if (!forms) continue;
const plural: Record<string, { stringUnit: { state: any; value: string } }> = {};
for (const [cat, value] of Object.entries(forms)) {
plural[cat] = { stringUnit: { state, value: render(entry, value, "apple") } };
}
localizations[lang] = { variations: { plural } };
} else {
const value = entry.translations?.[lang];
if (value === undefined) continue;
localizations[lang] = { stringUnit: { state, value: render(entry, value, "apple") } };
}
}
if (Object.keys(localizations).length) xc.localizations = localizations;
strings[key] = xc;
}
return { sourceLanguage: doc.sourceLanguage, strings, version: "1.0" };
}
// ---- KMP --------------------------------------------------------------------
function buildAndroid(doc: StringsFile, lang: string): ParsedAndroid {
const out: ParsedAndroid = { strings: [], plurals: [] };
for (const [key, entry] of Object.entries(doc.strings)) {
if (!targetsOf(entry).includes("kmp")) continue;
if (entry.plural) {
const forms = entry.plural[lang];
if (!forms) continue;
const items: Record<string, string> = {};
for (const [cat, value] of Object.entries(forms)) {
items[cat] = render(entry, value, "android");
}
out.plurals.push({ name: key, items });
} else {
const value = entry.translations?.[lang];
if (value === undefined) continue;
out.strings.push({ name: key, value: render(entry, value, "android") });
}
}
return out;
}
// ---- entry ------------------------------------------------------------------
export async function generate() {
const doc = JSON.parse(await Bun.file(STRINGS_JSON).text()) as StringsFile;
await Bun.write(APPLE_XCSTRINGS, renderXcstrings(buildXcstrings(doc)));
console.log(`Wrote ${APPLE_XCSTRINGS}`);
for (const lang of doc.supportedLanguages) {
const dir = join(KMP_RESOURCES, kmpValuesDir(lang, doc.sourceLanguage));
mkdirSync(dir, { recursive: true });
const file = join(dir, "strings.xml");
await Bun.write(file, renderAndroid(buildAndroid(doc, lang)));
console.log(`Wrote ${file}`);
}
}

View File

@@ -0,0 +1,195 @@
/**
* One-time migration: fold the existing .xcstrings (Apple) and Compose
* strings.xml (KMP) files into a single strings.json.
*
* - Placeholders are normalized to canonical `{argN}` tokens.
* - KMP `<base>_one` / `<base>_other` key pairs are folded into one plural entry.
* - `targets` is set from which platform(s) a key was found in.
* Arg names (`arg1`, `count`, …) and `context` are best-effort; curate by hand after.
*/
import { readdirSync } from "node:fs";
import { join } from "node:path";
import { APPLE_XCSTRINGS, KMP_RESOURCES, STRINGS_JSON } from "../config";
import { parseAndroid } from "../lib/android-xml";
import { parseXcstrings, type XcLocalization } from "../lib/xcstrings";
import { toCanonical, type ParsedString } from "../lib/placeholders";
import type { Arg, PluralForms, StringEntry, StringsFile, Target } from "../types";
const PLURAL_SUFFIXES = ["zero", "one", "two", "few", "many", "other"] as const;
interface Draft {
targets: Set<Target>;
args?: Arg[];
translations?: Record<string, string>;
plural?: Record<string, PluralForms>;
comment?: string;
}
/** Give a friendlier name to a lone integer arg (common for counts). */
function prettify(parsed: ParsedString): ParsedString {
if (parsed.args.length === 1 && parsed.args[0]!.type === "int") {
return {
text: parsed.text.replaceAll("{arg1}", "{count}"),
args: [{ name: "count", type: "int" }],
};
}
return parsed;
}
function ingest(draft: Draft, lang: string, raw: string) {
const p = prettify(toCanonical(raw));
if (p.args.length) draft.args = p.args;
(draft.translations ??= {})[lang] = p.text;
}
// ---- Apple (.xcstrings) -----------------------------------------------------
function unitValue(loc: XcLocalization | undefined): string | undefined {
return loc?.stringUnit?.value;
}
async function readApple(drafts: Map<string, Draft>) {
const cat = parseXcstrings(await Bun.file(APPLE_XCSTRINGS).text());
for (const [key, entry] of Object.entries(cat.strings)) {
const draft = get(drafts, key);
draft.targets.add("apple");
if (entry.comment) draft.comment = entry.comment;
for (const [lang, loc] of Object.entries(entry.localizations ?? {})) {
const plural = loc.variations?.plural;
if (plural) {
for (const [cat, unit] of Object.entries(plural)) {
const p = prettify(toCanonical(unit.stringUnit.value));
if (p.args.length) draft.args = p.args;
((draft.plural ??= {})[lang] ??= { other: "" } as PluralForms)[
cat as keyof PluralForms
] = p.text;
}
} else {
const v = unitValue(loc);
if (v !== undefined) ingest(draft, lang, v);
}
}
}
}
// ---- KMP (compose strings.xml) ---------------------------------------------
function pluralBaseOf(name: string): { base: string; cat: string } | null {
for (const suf of PLURAL_SUFFIXES) {
if (name.endsWith(`_${suf}`)) return { base: name.slice(0, -(suf.length + 1)), cat: suf };
}
return null;
}
async function readKmp(drafts: Map<string, Draft>, sourceLanguage: string) {
const dirs = readdirSync(KMP_RESOURCES, { withFileTypes: true })
.filter((d) => d.isDirectory() && (d.name === "values" || d.name.startsWith("values-")))
.map((d) => d.name);
for (const dir of dirs) {
const lang = dir === "values" ? sourceLanguage : dir.slice("values-".length);
const file = join(KMP_RESOURCES, dir, "strings.xml");
if (!(await Bun.file(file).exists())) continue;
const { strings, plurals } = parseAndroid(await Bun.file(file).text());
for (const s of strings) {
const draft = get(drafts, s.name);
draft.targets.add("kmp");
ingest(draft, lang, s.value);
}
// Real <plurals> blocks (if any) fold directly into the base key.
for (const p of plurals) {
const draft = get(drafts, p.name);
draft.targets.add("kmp");
for (const [cat, raw] of Object.entries(p.items)) {
const parsed = prettify(toCanonical(raw));
if (parsed.args.length) draft.args = parsed.args;
((draft.plural ??= {})[lang] ??= { other: "" } as PluralForms)[
cat as keyof PluralForms
] = parsed.text;
}
}
}
}
/**
* Global fold: collapse flat `<base>_<cat>` sibling keys (e.g. `_one`/`_other`)
* into one plural entry on `<base>`, regardless of which platform they came from.
* Requires `other` plus at least one more category to avoid folding non-plurals.
*/
function foldPlurals(drafts: Map<string, Draft>) {
const groups = new Map<string, string[]>();
for (const key of drafts.keys()) {
const pl = pluralBaseOf(key);
if (pl && drafts.get(key)!.translations) {
(groups.get(pl.base) ?? groups.set(pl.base, []).get(pl.base)!).push(key);
}
}
for (const [base, keys] of groups) {
const cats = new Set(keys.map((k) => pluralBaseOf(k)!.cat));
if (!cats.has("other") || cats.size < 2) continue;
const target = get(drafts, base);
for (const key of keys) {
const cat = pluralBaseOf(key)!.cat as keyof PluralForms;
const src = drafts.get(key)!;
src.targets.forEach((t) => target.targets.add(t));
if (src.args?.length) target.args = src.args;
target.comment ??= src.comment;
for (const [lang, text] of Object.entries(src.translations ?? {})) {
((target.plural ??= {})[lang] ??= { other: "" } as PluralForms)[cat] = text;
}
drafts.delete(key);
}
}
}
// ---- assembly ---------------------------------------------------------------
function get(drafts: Map<string, Draft>, key: string): Draft {
let d = drafts.get(key);
if (!d) drafts.set(key, (d = { targets: new Set() }));
return d;
}
export async function migrate(sourceLanguage = "en") {
const drafts = new Map<string, Draft>();
await readApple(drafts);
await readKmp(drafts, sourceLanguage);
foldPlurals(drafts);
const langs = new Set<string>([sourceLanguage]);
const strings: Record<string, StringEntry> = {};
for (const key of [...drafts.keys()].sort()) {
const d = drafts.get(key)!;
const entry: StringEntry = { context: d.comment ?? "TODO: describe where this appears." };
const targets = [...d.targets].sort() as Target[];
if (targets.length && targets.length < 2) entry.targets = targets;
if (d.args?.length) entry.args = d.args;
if (d.plural) {
entry.plural = d.plural;
Object.keys(d.plural).forEach((l) => langs.add(l));
} else if (d.translations) {
entry.translations = d.translations;
Object.keys(d.translations).forEach((l) => langs.add(l));
}
strings[key] = entry;
}
const out: StringsFile = {
sourceLanguage,
supportedLanguages: [...langs].sort(),
strings,
};
await Bun.write(
STRINGS_JSON,
JSON.stringify({ $schema: "./schema.json", ...out }, null, 2) + "\n",
);
console.log(
`Wrote ${STRINGS_JSON}\n ${Object.keys(strings).length} keys, languages: ${[...langs].sort().join(", ")}`,
);
}

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);
}