mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 10:29:58 +02:00
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:
97
localization/src/commands/generate.ts
Normal file
97
localization/src/commands/generate.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
195
localization/src/commands/migrate.ts
Normal file
195
localization/src/commands/migrate.ts
Normal 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(", ")}`,
|
||||
);
|
||||
}
|
||||
55
localization/src/commands/validate.ts
Normal file
55
localization/src/commands/validate.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user