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:
96
localization/src/lib/android-xml.ts
Normal file
96
localization/src/lib/android-xml.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Read/write Compose Multiplatform (Android-style) strings.xml.
|
||||
* The existing file is flat and machine-friendly, so we use a targeted parser
|
||||
* rather than a full DOM. Handles <string> and <plurals> with XML entities and
|
||||
* Android backslash escapes.
|
||||
*/
|
||||
|
||||
export interface AndroidString {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
export interface AndroidPlural {
|
||||
name: string;
|
||||
items: Record<string, string>; // quantity -> value
|
||||
}
|
||||
|
||||
export interface ParsedAndroid {
|
||||
strings: AndroidString[];
|
||||
plurals: AndroidPlural[];
|
||||
}
|
||||
|
||||
function decodeEntities(s: string): string {
|
||||
return s
|
||||
.replace(/&#x([0-9a-fA-F]+);/g, (_m, h) => String.fromCodePoint(parseInt(h, 16)))
|
||||
.replace(/&#(\d+);/g, (_m, d) => String.fromCodePoint(parseInt(d, 10)))
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll(""", '"')
|
||||
.replaceAll("'", "'")
|
||||
.replaceAll("&", "&");
|
||||
}
|
||||
|
||||
/** Reverse Android's leading/trailing-quote + backslash escapes into plain text. */
|
||||
function unescapeAndroid(raw: string): string {
|
||||
let s = raw.trim();
|
||||
if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) {
|
||||
s = s.slice(1, -1);
|
||||
}
|
||||
s = s
|
||||
.replace(/\\n/g, "\n")
|
||||
.replace(/\\t/g, "\t")
|
||||
.replace(/\\'/g, "'")
|
||||
.replace(/\\"/g, '"')
|
||||
.replace(/\\@/g, "@")
|
||||
.replace(/\\\\/g, "\\");
|
||||
return decodeEntities(s);
|
||||
}
|
||||
|
||||
export function parseAndroid(xml: string): ParsedAndroid {
|
||||
const strings: AndroidString[] = [];
|
||||
const plurals: AndroidPlural[] = [];
|
||||
|
||||
const stringRe = /<string\s+name="([^"]+)"\s*>([\s\S]*?)<\/string>/g;
|
||||
for (const m of xml.matchAll(stringRe)) {
|
||||
strings.push({ name: m[1]!, value: unescapeAndroid(m[2]!) });
|
||||
}
|
||||
|
||||
const pluralRe = /<plurals\s+name="([^"]+)"\s*>([\s\S]*?)<\/plurals>/g;
|
||||
const itemRe = /<item\s+quantity="([^"]+)"\s*>([\s\S]*?)<\/item>/g;
|
||||
for (const m of xml.matchAll(pluralRe)) {
|
||||
const items: Record<string, string> = {};
|
||||
for (const it of m[2]!.matchAll(itemRe)) {
|
||||
items[it[1]!] = unescapeAndroid(it[2]!);
|
||||
}
|
||||
plurals.push({ name: m[1]!, items });
|
||||
}
|
||||
|
||||
return { strings, plurals };
|
||||
}
|
||||
|
||||
function escapeXmlText(s: string): string {
|
||||
return s.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
||||
}
|
||||
|
||||
/** Android needs apostrophes and quotes escaped, and quotes on leading/trailing whitespace. */
|
||||
function escapeAndroidValue(s: string): string {
|
||||
let out = escapeXmlText(s).replace(/'/g, "\\'").replace(/\n/g, "\\n");
|
||||
if (out !== out.trim()) out = `"${out}"`;
|
||||
return out;
|
||||
}
|
||||
|
||||
export function renderAndroid(parsed: ParsedAndroid): string {
|
||||
const lines: string[] = ['<?xml version="1.0" encoding="utf-8"?>', "<resources>"];
|
||||
for (const s of parsed.strings) {
|
||||
lines.push(` <string name="${s.name}">${escapeAndroidValue(s.value)}</string>`);
|
||||
}
|
||||
for (const p of parsed.plurals) {
|
||||
lines.push(` <plurals name="${p.name}">`);
|
||||
for (const [q, v] of Object.entries(p.items)) {
|
||||
lines.push(` <item quantity="${q}">${escapeAndroidValue(v)}</item>`);
|
||||
}
|
||||
lines.push(" </plurals>");
|
||||
}
|
||||
lines.push("</resources>", "");
|
||||
return lines.join("\n");
|
||||
}
|
||||
98
localization/src/lib/placeholders.ts
Normal file
98
localization/src/lib/placeholders.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Canonical placeholder handling.
|
||||
*
|
||||
* Canonical form (in strings.json) uses named tokens: `{count}`, `{name}`.
|
||||
* Platform output uses positional printf tokens, resolved from the entry's
|
||||
* ordered `args`:
|
||||
* - Apple (.xcstrings): string -> %N$@ int -> %N$d double -> %N$f
|
||||
* - Android (.xml): string -> %N$s int -> %N$d double -> %N$f
|
||||
* A literal percent is written `%%` on both platforms whenever the string
|
||||
* carries args (printf-active); canonical text stores it as a bare `%`.
|
||||
*/
|
||||
import type { Arg, ArgType } from "../types";
|
||||
|
||||
export type Flavor = "apple" | "android";
|
||||
|
||||
function stringToken(flavor: Flavor): string {
|
||||
return flavor === "apple" ? "@" : "s";
|
||||
}
|
||||
|
||||
function tokenForType(type: ArgType, flavor: Flavor): string {
|
||||
switch (type) {
|
||||
case "string":
|
||||
return stringToken(flavor);
|
||||
case "int":
|
||||
return "d";
|
||||
case "double":
|
||||
return "f";
|
||||
}
|
||||
}
|
||||
|
||||
/** Render canonical `{name}` text into a platform printf string. */
|
||||
export function fromCanonical(
|
||||
text: string,
|
||||
args: Arg[],
|
||||
flavor: Flavor,
|
||||
): string {
|
||||
const hasArgs = args.length > 0;
|
||||
const index = new Map(args.map((a, i) => [a.name, { i: i + 1, type: a.type }]));
|
||||
|
||||
// Escape literal % first (only meaningful when printf-active).
|
||||
let out = hasArgs ? text.replaceAll("%", "%%") : text;
|
||||
|
||||
out = out.replace(/\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_whole, name: string) => {
|
||||
const found = index.get(name);
|
||||
if (!found) {
|
||||
throw new Error(
|
||||
`placeholder {${name}} has no matching arg (${args.map((a) => a.name).join(", ") || "none"})`,
|
||||
);
|
||||
}
|
||||
return `%${found.i}$${tokenForType(found.type, flavor)}`;
|
||||
});
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface ParsedString {
|
||||
/** Canonical text with `{argN}` tokens. */
|
||||
text: string;
|
||||
/** Args discovered, ordered by first positional index. */
|
||||
args: Arg[];
|
||||
}
|
||||
|
||||
// Matches an escaped literal `%%` or a printf specifier (positional or bare).
|
||||
const CONV = /%%|%(?:(\d+)\$)?([@sdif])/g;
|
||||
|
||||
function typeForConv(conv: string): ArgType {
|
||||
if (conv === "@" || conv === "s") return "string";
|
||||
if (conv === "d" || conv === "i") return "int";
|
||||
return "double"; // f
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a platform printf string back into canonical `{argN}` form, inferring
|
||||
* arg names (`arg1`, `arg2`, ...) and types from the conversion specifiers.
|
||||
* Handles positional (`%1$@`), bare (`%@`), and escaped literals (`%%`).
|
||||
*/
|
||||
export function toCanonical(raw: string): ParsedString {
|
||||
const byIndex = new Map<number, ArgType>();
|
||||
let auto = 0;
|
||||
|
||||
const text = raw.replace(CONV, (whole, pos: string | undefined, conv: string) => {
|
||||
if (whole === "%%") return "%"; // literal percent, not a specifier
|
||||
const idx = pos ? Number(pos) : ++auto;
|
||||
const type = typeForConv(conv);
|
||||
const existing = byIndex.get(idx);
|
||||
if (existing && existing !== type) {
|
||||
throw new Error(`arg ${idx} used with conflicting types in "${raw}"`);
|
||||
}
|
||||
byIndex.set(idx, type);
|
||||
return `{arg${idx}}`;
|
||||
});
|
||||
|
||||
const args: Arg[] = [...byIndex.keys()]
|
||||
.sort((a, b) => a - b)
|
||||
.map((idx) => ({ name: `arg${idx}`, type: byIndex.get(idx)! }));
|
||||
|
||||
return { text, args };
|
||||
}
|
||||
45
localization/src/lib/xcstrings.ts
Normal file
45
localization/src/lib/xcstrings.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/** Read/write Apple String Catalog (.xcstrings) — a single JSON file, all languages nested. */
|
||||
|
||||
export type XcState = "new" | "translated" | "needs_review" | "stale";
|
||||
|
||||
export interface XcStringUnit {
|
||||
state: XcState;
|
||||
value: string;
|
||||
}
|
||||
export interface XcVariations {
|
||||
plural?: Record<string, { stringUnit: XcStringUnit }>;
|
||||
}
|
||||
export interface XcLocalization {
|
||||
stringUnit?: XcStringUnit;
|
||||
variations?: XcVariations;
|
||||
}
|
||||
export interface XcEntry {
|
||||
comment?: string;
|
||||
extractionState?: string;
|
||||
localizations?: Record<string, XcLocalization>;
|
||||
}
|
||||
export interface XcCatalog {
|
||||
sourceLanguage: string;
|
||||
strings: Record<string, XcEntry>;
|
||||
version: string;
|
||||
}
|
||||
|
||||
export function parseXcstrings(json: string): XcCatalog {
|
||||
return JSON.parse(json) as XcCatalog;
|
||||
}
|
||||
|
||||
/** Stable, Xcode-compatible serialization: 2-space indent, keys sorted, trailing newline. */
|
||||
export function renderXcstrings(cat: XcCatalog): string {
|
||||
const sortDeep = (v: unknown): unknown => {
|
||||
if (Array.isArray(v)) return v.map(sortDeep);
|
||||
if (v && typeof v === "object") {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const k of Object.keys(v as Record<string, unknown>).sort()) {
|
||||
out[k] = sortDeep((v as Record<string, unknown>)[k]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return v;
|
||||
};
|
||||
return JSON.stringify(sortDeep(cat), null, 2) + "\n";
|
||||
}
|
||||
Reference in New Issue
Block a user