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:
87
localization/README.md
Normal file
87
localization/README.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# Localization
|
||||
|
||||
Single source of truth for every user-facing string, in [`strings.json`](strings.json).
|
||||
A Bun CLI generates the platform-native files from it:
|
||||
|
||||
| Target | Output | Notes |
|
||||
| --- | --- | --- |
|
||||
| `apple` | `apple/VniDrop/Resources/Localizable.xcstrings` | one catalog, all languages nested |
|
||||
| `kmp` | `shared/src/commonMain/composeResources/values[-lang]/strings.xml` | one file per language |
|
||||
|
||||
## Workflow
|
||||
|
||||
```bash
|
||||
cd localization
|
||||
bun run src/cli.ts validate # structural checks (run before committing)
|
||||
bun run src/cli.ts generate # regenerate .xcstrings + strings.xml from strings.json
|
||||
bun run src/cli.ts migrate # one-time: rebuild strings.json from existing platform files
|
||||
```
|
||||
|
||||
**Never edit the generated `.xcstrings` / `strings.xml` by hand** — edit `strings.json` and
|
||||
regenerate. Regenerated output is deterministic (sorted keys), so diffs stay small.
|
||||
|
||||
## `strings.json` format
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"sourceLanguage": "en",
|
||||
"supportedLanguages": ["en", "fr"],
|
||||
"strings": {
|
||||
"send_title": {
|
||||
"context": "Send tab — screen title.",
|
||||
"translations": { "en": "Send", "fr": "Envoyer" }
|
||||
},
|
||||
|
||||
"send_selected_files_count": {
|
||||
"context": "Send flow — number of files chosen before creating a transfer.",
|
||||
"targets": ["kmp", "apple"],
|
||||
"args": [{ "name": "count", "type": "int" }],
|
||||
"plural": {
|
||||
"en": { "one": "{count} file selected", "other": "{count} files selected" },
|
||||
"fr": { "one": "{count} fichier sélectionné", "other": "{count} fichiers sélectionnés" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Fields
|
||||
|
||||
- **`context`** *(required)* — where the string appears and its purpose. Emitted as the
|
||||
`.xcstrings` comment and an XML comment; also the note translators see.
|
||||
- **`targets`** *(optional)* — `["kmp", "apple"]`. Omit to mean **all** targets.
|
||||
- **`args`** *(optional)* — ordered list of `{ name, type }`, `type` ∈ `string | int | double`.
|
||||
Referenced in text as `{name}`.
|
||||
- **`translations`** — flat text per language. **Mutually exclusive with `plural`.**
|
||||
- **`plural`** — per language, per [CLDR category](https://cldr.unicode.org/index/cldr-spec/plural-rules)
|
||||
(`zero`, `one`, `two`, `few`, `many`, `other`). `other` is always required.
|
||||
|
||||
### Placeholders
|
||||
|
||||
Write named tokens `{count}`, `{name}` in text. The generator converts them to the right
|
||||
positional token per platform, using the declared `type`:
|
||||
|
||||
| type | Apple | Android/KMP |
|
||||
| --- | --- | --- |
|
||||
| `string` | `%N$@` | `%N$s` |
|
||||
| `int` | `%N$d` | `%N$d` |
|
||||
| `double` | `%N$f` | `%N$f` |
|
||||
|
||||
A literal `%` in text is emitted as `%%` whenever the string has args.
|
||||
|
||||
## Adding a language
|
||||
|
||||
Add its code to `supportedLanguages`, fill in `translations` / `plural` for each key, then
|
||||
`generate`. KMP gets a new `values-<lang>/strings.xml`; Apple gets the language inside the
|
||||
single catalog. `validate` warns about any key still missing that language.
|
||||
|
||||
## Migration notes (from the initial import)
|
||||
|
||||
- Apple keys that were literal English strings (`"%@ · %@"`) were imported verbatim — rename
|
||||
them to semantic keys and update the Swift call sites.
|
||||
- Arg names default to `arg1`, `arg2`… (a lone int arg becomes `count`). Rename for clarity;
|
||||
keep the `{token}` in text in sync.
|
||||
- Folding `transfer_file_count_one` / `_other` into the plural key `transfer_file_count`
|
||||
requires switching the KMP call site from `Res.string.transfer_file_count_one` to the
|
||||
Compose plural API (`pluralStringResource(Res.plurals.transfer_file_count, count, count)`),
|
||||
and the Apple side to automatic plural inflection.
|
||||
19
localization/bun.lock
Normal file
19
localization/bun.lock
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "vnidrop-localization",
|
||||
"devDependencies": {
|
||||
"bun-types": "^1.3.14",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
||||
|
||||
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
|
||||
}
|
||||
}
|
||||
18
localization/package.json
Normal file
18
localization/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "vnidrop-localization",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Single-source localization for VniDrop: strings.json -> .xcstrings (Apple) + per-language strings.xml (KMP/Compose).",
|
||||
"bin": {
|
||||
"loc": "./src/cli.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"migrate": "bun run src/cli.ts migrate",
|
||||
"generate": "bun run src/cli.ts generate",
|
||||
"validate": "bun run src/cli.ts validate"
|
||||
},
|
||||
"devDependencies": {
|
||||
"bun-types": "^1.3.14"
|
||||
}
|
||||
}
|
||||
72
localization/schema.json
Normal file
72
localization/schema.json
Normal file
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "VniDrop localization source",
|
||||
"type": "object",
|
||||
"required": ["sourceLanguage", "supportedLanguages", "strings"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"sourceLanguage": { "type": "string", "description": "Default/base language code, e.g. \"en\"." },
|
||||
"supportedLanguages": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"minItems": 1,
|
||||
"uniqueItems": true
|
||||
},
|
||||
"strings": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "$ref": "#/definitions/entry" }
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"entry": {
|
||||
"type": "object",
|
||||
"required": ["context"],
|
||||
"additionalProperties": false,
|
||||
"oneOf": [
|
||||
{ "required": ["translations"] },
|
||||
{ "required": ["plural"] },
|
||||
{ "not": { "anyOf": [{ "required": ["translations"] }, { "required": ["plural"] }] } }
|
||||
],
|
||||
"properties": {
|
||||
"context": { "type": "string" },
|
||||
"targets": {
|
||||
"type": "array",
|
||||
"items": { "enum": ["kmp", "apple"] },
|
||||
"uniqueItems": true
|
||||
},
|
||||
"args": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["name", "type"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"name": { "type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" },
|
||||
"type": { "enum": ["string", "int", "double"] }
|
||||
}
|
||||
}
|
||||
},
|
||||
"translations": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "type": "string" }
|
||||
},
|
||||
"plural": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"required": ["other"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"zero": { "type": "string" },
|
||||
"one": { "type": "string" },
|
||||
"two": { "type": "string" },
|
||||
"few": { "type": "string" },
|
||||
"many": { "type": "string" },
|
||||
"other": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
21
localization/src/cli.ts
Normal file
21
localization/src/cli.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bun
|
||||
import { migrate } from "./commands/migrate";
|
||||
import { generate } from "./commands/generate";
|
||||
import { validate } from "./commands/validate";
|
||||
|
||||
const [cmd] = process.argv.slice(2);
|
||||
|
||||
switch (cmd) {
|
||||
case "migrate":
|
||||
await migrate();
|
||||
break;
|
||||
case "generate":
|
||||
await generate();
|
||||
break;
|
||||
case "validate":
|
||||
await validate();
|
||||
break;
|
||||
default:
|
||||
console.error(`Unknown command: ${cmd ?? "(none)"}\nUsage: loc <migrate|generate|validate>`);
|
||||
process.exit(1);
|
||||
}
|
||||
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);
|
||||
}
|
||||
24
localization/src/config.ts
Normal file
24
localization/src/config.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/** Repo root = parent of the localization/ directory. */
|
||||
export const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
export const LOC_DIR = join(REPO_ROOT, "localization");
|
||||
export const STRINGS_JSON = join(LOC_DIR, "strings.json");
|
||||
|
||||
/** Apple String Catalog — one file, all languages nested. */
|
||||
export const APPLE_XCSTRINGS = join(
|
||||
REPO_ROOT,
|
||||
"apple/VniDrop/Resources/Localizable.xcstrings",
|
||||
);
|
||||
|
||||
/** KMP / Compose Multiplatform resources root; one values[-lang]/strings.xml per language. */
|
||||
export const KMP_RESOURCES = join(
|
||||
REPO_ROOT,
|
||||
"shared/src/commonMain/composeResources",
|
||||
);
|
||||
|
||||
/** Directory holding a language's strings.xml under the compose resources root. */
|
||||
export function kmpValuesDir(lang: string, sourceLanguage: string): string {
|
||||
return lang === sourceLanguage ? "values" : `values-${lang}`;
|
||||
}
|
||||
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";
|
||||
}
|
||||
43
localization/src/types.ts
Normal file
43
localization/src/types.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/** Canonical localization data model. See ../README.md for the full spec. */
|
||||
|
||||
/** Output targets a string can be emitted to. */
|
||||
export type Target = "kmp" | "apple";
|
||||
export const ALL_TARGETS: Target[] = ["kmp", "apple"];
|
||||
|
||||
/** Argument types, used to pick the right platform placeholder token. */
|
||||
export type ArgType = "string" | "int" | "double";
|
||||
|
||||
export interface Arg {
|
||||
/** Name referenced inside translations as `{name}`. */
|
||||
name: string;
|
||||
type: ArgType;
|
||||
}
|
||||
|
||||
/** CLDR plural categories. `other` is always required when `plural` is used. */
|
||||
export type PluralCategory = "zero" | "one" | "two" | "few" | "many" | "other";
|
||||
export type PluralForms = Partial<Record<PluralCategory, string>> & {
|
||||
other: string;
|
||||
};
|
||||
|
||||
export interface StringEntry {
|
||||
/** Where the string appears / its purpose. Emitted as xcstrings comment + xml comment. */
|
||||
context: string;
|
||||
/** Defaults to all targets when omitted. */
|
||||
targets?: Target[];
|
||||
/** Ordered positional args referenced as `{name}` in translations. */
|
||||
args?: Arg[];
|
||||
/** Flat (non-plural) translations, keyed by language. Mutually exclusive with `plural`. */
|
||||
translations?: Record<string, string>;
|
||||
/** Plural translations, keyed by language then CLDR category. Mutually exclusive with `translations`. */
|
||||
plural?: Record<string, PluralForms>;
|
||||
}
|
||||
|
||||
export interface StringsFile {
|
||||
sourceLanguage: string;
|
||||
supportedLanguages: string[];
|
||||
strings: Record<string, StringEntry>;
|
||||
}
|
||||
|
||||
export function targetsOf(entry: StringEntry): Target[] {
|
||||
return entry.targets ?? ALL_TARGETS;
|
||||
}
|
||||
1741
localization/strings.json
Normal file
1741
localization/strings.json
Normal file
File diff suppressed because it is too large
Load Diff
14
localization/tsconfig.json
Normal file
14
localization/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext"],
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"types": ["bun-types"],
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"skipLibCheck": true,
|
||||
"verbatimModuleSyntax": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user