mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 02:29:55 +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}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user