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:
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