@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 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 # write translator-ready XLIFF 2.0 / CSV files per locale
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 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 });
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-mpjdqqnY.js.map
51
+ //# sourceMappingURL=claude-C-ETlqsW.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"claude-mpjdqqnY.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,oMACA,EAAE,OAAO,MAAM,CACjB;EAEF,MAAM;CACR;AACF"}
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
- return JSON.parse(readFileSync(catalogPath(cfg, locale), "utf8"));
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 ? ` "${truncate(entry.source, 40)}"` : "";
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} used in ${entry.files.join(", ")}`);
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 parts = [];
131
- if (types.has("number")) parts.push("number");
132
- if (types.has("string")) parts.push("string");
133
- if (types.has("date")) parts.push("Date | number | string");
134
- return parts.join(" | ");
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(entries) {
148
+ function generateDts(catalog) {
139
149
  const lines = [];
140
- for (const [key, message] of [...entries.entries()].sort(([a], [b]) => a.localeCompare(b))) {
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, entries) {
190
- writeFileSync(join(cfg.root, "verbaly.d.ts"), generateDts(entries));
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 install it: pnpm add -D esbuild`, { cause: error });
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)} second dropped.`);
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
- tagged.push({
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
- tagged.push({
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 [key, value] of Object.entries(node)) {
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 = (check, message) => entries.push({
914
+ const ok = (name, message) => entries.push({
911
915
  level: "ok",
912
- check,
916
+ check: name,
913
917
  message
914
918
  });
915
- const warn = (check, message, fix) => entries.push({
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 = (check, message, fix) => entries.push({
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 running on defaults", "run `npx verbaly init`");
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 CLI flow (extract/check) applies");
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(new Map(Object.entries(source)))) warn("types", "verbaly.d.ts is stale", "run `npx verbaly extract`");
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 `npx verbaly check` for details");
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 `npx verbaly check` for details");
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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
- let entries = Object.keys(source).filter((key) => source[key]).sort().map((key) => ({
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
- if (options.missing) entries = entries.filter((entry) => !entry.target);
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: entries.filter((entry) => !entry.target).length
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 pass --locale <id>.`);
1111
- 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.`);
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 pass --locale <id>.`);
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 expected .xlf, .xliff or .csv.`);
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 i18n compiler
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-ready files per locale (XLIFF 2.0 or CSV)
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 csv (export)
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 enables hreflang alternates (render)
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 ${result.entries.length} checks`);
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} ${keys.join(", ")}` : ` ${locale}: -${keys.length} pruned`);
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, new Map(Object.entries(catalogs[cfg.sourceLocale] ?? {})));
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 ${keys.join(", ")}`);
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 (format !== "xliff" && format !== "csv") {
1689
- console.error(`[verbaly] unknown format "${values.format}" — use xliff or csv`);
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} untranslated) → ${file.path}`);
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 ${keys.join(", ")}`);
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" ? " did you mean --locales?" : ""}`);
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-mpjdqqnY.js");
1884
+ const { claudeProvider } = await import("./claude-C-ETlqsW.js");
1801
1885
  return claudeProvider({ model: model ?? cfg.translate.model });
1802
1886
  }
1803
1887
  //#endregion