@verbaly/compiler 0.20.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -1
- package/dist/{claude-mpjdqqnY.js → claude-C-ETlqsW.js} +2 -2
- package/dist/{claude-mpjdqqnY.js.map → claude-C-ETlqsW.js.map} +1 -1
- package/dist/cli.js +159 -75
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +23 -13
- package/dist/index.js +176 -94
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@ npx verbaly extract # sync catalogs + types
|
|
|
24
24
|
npx verbaly check # exit 1 if anything is missing (CI)
|
|
25
25
|
npx verbaly extract --prune # drop orphaned keys
|
|
26
26
|
npx verbaly translate # fill missing translations via Claude (or your provider)
|
|
27
|
-
npx verbaly export #
|
|
27
|
+
npx verbaly export # translator files (XLIFF 2.0, CSV) or mobile resources (Android, iOS)
|
|
28
28
|
npx verbaly import <files> # fill catalogs back from translated XLIFF/CSV files
|
|
29
29
|
npx verbaly pseudo # generate a pseudo-locale catalog for i18n QA (en-XA)
|
|
30
30
|
npx verbaly render # pre-fill data-verbaly HTML per locale (SSG — kills the FOUC)
|
|
@@ -54,6 +54,17 @@ npx verbaly import verbaly-export/es.xlf # fill the catalog back
|
|
|
54
54
|
|
|
55
55
|
`export` writes one file per target locale with the source text alongside the current translation (`--missing` exports only the untranslated entries). `import` reads XLIFF 2.0/1.2 or CSV back and **validates every entry like `translate` does** — a translation that drops a `{param}`, a variant block or an `<em>` tag is rejected and reported, so a translator's typo can't break your UI. Existing translations are kept unless `--overwrite`; `--dry-run` previews everything.
|
|
56
56
|
|
|
57
|
+
## 📱 Mobile resources
|
|
58
|
+
|
|
59
|
+
The same catalogs can ship to a companion mobile app as drop-in native resources:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
npx verbaly export --format android-xml # verbaly-export/values-<locale>/strings.xml (drop into res/)
|
|
63
|
+
npx verbaly export --format ios-strings # verbaly-export/<locale>.lproj/Localizable.strings (drop into Xcode)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Your source locale becomes the platform default (`values/strings.xml`, `en.lproj`), and untranslated keys are skipped so the app falls back to it natively instead of showing empty text. Keys are sanitized to valid Android resource names (`hero.title` → `hero_title`; a collision fails loudly), values keep Verbaly's `{name}` syntax. Export-only by design: translations flow from your catalogs to the app.
|
|
67
|
+
|
|
57
68
|
## 📄 Static rendering (SSG)
|
|
58
69
|
|
|
59
70
|
`verbaly render` walks your built site (`dist/` by default, `--site <path>` to change) and pre-fills every `data-verbaly` element **per locale** using the real runtime — plurals, `Intl` formatting, `data-verbaly-args`, attribute translation and `data-verbaly-rich` (same whitelist, XSS-safe). The source locale is filled in place; every other locale is mirrored to `dist/<locale>/…` with `<html lang>` set. Static HTML ships already translated — **no flash of untranslated content** — and the runtime attributes stay put, so client-side locale switching keeps working.
|
|
@@ -41,11 +41,11 @@ async function loadSdk(load = () => import("@anthropic-ai/sdk")) {
|
|
|
41
41
|
try {
|
|
42
42
|
return (await load()).default;
|
|
43
43
|
} catch (error) {
|
|
44
|
-
if (isModuleNotFound(error, "@anthropic-ai/sdk")) throw new Error("[verbaly] the claude translate provider needs @anthropic-ai/sdk
|
|
44
|
+
if (isModuleNotFound(error, "@anthropic-ai/sdk")) throw new Error("[verbaly] the claude translate provider needs @anthropic-ai/sdk: install it as a dev dependency (e.g. `pnpm add -D @anthropic-ai/sdk` / `npm i -D @anthropic-ai/sdk`) and set ANTHROPIC_API_KEY", { cause: error });
|
|
45
45
|
throw error;
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
48
|
//#endregion
|
|
49
49
|
export { claudeProvider };
|
|
50
50
|
|
|
51
|
-
//# sourceMappingURL=claude-
|
|
51
|
+
//# sourceMappingURL=claude-C-ETlqsW.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"claude-
|
|
1
|
+
{"version":3,"file":"claude-C-ETlqsW.js","names":[],"sources":["../src/providers/claude.ts"],"sourcesContent":["import { isModuleNotFound } from '../config';\nimport type { TranslateProvider, TranslateRequest } from '../translate';\n\nexport interface ClaudeProviderOptions {\n model?: string;\n apiKey?: string;\n maxTokens?: number;\n}\n\n// balanced default for translation\nconst DEFAULT_MODEL = 'claude-sonnet-5';\n\nconst SYSTEM = `You translate UI strings for the Verbaly i18n library.\nRules:\n- Translate only the human-readable text, naturally for the target locale.\n- Preserve verbatim: placeholders like {name}, format specs like {price:currency/EUR}, variant blocks like {count | one: ... | other: # ...} (translate only the text inside each variant, keep keys and # as-is), ICU syntax, named tags like <em>...</em> and escapes {{ }} || ##.\n- Keys are opaque identifiers: return exactly the same keys, never translate or rename them.`;\n\nexport function claudeProvider(options: ClaudeProviderOptions = {}): TranslateProvider {\n return async (request: TranslateRequest) => {\n const Anthropic = await loadSdk();\n const client = new Anthropic(options.apiKey ? { apiKey: options.apiKey } : {});\n const response = await client.messages.create({\n model: options.model ?? DEFAULT_MODEL,\n max_tokens: options.maxTokens ?? 16000,\n thinking: { type: 'disabled' },\n system: SYSTEM,\n messages: [{ role: 'user', content: buildPrompt(request) }],\n output_config: { format: batchFormat(request) },\n });\n const text = response.content.find((block) => block.type === 'text')?.text ?? '{}';\n return JSON.parse(text) as Record<string, string>;\n };\n}\n\nexport function buildPrompt(request: TranslateRequest): string {\n return (\n `Translate each value from \"${request.sourceLocale}\" to \"${request.targetLocale}\". ` +\n `Return a JSON object with the same keys and translated values.\\n\\n` +\n JSON.stringify(request.messages, null, 2)\n );\n}\n\nexport function batchFormat(request: TranslateRequest) {\n const keys = Object.keys(request.messages);\n return {\n type: 'json_schema' as const,\n schema: {\n type: 'object',\n properties: Object.fromEntries(keys.map((key) => [key, { type: 'string' }])),\n required: keys,\n additionalProperties: false,\n },\n };\n}\n\ntype SdkModule = { default: typeof import('@anthropic-ai/sdk').default };\n\n// injectable for tests\nexport async function loadSdk(\n load: () => Promise<SdkModule> = () => import('@anthropic-ai/sdk'),\n): Promise<SdkModule['default']> {\n try {\n const mod = await load();\n return mod.default;\n } catch (error) {\n if (isModuleNotFound(error, '@anthropic-ai/sdk')) {\n throw new Error(\n '[verbaly] the claude translate provider needs @anthropic-ai/sdk: install it as a dev dependency (e.g. `pnpm add -D @anthropic-ai/sdk` / `npm i -D @anthropic-ai/sdk`) and set ANTHROPIC_API_KEY',\n { cause: error },\n );\n }\n throw error;\n }\n}\n\n"],"mappings":";;AAUA,MAAM,gBAAgB;AAEtB,MAAM,SAAS;;;;;AAMf,SAAgB,eAAe,UAAiC,CAAC,GAAsB;CACrF,OAAO,OAAO,YAA8B;EAW1C,MAAM,QAAO,MARU,KADJ,OADK,QAAQ,IACH,QAAQ,SAAS,EAAE,QAAQ,QAAQ,OAAO,IAAI,CAAC,CAChD,CAAC,CAAC,SAAS,OAAO;GAC5C,OAAO,QAAQ,SAAS;GACxB,YAAY,QAAQ,aAAa;GACjC,UAAU,EAAE,MAAM,WAAW;GAC7B,QAAQ;GACR,UAAU,CAAC;IAAE,MAAM;IAAQ,SAAS,YAAY,OAAO;GAAE,CAAC;GAC1D,eAAe,EAAE,QAAQ,YAAY,OAAO,EAAE;EAChD,CAAC,EAAA,CACqB,QAAQ,MAAM,UAAU,MAAM,SAAS,MAAM,CAAC,EAAE,QAAQ;EAC9E,OAAO,KAAK,MAAM,IAAI;CACxB;AACF;AAEA,SAAgB,YAAY,SAAmC;CAC7D,OACE,8BAA8B,QAAQ,aAAa,QAAQ,QAAQ,aAAa,yEAEhF,KAAK,UAAU,QAAQ,UAAU,MAAM,CAAC;AAE5C;AAEA,SAAgB,YAAY,SAA2B;CACrD,MAAM,OAAO,OAAO,KAAK,QAAQ,QAAQ;CACzC,OAAO;EACL,MAAM;EACN,QAAQ;GACN,MAAM;GACN,YAAY,OAAO,YAAY,KAAK,KAAK,QAAQ,CAAC,KAAK,EAAE,MAAM,SAAS,CAAC,CAAC,CAAC;GAC3E,UAAU;GACV,sBAAsB;EACxB;CACF;AACF;AAKA,eAAsB,QACpB,aAAuC,OAAO,sBACf;CAC/B,IAAI;EAEF,QAAO,MADW,KAAK,EAAA,CACZ;CACb,SAAS,OAAO;EACd,IAAI,iBAAiB,OAAO,mBAAmB,GAC7C,MAAM,IAAI,MACR,mMACA,EAAE,OAAO,MAAM,CACjB;EAEF,MAAM;CACR;AACF"}
|
package/dist/cli.js
CHANGED
|
@@ -13,11 +13,17 @@ function catalogPath(cfg, locale) {
|
|
|
13
13
|
return join(cfg.dir, `${locale}.json`);
|
|
14
14
|
}
|
|
15
15
|
function readCatalog(cfg, locale) {
|
|
16
|
+
let content;
|
|
16
17
|
try {
|
|
17
|
-
|
|
18
|
+
content = readFileSync(catalogPath(cfg, locale), "utf8");
|
|
18
19
|
} catch {
|
|
19
20
|
return {};
|
|
20
21
|
}
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(content.replace(/^\uFEFF/, ""));
|
|
24
|
+
} catch (error) {
|
|
25
|
+
throw new Error(`[verbaly] ${catalogPath(cfg, locale)} is not valid JSON, fix or delete the file`, { cause: error });
|
|
26
|
+
}
|
|
21
27
|
}
|
|
22
28
|
function loadCatalogs(cfg) {
|
|
23
29
|
const catalogs = {};
|
|
@@ -74,13 +80,13 @@ function formatCheckResult(result) {
|
|
|
74
80
|
if (result.missing.length > 0) {
|
|
75
81
|
lines.push("missing translations:");
|
|
76
82
|
for (const entry of result.missing) {
|
|
77
|
-
const hint = entry.source ?
|
|
83
|
+
const hint = entry.source ? `: "${truncate(entry.source, 40)}"` : "";
|
|
78
84
|
lines.push(` [${entry.locale}] ${entry.key}${hint}`);
|
|
79
85
|
}
|
|
80
86
|
}
|
|
81
87
|
if (result.unknown.length > 0) {
|
|
82
88
|
lines.push("unknown keys (not in any catalog):");
|
|
83
|
-
for (const entry of result.unknown) lines.push(` ${entry.key}
|
|
89
|
+
for (const entry of result.unknown) lines.push(` ${entry.key} (used in ${entry.files.join(", ")})`);
|
|
84
90
|
}
|
|
85
91
|
return lines.join("\n");
|
|
86
92
|
}
|
|
@@ -127,17 +133,21 @@ function visit(nodes, out) {
|
|
|
127
133
|
}
|
|
128
134
|
function renderParamType(types) {
|
|
129
135
|
if (types.has("unknown") || types.size === 0) return "unknown";
|
|
130
|
-
const
|
|
131
|
-
if (types.has("number"))
|
|
132
|
-
if (types.has("string"))
|
|
133
|
-
if (types.has("date"))
|
|
134
|
-
|
|
136
|
+
const members = /* @__PURE__ */ new Set();
|
|
137
|
+
if (types.has("number")) members.add("number");
|
|
138
|
+
if (types.has("string")) members.add("string");
|
|
139
|
+
if (types.has("date")) for (const m of [
|
|
140
|
+
"Date",
|
|
141
|
+
"number",
|
|
142
|
+
"string"
|
|
143
|
+
]) members.add(m);
|
|
144
|
+
return [...members].join(" | ");
|
|
135
145
|
}
|
|
136
146
|
//#endregion
|
|
137
147
|
//#region src/codegen.ts
|
|
138
|
-
function generateDts(
|
|
148
|
+
function generateDts(catalog) {
|
|
139
149
|
const lines = [];
|
|
140
|
-
for (const [key, message] of
|
|
150
|
+
for (const [key, message] of Object.entries(catalog).sort(([a], [b]) => a.localeCompare(b))) {
|
|
141
151
|
const params = collectParams(message);
|
|
142
152
|
if (params.size === 0) lines.push(` ${JSON.stringify(key)}: never;`);
|
|
143
153
|
else {
|
|
@@ -186,8 +196,13 @@ declare module 'virtual:verbaly/locale/*' {
|
|
|
186
196
|
}
|
|
187
197
|
`;
|
|
188
198
|
}
|
|
189
|
-
function writeDts(cfg,
|
|
190
|
-
|
|
199
|
+
function writeDts(cfg, catalog) {
|
|
200
|
+
const file = join(cfg.root, "verbaly.d.ts");
|
|
201
|
+
const content = generateDts(catalog);
|
|
202
|
+
try {
|
|
203
|
+
if (readFileSync(file, "utf8") === content) return;
|
|
204
|
+
} catch {}
|
|
205
|
+
writeFileSync(file, content);
|
|
191
206
|
}
|
|
192
207
|
//#endregion
|
|
193
208
|
//#region src/pseudo.ts
|
|
@@ -358,7 +373,7 @@ async function loadTsConfig(path) {
|
|
|
358
373
|
const { mod } = await bundleRequire({ filepath: path });
|
|
359
374
|
return mod.default ?? {};
|
|
360
375
|
} catch (error) {
|
|
361
|
-
if (isModuleNotFound(error, "esbuild")) throw new Error(`[verbaly] ${path} needs esbuild
|
|
376
|
+
if (isModuleNotFound(error, "esbuild")) throw new Error(`[verbaly] ${path} needs esbuild: install it with pnpm add -D esbuild`, { cause: error });
|
|
362
377
|
throw error;
|
|
363
378
|
}
|
|
364
379
|
}
|
|
@@ -393,7 +408,7 @@ var MessageRegistry = class {
|
|
|
393
408
|
for (const analysis of this.files.values()) for (const msg of analysis.tagged) {
|
|
394
409
|
const existing = out.get(msg.key);
|
|
395
410
|
if (existing) {
|
|
396
|
-
if (existing.message !== msg.message) console.warn(`[verbaly] key collision "${msg.key}": ${JSON.stringify(existing.message)} vs ${JSON.stringify(msg.message)}
|
|
411
|
+
if (existing.message !== msg.message) console.warn(`[verbaly] key collision "${msg.key}": ${JSON.stringify(existing.message)} vs ${JSON.stringify(msg.message)}, second dropped.`);
|
|
397
412
|
continue;
|
|
398
413
|
}
|
|
399
414
|
out.set(msg.key, msg);
|
|
@@ -474,6 +489,20 @@ function handleTrans(code, node, file, tagged, usedKeys, names) {
|
|
|
474
489
|
const attrs = opening.attributes;
|
|
475
490
|
const idAttr = attrs.find((a) => a.type === "JSXAttribute" && a.name.name === "id");
|
|
476
491
|
const children = node.children;
|
|
492
|
+
const push = (key, built) => tagged.push({
|
|
493
|
+
key,
|
|
494
|
+
message: built.text,
|
|
495
|
+
params: built.params,
|
|
496
|
+
start: node.start,
|
|
497
|
+
end: node.end,
|
|
498
|
+
tagStart: nameNode.start,
|
|
499
|
+
tagEnd: nameNode.end,
|
|
500
|
+
file,
|
|
501
|
+
jsx: {
|
|
502
|
+
name: nameNode.name,
|
|
503
|
+
components: built.components
|
|
504
|
+
}
|
|
505
|
+
});
|
|
477
506
|
if (idAttr) {
|
|
478
507
|
const value = idAttr.value;
|
|
479
508
|
if (value?.type !== "StringLiteral") return;
|
|
@@ -481,20 +510,7 @@ function handleTrans(code, node, file, tagged, usedKeys, names) {
|
|
|
481
510
|
if (attrs.length === 1 && children?.length) {
|
|
482
511
|
const built = buildTransMessage(code, children, names);
|
|
483
512
|
if (built?.text.trim()) {
|
|
484
|
-
|
|
485
|
-
key: id,
|
|
486
|
-
message: built.text,
|
|
487
|
-
params: built.params,
|
|
488
|
-
start: node.start,
|
|
489
|
-
end: node.end,
|
|
490
|
-
tagStart: nameNode.start,
|
|
491
|
-
tagEnd: nameNode.end,
|
|
492
|
-
file,
|
|
493
|
-
jsx: {
|
|
494
|
-
name: nameNode.name,
|
|
495
|
-
components: built.components
|
|
496
|
-
}
|
|
497
|
-
});
|
|
513
|
+
push(id, built);
|
|
498
514
|
return;
|
|
499
515
|
}
|
|
500
516
|
}
|
|
@@ -508,20 +524,7 @@ function handleTrans(code, node, file, tagged, usedKeys, names) {
|
|
|
508
524
|
if (!children?.length) return;
|
|
509
525
|
const built = buildTransMessage(code, children, names);
|
|
510
526
|
if (!built || !built.text.trim()) return;
|
|
511
|
-
|
|
512
|
-
key: stableKey(built.text),
|
|
513
|
-
message: built.text,
|
|
514
|
-
params: built.params,
|
|
515
|
-
start: node.start,
|
|
516
|
-
end: node.end,
|
|
517
|
-
tagStart: nameNode.start,
|
|
518
|
-
tagEnd: nameNode.end,
|
|
519
|
-
file,
|
|
520
|
-
jsx: {
|
|
521
|
-
name: nameNode.name,
|
|
522
|
-
components: built.components
|
|
523
|
-
}
|
|
524
|
-
});
|
|
527
|
+
push(stableKey(built.text), built);
|
|
525
528
|
}
|
|
526
529
|
function explicitId(tag, names) {
|
|
527
530
|
if (tag.type !== "CallExpression") return void 0;
|
|
@@ -677,8 +680,9 @@ function uniqueName(base, source, taken) {
|
|
|
677
680
|
}
|
|
678
681
|
function walk(node, visit) {
|
|
679
682
|
visit(node);
|
|
680
|
-
for (const
|
|
683
|
+
for (const key in node) {
|
|
681
684
|
if (SKIP_KEYS.has(key)) continue;
|
|
685
|
+
const value = node[key];
|
|
682
686
|
if (Array.isArray(value)) {
|
|
683
687
|
for (const item of value) if (isNode(item)) walk(item, visit);
|
|
684
688
|
} else if (isNode(value)) walk(value, visit);
|
|
@@ -907,27 +911,27 @@ function init(options = {}) {
|
|
|
907
911
|
const PREVIEW = 5;
|
|
908
912
|
async function doctor(cfg) {
|
|
909
913
|
const entries = [];
|
|
910
|
-
const ok = (
|
|
914
|
+
const ok = (name, message) => entries.push({
|
|
911
915
|
level: "ok",
|
|
912
|
-
check,
|
|
916
|
+
check: name,
|
|
913
917
|
message
|
|
914
918
|
});
|
|
915
|
-
const warn = (
|
|
919
|
+
const warn = (name, message, fix) => entries.push({
|
|
916
920
|
level: "warn",
|
|
917
|
-
check,
|
|
921
|
+
check: name,
|
|
918
922
|
message,
|
|
919
923
|
fix
|
|
920
924
|
});
|
|
921
|
-
const error = (
|
|
925
|
+
const error = (name, message, fix) => entries.push({
|
|
922
926
|
level: "error",
|
|
923
|
-
check,
|
|
927
|
+
check: name,
|
|
924
928
|
message,
|
|
925
929
|
fix
|
|
926
930
|
});
|
|
927
931
|
const rel = (path) => relative(cfg.root, path).replaceAll("\\", "/");
|
|
928
932
|
const configFile = findConfigFile(cfg.root);
|
|
929
933
|
if (configFile) ok("config", `${configFile} found`);
|
|
930
|
-
else warn("config", "no config file
|
|
934
|
+
else warn("config", "no config file, running on defaults", "run `npx verbaly init`");
|
|
931
935
|
const catalogs = {};
|
|
932
936
|
let catalogsHealthy = true;
|
|
933
937
|
if (!existsSync(cfg.dir)) {
|
|
@@ -960,14 +964,14 @@ async function doctor(cfg) {
|
|
|
960
964
|
const deps = readDeps(cfg.root);
|
|
961
965
|
const bundler = detectBundler(cfg.root);
|
|
962
966
|
const wired = deps["@verbaly/vite"] || deps["@verbaly/unplugin"];
|
|
963
|
-
if (!bundler) ok("plugin", "no bundler detected
|
|
967
|
+
if (!bundler) ok("plugin", "no bundler detected, the CLI flow (extract/check) applies");
|
|
964
968
|
else if (wired) ok("plugin", `${deps["@verbaly/vite"] ? "@verbaly/vite" : "@verbaly/unplugin"} installed for ${bundler}`);
|
|
965
969
|
else if (bundler === "vite") warn("plugin", "vite detected but @verbaly/vite is not installed", "pnpm add -D @verbaly/vite and add verbaly() to the plugins in vite.config");
|
|
966
970
|
else warn("plugin", `${bundler} detected but @verbaly/unplugin is not installed`, `pnpm add -D @verbaly/unplugin and add the verbaly ${bundler} plugin to your build config`);
|
|
967
971
|
if (source) {
|
|
968
972
|
const dtsPath = join(cfg.root, "verbaly.d.ts");
|
|
969
973
|
if (!existsSync(dtsPath)) warn("types", "verbaly.d.ts has not been generated", "run `npx verbaly extract`");
|
|
970
|
-
else if (readFileSync(dtsPath, "utf8") !== generateDts(
|
|
974
|
+
else if (readFileSync(dtsPath, "utf8") !== generateDts(source)) warn("types", "verbaly.d.ts is stale", "run `npx verbaly extract`");
|
|
971
975
|
else ok("types", "verbaly.d.ts is up to date");
|
|
972
976
|
}
|
|
973
977
|
const registry = await extractProject(cfg);
|
|
@@ -980,10 +984,10 @@ async function doctor(cfg) {
|
|
|
980
984
|
}
|
|
981
985
|
if (catalogsHealthy) {
|
|
982
986
|
const result = check(cfg, catalogs, registry);
|
|
983
|
-
if (result.unknown.length > 0) error("keys", `${result.unknown.length} unknown ${result.unknown.length === 1 ? "key" : "keys"} used in code (${preview(result.unknown.map((u) => u.key))})`, "fix the key or add it to the catalogs
|
|
987
|
+
if (result.unknown.length > 0) error("keys", `${result.unknown.length} unknown ${result.unknown.length === 1 ? "key" : "keys"} used in code (${preview(result.unknown.map((u) => u.key))})`, "fix the key or add it to the catalogs (`npx verbaly check` for details)");
|
|
984
988
|
if (result.missing.length > 0) {
|
|
985
989
|
const locales = [...new Set(result.missing.map((m) => m.locale))];
|
|
986
|
-
warn("translations", `${result.missing.length} missing ${result.missing.length === 1 ? "translation" : "translations"} (${locales.join(", ")})`, "run `npx verbaly translate` or fill the catalogs
|
|
990
|
+
warn("translations", `${result.missing.length} missing ${result.missing.length === 1 ? "translation" : "translations"} (${locales.join(", ")})`, "run `npx verbaly translate` or fill the catalogs (`npx verbaly check` for details)");
|
|
987
991
|
}
|
|
988
992
|
if (result.ok) ok("translations", "all translations complete");
|
|
989
993
|
}
|
|
@@ -1008,6 +1012,45 @@ function readDeps(root) {
|
|
|
1008
1012
|
}
|
|
1009
1013
|
}
|
|
1010
1014
|
//#endregion
|
|
1015
|
+
//#region src/mobile.ts
|
|
1016
|
+
function androidValuesDir(locale, sourceLocale) {
|
|
1017
|
+
if (locale === sourceLocale) return "values";
|
|
1018
|
+
const parts = locale.split("-");
|
|
1019
|
+
if (parts.length === 1) return `values-${locale}`;
|
|
1020
|
+
if (parts.length === 2 && /^[a-zA-Z]{2}$/.test(parts[1])) return `values-${parts[0]}-r${parts[1].toUpperCase()}`;
|
|
1021
|
+
return `values-b+${parts.join("+")}`;
|
|
1022
|
+
}
|
|
1023
|
+
function toAndroidXml(entries) {
|
|
1024
|
+
const names = /* @__PURE__ */ new Map();
|
|
1025
|
+
return [
|
|
1026
|
+
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
|
|
1027
|
+
"<resources>",
|
|
1028
|
+
...entries.map(({ key, text }) => {
|
|
1029
|
+
const name = androidName(key);
|
|
1030
|
+
const clash = names.get(name);
|
|
1031
|
+
if (clash !== void 0) throw new Error(`[verbaly] android-xml: keys "${clash}" and "${key}" both become resource name "${name}", rename one of them.`);
|
|
1032
|
+
names.set(name, key);
|
|
1033
|
+
return ` <string name="${name}">${androidText(text)}</string>`;
|
|
1034
|
+
}),
|
|
1035
|
+
"</resources>",
|
|
1036
|
+
""
|
|
1037
|
+
].join("\n");
|
|
1038
|
+
}
|
|
1039
|
+
function toIosStrings(entries) {
|
|
1040
|
+
return [...entries.map(({ key, text }) => `"${iosText(key)}" = "${iosText(text)}";`), ""].join("\n");
|
|
1041
|
+
}
|
|
1042
|
+
function androidName(key) {
|
|
1043
|
+
const name = key.replace(/[^A-Za-z0-9_]/g, "_");
|
|
1044
|
+
return /^[0-9]/.test(name) ? `_${name}` : name;
|
|
1045
|
+
}
|
|
1046
|
+
function androidText(text) {
|
|
1047
|
+
const escaped = text.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/"/g, "\\\"").replace(/\n/g, "\\n").replace(/\t/g, "\\t").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
1048
|
+
return /^[@?]/.test(escaped) ? `\\${escaped}` : escaped;
|
|
1049
|
+
}
|
|
1050
|
+
function iosText(text) {
|
|
1051
|
+
return text.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n/g, "\\n").replace(/\t/g, "\\t");
|
|
1052
|
+
}
|
|
1053
|
+
//#endregion
|
|
1011
1054
|
//#region src/translate.ts
|
|
1012
1055
|
async function translateCatalogs(cfg, catalogs, provider, options = {}) {
|
|
1013
1056
|
const batchSize = options.batchSize ?? 20;
|
|
@@ -1066,28 +1109,58 @@ function sameMembers(a, b) {
|
|
|
1066
1109
|
}
|
|
1067
1110
|
//#endregion
|
|
1068
1111
|
//#region src/exchange.ts
|
|
1112
|
+
function isMobileFormat(format) {
|
|
1113
|
+
return format === "android-xml" || format === "ios-strings";
|
|
1114
|
+
}
|
|
1069
1115
|
function exportCatalogs(cfg, catalogs, options = {}) {
|
|
1070
1116
|
const format = options.format ?? "xliff";
|
|
1071
1117
|
const dir = resolve(cfg.root, options.out ?? "verbaly-export");
|
|
1072
1118
|
const source = catalogs[cfg.sourceLocale] ?? {};
|
|
1073
1119
|
const targets = targetLocales(cfg, options.locales);
|
|
1120
|
+
if (isMobileFormat(format)) return exportMobile(cfg, catalogs, format, dir, source, targets);
|
|
1074
1121
|
const files = [];
|
|
1075
1122
|
mkdirSync(dir, { recursive: true });
|
|
1076
1123
|
for (const locale of targets) {
|
|
1077
1124
|
const catalog = catalogs[locale] ?? {};
|
|
1078
|
-
|
|
1125
|
+
const all = Object.keys(source).filter((key) => source[key]).sort().map((key) => ({
|
|
1079
1126
|
key,
|
|
1080
1127
|
source: source[key],
|
|
1081
1128
|
target: catalog[key] ?? ""
|
|
1082
1129
|
}));
|
|
1083
|
-
|
|
1130
|
+
const untranslated = all.filter((entry) => !entry.target);
|
|
1131
|
+
const entries = options.missing ? untranslated : all;
|
|
1084
1132
|
const path = join(dir, `${locale}.${format === "csv" ? "csv" : "xlf"}`);
|
|
1085
1133
|
writeFileSync(path, format === "csv" ? toCsv(entries) : toXliff(cfg.sourceLocale, locale, entries));
|
|
1086
1134
|
files.push({
|
|
1087
1135
|
locale,
|
|
1088
1136
|
path,
|
|
1089
1137
|
total: entries.length,
|
|
1090
|
-
untranslated:
|
|
1138
|
+
untranslated: untranslated.length
|
|
1139
|
+
});
|
|
1140
|
+
}
|
|
1141
|
+
return {
|
|
1142
|
+
format,
|
|
1143
|
+
dir,
|
|
1144
|
+
files
|
|
1145
|
+
};
|
|
1146
|
+
}
|
|
1147
|
+
function exportMobile(cfg, catalogs, format, dir, source, targets) {
|
|
1148
|
+
const keys = Object.keys(source).filter((key) => source[key]).sort();
|
|
1149
|
+
const files = [];
|
|
1150
|
+
for (const locale of [cfg.sourceLocale, ...targets]) {
|
|
1151
|
+
const catalog = locale === cfg.sourceLocale ? source : catalogs[locale] ?? {};
|
|
1152
|
+
const entries = keys.map((key) => ({
|
|
1153
|
+
key,
|
|
1154
|
+
text: catalog[key] ?? ""
|
|
1155
|
+
})).filter((entry) => entry.text);
|
|
1156
|
+
const path = join(dir, format === "android-xml" ? join(androidValuesDir(locale, cfg.sourceLocale), "strings.xml") : join(`${locale}.lproj`, "Localizable.strings"));
|
|
1157
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
1158
|
+
writeFileSync(path, format === "android-xml" ? toAndroidXml(entries) : toIosStrings(entries));
|
|
1159
|
+
files.push({
|
|
1160
|
+
locale,
|
|
1161
|
+
path,
|
|
1162
|
+
total: entries.length,
|
|
1163
|
+
untranslated: keys.length - entries.length
|
|
1091
1164
|
});
|
|
1092
1165
|
}
|
|
1093
1166
|
return {
|
|
@@ -1107,8 +1180,8 @@ function importCatalogs(cfg, catalogs, files, options = {}) {
|
|
|
1107
1180
|
for (const file of files) {
|
|
1108
1181
|
const parsed = parseExchangeFile(file, options.locale);
|
|
1109
1182
|
const locale = parsed.locale;
|
|
1110
|
-
if (!/^[a-zA-Z]{2,3}([-_][a-zA-Z0-9]+)*$/.test(locale)) throw new Error(`[verbaly] ${file}: "${locale}" doesn't look like a locale
|
|
1111
|
-
if (locale === cfg.sourceLocale) throw new Error(`[verbaly] ${file} targets the source locale "${locale}"
|
|
1183
|
+
if (!/^[a-zA-Z]{2,3}([-_][a-zA-Z0-9]+)*$/.test(locale)) throw new Error(`[verbaly] ${file}: "${locale}" doesn't look like a locale, pass --locale <id>.`);
|
|
1184
|
+
if (locale === cfg.sourceLocale) throw new Error(`[verbaly] ${file} targets the source locale "${locale}": import fills translations, not the source. Pass --locale if the detection is wrong.`);
|
|
1112
1185
|
const catalog = catalogs[locale] ??= {};
|
|
1113
1186
|
for (const [key, text] of Object.entries(parsed.entries)) {
|
|
1114
1187
|
if (!text.trim()) continue;
|
|
@@ -1135,7 +1208,7 @@ function parseExchangeFile(file, localeOverride) {
|
|
|
1135
1208
|
if (/\.(xlf|xliff)$/i.test(file)) {
|
|
1136
1209
|
const parsed = parseXliff(content);
|
|
1137
1210
|
const locale = localeOverride ?? parsed.locale;
|
|
1138
|
-
if (!locale) throw new Error(`[verbaly] ${file} has no trgLang/target-language
|
|
1211
|
+
if (!locale) throw new Error(`[verbaly] ${file} has no trgLang/target-language, pass --locale <id>.`);
|
|
1139
1212
|
return {
|
|
1140
1213
|
locale,
|
|
1141
1214
|
entries: parsed.entries
|
|
@@ -1145,7 +1218,7 @@ function parseExchangeFile(file, localeOverride) {
|
|
|
1145
1218
|
locale: localeOverride ?? basename(file).replace(/\.csv$/i, ""),
|
|
1146
1219
|
entries: parseCsv(content)
|
|
1147
1220
|
};
|
|
1148
|
-
throw new Error(`[verbaly] ${file}: unsupported format
|
|
1221
|
+
throw new Error(`[verbaly] ${file}: unsupported format, expected .xlf, .xliff or .csv.`);
|
|
1149
1222
|
}
|
|
1150
1223
|
function toXliff(sourceLocale, locale, entries) {
|
|
1151
1224
|
const units = entries.map(({ key, source, target }) => [
|
|
@@ -1520,7 +1593,7 @@ function decodeEntities(text) {
|
|
|
1520
1593
|
}
|
|
1521
1594
|
//#endregion
|
|
1522
1595
|
//#region src/run.ts
|
|
1523
|
-
const HELP = `verbaly
|
|
1596
|
+
const HELP = `verbaly · i18n compiler
|
|
1524
1597
|
|
|
1525
1598
|
Usage:
|
|
1526
1599
|
verbaly init scaffold config + locale catalogs (detects your bundler)
|
|
@@ -1528,7 +1601,7 @@ Usage:
|
|
|
1528
1601
|
verbaly extract scan sources, update catalogs and types
|
|
1529
1602
|
verbaly check verify translations are complete (CI)
|
|
1530
1603
|
verbaly translate fill missing translations via a provider (default: claude)
|
|
1531
|
-
verbaly export write translator
|
|
1604
|
+
verbaly export write translator files (XLIFF 2.0, CSV) or mobile resources (Android, iOS)
|
|
1532
1605
|
verbaly import <files…> fill catalogs back from translated XLIFF/CSV files
|
|
1533
1606
|
verbaly pseudo generate a pseudo-locale catalog for i18n QA (default: en-XA)
|
|
1534
1607
|
verbaly render pre-fill data-verbaly HTML per locale (SSG, kills the FOUC)
|
|
@@ -1541,14 +1614,14 @@ Options:
|
|
|
1541
1614
|
--prune drop keys no longer referenced (extract)
|
|
1542
1615
|
--model <id> model override for the claude provider (translate)
|
|
1543
1616
|
--dry-run list what would happen, write nothing (translate, import, extract)
|
|
1544
|
-
--format <f> export format: xliff (default) or
|
|
1617
|
+
--format <f> export format: xliff (default), csv, android-xml or ios-strings (export)
|
|
1545
1618
|
--out <path> export directory (export, default: verbaly-export)
|
|
1546
1619
|
--missing export only untranslated entries (export)
|
|
1547
1620
|
--overwrite replace existing translations on import (import)
|
|
1548
1621
|
--locale <id> pseudo-locale id (pseudo) / target-locale override (import)
|
|
1549
1622
|
--site <path> built site directory (render, default: dist)
|
|
1550
1623
|
--attribute <name> base data attribute (render, default: data-verbaly)
|
|
1551
|
-
--base-url <url> site origin
|
|
1624
|
+
--base-url <url> site origin, enables hreflang alternates (render)
|
|
1552
1625
|
--sitemap emit sitemap-i18n.xml with per-locale alternates (render)
|
|
1553
1626
|
--clean remove existing locale dirs before mirroring (render)
|
|
1554
1627
|
|
|
@@ -1615,7 +1688,7 @@ async function runCli(args = process.argv.slice(2)) {
|
|
|
1615
1688
|
warn: "⚠",
|
|
1616
1689
|
error: "✗"
|
|
1617
1690
|
};
|
|
1618
|
-
console.log(`[verbaly] doctor
|
|
1691
|
+
console.log(`[verbaly] doctor: ${result.entries.length} checks`);
|
|
1619
1692
|
for (const entry of result.entries) {
|
|
1620
1693
|
const line = ` ${icon[entry.level]} ${entry.check}: ${entry.message}`;
|
|
1621
1694
|
if (entry.level === "error") console.error(line);
|
|
@@ -1636,12 +1709,12 @@ async function runCli(args = process.argv.slice(2)) {
|
|
|
1636
1709
|
const catalogs = loadCatalogs(cfg);
|
|
1637
1710
|
if (values.prune) {
|
|
1638
1711
|
const removed = pruneCatalogs(cfg, catalogs, registry);
|
|
1639
|
-
for (const [locale, keys] of Object.entries(removed)) console.log(dryRun ? ` ${locale}: would prune ${keys.length}
|
|
1712
|
+
for (const [locale, keys] of Object.entries(removed)) console.log(dryRun ? ` ${locale}: would prune ${keys.length}: ${keys.join(", ")}` : ` ${locale}: -${keys.length} pruned`);
|
|
1640
1713
|
}
|
|
1641
1714
|
const { added } = syncCatalogs(cfg, catalogs, registry);
|
|
1642
1715
|
if (!dryRun) {
|
|
1643
1716
|
for (const locale of cfg.locales) writeCatalog(cfg, locale, catalogs[locale] ?? {});
|
|
1644
|
-
writeDts(cfg,
|
|
1717
|
+
writeDts(cfg, catalogs[cfg.sourceLocale] ?? {});
|
|
1645
1718
|
}
|
|
1646
1719
|
const total = registry.messages().size;
|
|
1647
1720
|
console.log(`[verbaly] ${total} messages · locales: ${cfg.locales.join(", ")}${dryRun ? " (dry run, nothing written)" : ""}`);
|
|
@@ -1672,7 +1745,7 @@ async function runCli(args = process.argv.slice(2)) {
|
|
|
1672
1745
|
console.log("[verbaly] nothing to translate ✓");
|
|
1673
1746
|
return;
|
|
1674
1747
|
}
|
|
1675
|
-
for (const [locale, keys] of entries) console.log(` ${locale}: ${keys.length} missing
|
|
1748
|
+
for (const [locale, keys] of entries) console.log(` ${locale}: ${keys.length} missing: ${keys.join(", ")}`);
|
|
1676
1749
|
return;
|
|
1677
1750
|
}
|
|
1678
1751
|
for (const locale of Object.keys(result.translated)) {
|
|
@@ -1685,8 +1758,18 @@ async function runCli(args = process.argv.slice(2)) {
|
|
|
1685
1758
|
}
|
|
1686
1759
|
if (command === "export") {
|
|
1687
1760
|
const format = values.format ?? "xliff";
|
|
1688
|
-
if (
|
|
1689
|
-
|
|
1761
|
+
if (![
|
|
1762
|
+
"xliff",
|
|
1763
|
+
"csv",
|
|
1764
|
+
"android-xml",
|
|
1765
|
+
"ios-strings"
|
|
1766
|
+
].includes(format)) {
|
|
1767
|
+
console.error(`[verbaly] unknown format "${values.format}", use xliff, csv, android-xml or ios-strings`);
|
|
1768
|
+
process.exitCode = 1;
|
|
1769
|
+
return;
|
|
1770
|
+
}
|
|
1771
|
+
if (values.missing && isMobileFormat(format)) {
|
|
1772
|
+
console.error(`[verbaly] --missing is for translator formats (xliff, csv): ${format} already skips untranslated keys so the app falls back to the source locale`);
|
|
1690
1773
|
process.exitCode = 1;
|
|
1691
1774
|
return;
|
|
1692
1775
|
}
|
|
@@ -1700,8 +1783,9 @@ async function runCli(args = process.argv.slice(2)) {
|
|
|
1700
1783
|
console.log("[verbaly] no target locales to export (add locales to your config)");
|
|
1701
1784
|
return;
|
|
1702
1785
|
}
|
|
1786
|
+
const note = isMobileFormat(result.format) ? "untranslated skipped" : "untranslated";
|
|
1703
1787
|
console.log(`[verbaly] exported ${result.files.length} locales (${result.format}) → ${result.dir}`);
|
|
1704
|
-
for (const file of result.files) console.log(` ${file.locale}: ${file.total} messages (${file.untranslated}
|
|
1788
|
+
for (const file of result.files) console.log(` ${file.locale}: ${file.total} messages (${file.untranslated} ${note}) → ${file.path}`);
|
|
1705
1789
|
return;
|
|
1706
1790
|
}
|
|
1707
1791
|
if (command === "import") {
|
|
@@ -1738,7 +1822,7 @@ async function runCli(args = process.argv.slice(2)) {
|
|
|
1738
1822
|
clean: values.clean
|
|
1739
1823
|
});
|
|
1740
1824
|
console.log(`[verbaly] ${result.files} pages × ${result.locales.length} locales (${result.locales.join(", ")})`);
|
|
1741
|
-
for (const [locale, keys] of Object.entries(result.missing)) console.warn(` ${locale}: ${keys.length} keys not pre-filled
|
|
1825
|
+
for (const [locale, keys] of Object.entries(result.missing)) console.warn(` ${locale}: ${keys.length} keys not pre-filled: ${keys.join(", ")}`);
|
|
1742
1826
|
return;
|
|
1743
1827
|
}
|
|
1744
1828
|
if (command === "pseudo") {
|
|
@@ -1790,14 +1874,14 @@ function rejectStrayFlags(command, values) {
|
|
|
1790
1874
|
const allowed = /* @__PURE__ */ new Set([...COMMON_FLAGS, ...own]);
|
|
1791
1875
|
const stray = Object.keys(values).filter((k) => values[k] !== void 0 && !allowed.has(k));
|
|
1792
1876
|
if (stray.length === 0) return false;
|
|
1793
|
-
for (const flag of stray) console.error(`[verbaly] --${flag} is not a "${command}" flag${flag === "locale" ? "
|
|
1877
|
+
for (const flag of stray) console.error(`[verbaly] --${flag} is not a "${command}" flag${flag === "locale" ? " (did you mean --locales?)" : ""}`);
|
|
1794
1878
|
process.exitCode = 1;
|
|
1795
1879
|
return true;
|
|
1796
1880
|
}
|
|
1797
1881
|
async function resolveProvider(cfg, model) {
|
|
1798
1882
|
const configured = cfg.translate.provider;
|
|
1799
1883
|
if (typeof configured === "function") return configured;
|
|
1800
|
-
const { claudeProvider } = await import("./claude-
|
|
1884
|
+
const { claudeProvider } = await import("./claude-C-ETlqsW.js");
|
|
1801
1885
|
return claudeProvider({ model: model ?? cfg.translate.model });
|
|
1802
1886
|
}
|
|
1803
1887
|
//#endregion
|