@verbaly/compiler 0.21.0 → 0.23.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
@@ -11,9 +11,9 @@
11
11
 
12
12
  ---
13
13
 
14
- The compiler behind [Verbaly](https://github.com/AronSoto/verbaly): AST extraction of `t\`...\``**and JSX`<Trans>` children** into stable hashed keys or **readable keys** via `` t.id('inbox.title')`…` `` and `<Trans id="inbox.title">…</Trans>` flat JSON catalog sync, and typed codegen. It also ships the **`verbaly` CLI**. Extraction covers `.js/.ts/.jsx/.tsx` **and `.svelte`/`.vue` single-file components** (script blocks and markup, including Svelte's `$t` store form).
14
+ The compiler behind [Verbaly](https://github.com/AronSoto/verbaly): AST extraction of `t\`...\``**and JSX`<Trans>` children** into stable hashed keys (or **readable keys** via `` t.id('inbox.title')`…` `` and `<Trans id="inbox.title">…</Trans>`), flat JSON catalog sync, and typed codegen. It also ships the **`verbaly` CLI**. Extraction covers `.js/.ts/.jsx/.tsx` **and `.svelte`/`.vue` single-file components** (script blocks and markup, including Svelte's `$t` store form).
15
15
 
16
- > Most projects don't install this directly [`@verbaly/vite`](https://www.npmjs.com/package/@verbaly/vite) wraps it with zero config. Reach for it when scripting extraction/checks yourself.
16
+ > Most projects don't install this directly: [`@verbaly/vite`](https://www.npmjs.com/package/@verbaly/vite) wraps it with zero config. Reach for it when scripting extraction/checks yourself.
17
17
 
18
18
  ## 🧰 CLI
19
19
 
@@ -21,20 +21,22 @@ The compiler behind [Verbaly](https://github.com/AronSoto/verbaly): AST extracti
21
21
  npx verbaly init # scaffold config + locale catalogs (detects your bundler)
22
22
  npx verbaly doctor # diagnose the setup (config, catalogs, plugin, types, keys)
23
23
  npx verbaly extract # sync catalogs + types
24
- npx verbaly check # exit 1 if anything is missing (CI)
24
+ npx verbaly extract --watch # keep extracting as you code (dev loop)
25
25
  npx verbaly extract --prune # drop orphaned keys
26
+ npx verbaly status # translation coverage per locale, at a glance
27
+ npx verbaly check # exit 1 if anything is missing (CI)
26
28
  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
29
+ npx verbaly export # translator files (XLIFF 2.0, CSV) or mobile resources (Android, iOS)
28
30
  npx verbaly import <files> # fill catalogs back from translated XLIFF/CSV files
29
31
  npx verbaly pseudo # generate a pseudo-locale catalog for i18n QA (en-XA)
30
- npx verbaly render # pre-fill data-verbaly HTML per locale (SSG kills the FOUC)
32
+ npx verbaly render # pre-fill data-verbaly HTML per locale (SSG, kills the FOUC)
31
33
  ```
32
34
 
33
- Reads `verbaly.config.{js,mjs,ts,mts,json}` (TS configs need `esbuild` installed). Generates `locales/<locale>.json` (flat, portable no proprietary format) and `verbaly.d.ts` with params typed per key.
35
+ Reads `verbaly.config.{js,mjs,ts,mts,json}` (TS configs need `esbuild` installed). Generates `locales/<locale>.json` (flat, portable, no proprietary format) and `verbaly.d.ts` with params typed per key.
34
36
 
35
37
  ## 🤖 Machine translation
36
38
 
37
- `verbaly translate` fills the `""` holes `check` reports. The default provider uses Claude via the official SDK install it as a dev dependency (translation is a build-time step, not an app runtime dependency): `pnpm add -D @anthropic-ai/sdk` (or `npm i -D`), plus `ANTHROPIC_API_KEY`. Default model is `claude-sonnet-5` (balanced quality/cost); override with `translate.model` in config or `--model <id>`. Placeholders, variants and tags are validated after translation anything not preserved verbatim stays `""` so `check` keeps failing. Plug your own provider in `verbaly.config.ts`:
39
+ `verbaly translate` fills the `""` holes `check` reports. The default provider uses Claude via the official SDK; install it as a dev dependency (translation is a build-time step, not an app runtime dependency): `pnpm add -D @anthropic-ai/sdk` (or `npm i -D`), plus `ANTHROPIC_API_KEY`. Default model is `claude-sonnet-5` (balanced quality/cost); override with `translate.model` in config or `--model <id>`. Placeholders, variants and tags are validated after translation: anything not preserved verbatim stays `""` so `check` keeps failing. Plug your own provider in `verbaly.config.ts`:
38
40
 
39
41
  ```ts
40
42
  translate: {
@@ -44,7 +46,7 @@ translate: {
44
46
 
45
47
  ## 🌍 Human translators & TMS
46
48
 
47
- Catalogs are **flat JSON** most TMS platforms (Crowdin, Lokalise, Phrase, …) ingest them natively; point the platform at `locales/` and you're done. For everything else there's a built-in round-trip:
49
+ Catalogs are **flat JSON**: most TMS platforms (Crowdin, Lokalise, Phrase, …) ingest them natively; point the platform at `locales/` and you're done. For everything else there's a built-in round-trip:
48
50
 
49
51
  ```bash
50
52
  npx verbaly export # verbaly-export/<locale>.xlf (XLIFF 2.0, source + target per unit)
@@ -52,13 +54,24 @@ npx verbaly export --format csv # spreadsheet-friendly: key,source,target
52
54
  npx verbaly import verbaly-export/es.xlf # fill the catalog back
53
55
  ```
54
56
 
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.
57
+ `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.
58
+
59
+ ## 📱 Mobile resources
60
+
61
+ The same catalogs can ship to a companion mobile app as drop-in native resources:
62
+
63
+ ```bash
64
+ npx verbaly export --format android-xml # verbaly-export/values-<locale>/strings.xml (drop into res/)
65
+ npx verbaly export --format ios-strings # verbaly-export/<locale>.lproj/Localizable.strings (drop into Xcode)
66
+ ```
67
+
68
+ 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.
56
69
 
57
70
  ## 📄 Static rendering (SSG)
58
71
 
59
- `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.
72
+ `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.
60
73
 
61
- Named links in rich messages render as real `<a>` elements hrefs come from config or markup, never from messages (`javascript:` blocked):
74
+ Named links in rich messages render as real `<a>` elements; hrefs come from config or markup, never from messages (`javascript:` blocked):
62
75
 
63
76
  ```ts
64
77
  // verbaly.config.ts
@@ -67,15 +80,15 @@ render: { links: { docs: { href: '/docs', target: '_blank', rel: 'noopener' } }
67
80
 
68
81
  Per-element `data-verbaly-links='{"repo":"https://…"}'` merges over the config map.
69
82
 
70
- **Multi-locale SEO** set `render.baseUrl` (or `--base-url`) and every page gets reciprocal `<link rel="alternate" hreflang>` (plus `x-default`) for the whole locale set; `--sitemap` writes a locale-aware `sitemap-i18n.xml`. `--clean` drops stale `dist/<locale>/` pages before mirroring. Injection is idempotent.
83
+ **Multi-locale SEO**: set `render.baseUrl` (or `--base-url`) and every page gets reciprocal `<link rel="alternate" hreflang>` (plus `x-default`) for the whole locale set; `--sitemap` writes a locale-aware `sitemap-i18n.xml`. `--clean` drops stale `dist/<locale>/` pages before mirroring. Injection is idempotent.
71
84
 
72
85
  ## 🔍 Pseudo-localization
73
86
 
74
- `verbaly pseudo` fills a QA catalog (`en-XA` by default, `--locale <id>` to change) from the source: accented letters, `⟦…⟧` markers and ~33% length padding reveal hardcoded strings, clipped layouts and concatenation bugs. Params, variant blocks and tags survive verbatim the same structural validation as `translate`.
87
+ `verbaly pseudo` fills a QA catalog (`en-XA` by default, `--locale <id>` to change) from the source: accented letters, `⟦…⟧` markers and ~33% length padding reveal hardcoded strings, clipped layouts and concatenation bugs. Params, variant blocks and tags survive verbatim, with the same structural validation as `translate`.
75
88
 
76
89
  📖 Docs: **https://verbaly-web.vercel.app/docs/cli**
77
90
 
78
- > ⚠️ Early development (`0.x`) API not stable yet.
91
+ > ⚠️ Early development (`0.x`): API not stable yet.
79
92
 
80
93
  ## License
81
94
 
package/dist/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { parseArgs } from "node:util";
3
- import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, watch, writeFileSync } from "node:fs";
4
4
  import { basename, dirname, join, relative, resolve } from "node:path";
5
5
  import { RICH_TAGS, createVerbaly, parse, parseTags, safeHref } from "verbaly";
6
6
  import { pathToFileURL } from "node:url";
@@ -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 = {};
@@ -34,8 +40,13 @@ function serializeCatalog(catalog) {
34
40
  }
35
41
  function writeCatalog(cfg, locale, catalog) {
36
42
  const serialized = serializeCatalog(catalog);
37
- mkdirSync(cfg.dir, { recursive: true });
38
- writeFileSync(catalogPath(cfg, locale), serialized);
43
+ const path = catalogPath(cfg, locale);
44
+ try {
45
+ if (readFileSync(path, "utf8") === serialized) return serialized;
46
+ } catch {
47
+ mkdirSync(cfg.dir, { recursive: true });
48
+ }
49
+ writeFileSync(path, serialized);
39
50
  return serialized;
40
51
  }
41
52
  //#endregion
@@ -137,8 +148,6 @@ function renderParamType(types) {
137
148
  ]) members.add(m);
138
149
  return [...members].join(" | ");
139
150
  }
140
- //#endregion
141
- //#region src/codegen.ts
142
151
  function generateDts(catalog) {
143
152
  const lines = [];
144
153
  for (const [key, message] of Object.entries(catalog).sort(([a], [b]) => a.localeCompare(b))) {
@@ -1006,6 +1015,45 @@ function readDeps(root) {
1006
1015
  }
1007
1016
  }
1008
1017
  //#endregion
1018
+ //#region src/mobile.ts
1019
+ function androidValuesDir(locale, sourceLocale) {
1020
+ if (locale === sourceLocale) return "values";
1021
+ const parts = locale.split("-");
1022
+ if (parts.length === 1) return `values-${locale}`;
1023
+ if (parts.length === 2 && /^[a-zA-Z]{2}$/.test(parts[1])) return `values-${parts[0]}-r${parts[1].toUpperCase()}`;
1024
+ return `values-b+${parts.join("+")}`;
1025
+ }
1026
+ function toAndroidXml(entries) {
1027
+ const names = /* @__PURE__ */ new Map();
1028
+ return [
1029
+ "<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
1030
+ "<resources>",
1031
+ ...entries.map(({ key, text }) => {
1032
+ const name = androidName(key);
1033
+ const clash = names.get(name);
1034
+ if (clash !== void 0) throw new Error(`[verbaly] android-xml: keys "${clash}" and "${key}" both become resource name "${name}", rename one of them.`);
1035
+ names.set(name, key);
1036
+ return ` <string name="${name}">${androidText(text)}</string>`;
1037
+ }),
1038
+ "</resources>",
1039
+ ""
1040
+ ].join("\n");
1041
+ }
1042
+ function toIosStrings(entries) {
1043
+ return [...entries.map(({ key, text }) => `"${iosText(key)}" = "${iosText(text)}";`), ""].join("\n");
1044
+ }
1045
+ function androidName(key) {
1046
+ const name = key.replace(/[^A-Za-z0-9_]/g, "_");
1047
+ return /^[0-9]/.test(name) ? `_${name}` : name;
1048
+ }
1049
+ function androidText(text) {
1050
+ 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;");
1051
+ return /^[@?]/.test(escaped) ? `\\${escaped}` : escaped;
1052
+ }
1053
+ function iosText(text) {
1054
+ return text.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n/g, "\\n").replace(/\t/g, "\\t");
1055
+ }
1056
+ //#endregion
1009
1057
  //#region src/translate.ts
1010
1058
  async function translateCatalogs(cfg, catalogs, provider, options = {}) {
1011
1059
  const batchSize = options.batchSize ?? 20;
@@ -1064,11 +1112,15 @@ function sameMembers(a, b) {
1064
1112
  }
1065
1113
  //#endregion
1066
1114
  //#region src/exchange.ts
1115
+ function isMobileFormat(format) {
1116
+ return format === "android-xml" || format === "ios-strings";
1117
+ }
1067
1118
  function exportCatalogs(cfg, catalogs, options = {}) {
1068
1119
  const format = options.format ?? "xliff";
1069
1120
  const dir = resolve(cfg.root, options.out ?? "verbaly-export");
1070
1121
  const source = catalogs[cfg.sourceLocale] ?? {};
1071
1122
  const targets = targetLocales(cfg, options.locales);
1123
+ if (isMobileFormat(format)) return exportMobile(cfg, catalogs, format, dir, source, targets);
1072
1124
  const files = [];
1073
1125
  mkdirSync(dir, { recursive: true });
1074
1126
  for (const locale of targets) {
@@ -1095,6 +1147,31 @@ function exportCatalogs(cfg, catalogs, options = {}) {
1095
1147
  files
1096
1148
  };
1097
1149
  }
1150
+ function exportMobile(cfg, catalogs, format, dir, source, targets) {
1151
+ const keys = Object.keys(source).filter((key) => source[key]).sort();
1152
+ const files = [];
1153
+ for (const locale of [cfg.sourceLocale, ...targets]) {
1154
+ const catalog = locale === cfg.sourceLocale ? source : catalogs[locale] ?? {};
1155
+ const entries = keys.map((key) => ({
1156
+ key,
1157
+ text: catalog[key] ?? ""
1158
+ })).filter((entry) => entry.text);
1159
+ const path = join(dir, format === "android-xml" ? join(androidValuesDir(locale, cfg.sourceLocale), "strings.xml") : join(`${locale}.lproj`, "Localizable.strings"));
1160
+ mkdirSync(dirname(path), { recursive: true });
1161
+ writeFileSync(path, format === "android-xml" ? toAndroidXml(entries) : toIosStrings(entries));
1162
+ files.push({
1163
+ locale,
1164
+ path,
1165
+ total: entries.length,
1166
+ untranslated: keys.length - entries.length
1167
+ });
1168
+ }
1169
+ return {
1170
+ format,
1171
+ dir,
1172
+ files
1173
+ };
1174
+ }
1098
1175
  function importCatalogs(cfg, catalogs, files, options = {}) {
1099
1176
  const source = catalogs[cfg.sourceLocale] ?? {};
1100
1177
  const result = {
@@ -1518,6 +1595,83 @@ function decodeEntities(text) {
1518
1595
  return text.replace(/&quot;/g, "\"").replace(/&#39;/g, "'").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
1519
1596
  }
1520
1597
  //#endregion
1598
+ //#region src/status.ts
1599
+ function status(cfg, catalogs, registry) {
1600
+ const source = catalogs[cfg.sourceLocale] ?? {};
1601
+ const needed = /* @__PURE__ */ new Set([...registry.messages().keys(), ...Object.keys(source)]);
1602
+ const locales = [];
1603
+ for (const locale of cfg.locales) {
1604
+ if (locale === cfg.sourceLocale) continue;
1605
+ let translated = 0;
1606
+ for (const key of needed) if (catalogs[locale]?.[key]) translated += 1;
1607
+ locales.push({
1608
+ locale,
1609
+ translated,
1610
+ total: needed.size
1611
+ });
1612
+ }
1613
+ return {
1614
+ messages: needed.size,
1615
+ source: cfg.sourceLocale,
1616
+ locales
1617
+ };
1618
+ }
1619
+ function formatStatusResult(result) {
1620
+ const lines = [`[verbaly] ${result.messages} messages · source: ${result.source}`];
1621
+ if (result.locales.length === 0) {
1622
+ lines.push(" no target locales (add locales to your config)");
1623
+ return lines.join("\n");
1624
+ }
1625
+ for (const { locale, translated, total } of result.locales) {
1626
+ const pct = total === 0 ? 100 : Math.floor(translated / total * 100);
1627
+ const mark = translated === total ? " ✓" : "";
1628
+ lines.push(` ${locale}: ${translated}/${total} translated (${pct}%)${mark}`);
1629
+ }
1630
+ return lines.join("\n");
1631
+ }
1632
+ const SOURCE_FILE_RE = /\.(?:[cm]?[jt]sx?|svelte|vue)$/;
1633
+ //#endregion
1634
+ //#region src/watch.ts
1635
+ function watchProject(cfg, run, options = {}) {
1636
+ const catalogDir = relative(cfg.root, cfg.dir).replaceAll("\\", "/");
1637
+ let timer;
1638
+ let running = false;
1639
+ let queued = false;
1640
+ async function refresh() {
1641
+ if (running) {
1642
+ queued = true;
1643
+ return;
1644
+ }
1645
+ running = true;
1646
+ try {
1647
+ await run();
1648
+ } catch (error) {
1649
+ console.warn("[verbaly] watch run failed:", error);
1650
+ } finally {
1651
+ running = false;
1652
+ if (queued) {
1653
+ queued = false;
1654
+ schedule();
1655
+ }
1656
+ }
1657
+ }
1658
+ function schedule() {
1659
+ clearTimeout(timer);
1660
+ timer = setTimeout(() => void refresh(), options.debounce ?? 150);
1661
+ }
1662
+ const watcher = watch(cfg.root, { recursive: true }, (_event, filename) => {
1663
+ if (!filename) return;
1664
+ const file = filename.replaceAll("\\", "/");
1665
+ if (file.includes("node_modules/") || file.endsWith(".d.ts")) return;
1666
+ if (catalogDir && file.startsWith(`${catalogDir}/`)) return;
1667
+ if (SOURCE_FILE_RE.test(file)) schedule();
1668
+ });
1669
+ return () => {
1670
+ clearTimeout(timer);
1671
+ watcher.close();
1672
+ };
1673
+ }
1674
+ //#endregion
1521
1675
  //#region src/run.ts
1522
1676
  const HELP = `verbaly · i18n compiler
1523
1677
 
@@ -1525,9 +1679,10 @@ Usage:
1525
1679
  verbaly init scaffold config + locale catalogs (detects your bundler)
1526
1680
  verbaly doctor diagnose the setup (config, catalogs, plugin, types, keys)
1527
1681
  verbaly extract scan sources, update catalogs and types
1682
+ verbaly status translation coverage per locale, at a glance
1528
1683
  verbaly check verify translations are complete (CI)
1529
1684
  verbaly translate fill missing translations via a provider (default: claude)
1530
- verbaly export write translator-ready files per locale (XLIFF 2.0 or CSV)
1685
+ verbaly export write translator files (XLIFF 2.0, CSV) or mobile resources (Android, iOS)
1531
1686
  verbaly import <files…> fill catalogs back from translated XLIFF/CSV files
1532
1687
  verbaly pseudo generate a pseudo-locale catalog for i18n QA (default: en-XA)
1533
1688
  verbaly render pre-fill data-verbaly HTML per locale (SSG, kills the FOUC)
@@ -1538,9 +1693,10 @@ Options:
1538
1693
  --source <locale> source locale (default: en)
1539
1694
  --locales <csv> extra locales; for translate: target locales to fill
1540
1695
  --prune drop keys no longer referenced (extract)
1696
+ --watch keep extracting as source files change (extract)
1541
1697
  --model <id> model override for the claude provider (translate)
1542
1698
  --dry-run list what would happen, write nothing (translate, import, extract)
1543
- --format <f> export format: xliff (default) or csv (export)
1699
+ --format <f> export format: xliff (default), csv, android-xml or ios-strings (export)
1544
1700
  --out <path> export directory (export, default: verbaly-export)
1545
1701
  --missing export only untranslated entries (export)
1546
1702
  --overwrite replace existing translations on import (import)
@@ -1564,6 +1720,7 @@ async function runCli(args = process.argv.slice(2)) {
1564
1720
  source: { type: "string" },
1565
1721
  locales: { type: "string" },
1566
1722
  prune: { type: "boolean" },
1723
+ watch: { type: "boolean" },
1567
1724
  model: { type: "string" },
1568
1725
  locale: { type: "string" },
1569
1726
  site: { type: "string" },
@@ -1631,20 +1788,37 @@ async function runCli(args = process.argv.slice(2)) {
1631
1788
  }
1632
1789
  if (command === "extract") {
1633
1790
  const dryRun = values["dry-run"];
1634
- const registry = await extractProject(cfg);
1635
- const catalogs = loadCatalogs(cfg);
1636
- if (values.prune) {
1637
- const removed = pruneCatalogs(cfg, catalogs, registry);
1638
- for (const [locale, keys] of Object.entries(removed)) console.log(dryRun ? ` ${locale}: would prune ${keys.length}: ${keys.join(", ")}` : ` ${locale}: -${keys.length} pruned`);
1791
+ if (values.watch && (dryRun || values.prune)) {
1792
+ console.error("[verbaly] --watch runs alone: prune is a deliberate one-shot action and dry-run writes nothing");
1793
+ process.exitCode = 1;
1794
+ return;
1639
1795
  }
1640
- const { added } = syncCatalogs(cfg, catalogs, registry);
1641
- if (!dryRun) {
1642
- for (const locale of cfg.locales) writeCatalog(cfg, locale, catalogs[locale] ?? {});
1643
- writeDts(cfg, catalogs[cfg.sourceLocale] ?? {});
1796
+ async function runExtract() {
1797
+ const registry = await extractProject(cfg);
1798
+ const catalogs = loadCatalogs(cfg);
1799
+ if (values.prune) {
1800
+ const removed = pruneCatalogs(cfg, catalogs, registry);
1801
+ for (const [locale, keys] of Object.entries(removed)) console.log(dryRun ? ` ${locale}: would prune ${keys.length}: ${keys.join(", ")}` : ` ${locale}: -${keys.length} pruned`);
1802
+ }
1803
+ const { added } = syncCatalogs(cfg, catalogs, registry);
1804
+ if (!dryRun) {
1805
+ for (const locale of cfg.locales) writeCatalog(cfg, locale, catalogs[locale] ?? {});
1806
+ writeDts(cfg, catalogs[cfg.sourceLocale] ?? {});
1807
+ }
1808
+ const total = registry.messages().size;
1809
+ console.log(`[verbaly] ${total} messages · locales: ${cfg.locales.join(", ")}${dryRun ? " (dry run, nothing written)" : ""}`);
1810
+ for (const [locale, keys] of Object.entries(added)) console.log(` ${locale}: ${dryRun ? `would add ${keys.length}` : `+${keys.length}`}`);
1644
1811
  }
1645
- const total = registry.messages().size;
1646
- console.log(`[verbaly] ${total} messages · locales: ${cfg.locales.join(", ")}${dryRun ? " (dry run, nothing written)" : ""}`);
1647
- for (const [locale, keys] of Object.entries(added)) console.log(` ${locale}: ${dryRun ? `would add ${keys.length}` : `+${keys.length}`}`);
1812
+ await runExtract();
1813
+ if (values.watch) {
1814
+ watchProject(cfg, runExtract);
1815
+ console.log("[verbaly] watching for source changes (ctrl+c to stop)");
1816
+ }
1817
+ return;
1818
+ }
1819
+ if (command === "status") {
1820
+ const registry = await extractProject(cfg);
1821
+ console.log(formatStatusResult(status(cfg, loadCatalogs(cfg), registry)));
1648
1822
  return;
1649
1823
  }
1650
1824
  if (command === "check") {
@@ -1684,8 +1858,18 @@ async function runCli(args = process.argv.slice(2)) {
1684
1858
  }
1685
1859
  if (command === "export") {
1686
1860
  const format = values.format ?? "xliff";
1687
- if (format !== "xliff" && format !== "csv") {
1688
- console.error(`[verbaly] unknown format "${values.format}", use xliff or csv`);
1861
+ if (![
1862
+ "xliff",
1863
+ "csv",
1864
+ "android-xml",
1865
+ "ios-strings"
1866
+ ].includes(format)) {
1867
+ console.error(`[verbaly] unknown format "${values.format}", use xliff, csv, android-xml or ios-strings`);
1868
+ process.exitCode = 1;
1869
+ return;
1870
+ }
1871
+ if (values.missing && isMobileFormat(format)) {
1872
+ console.error(`[verbaly] --missing is for translator formats (xliff, csv): ${format} already skips untranslated keys so the app falls back to the source locale`);
1689
1873
  process.exitCode = 1;
1690
1874
  return;
1691
1875
  }
@@ -1699,8 +1883,9 @@ async function runCli(args = process.argv.slice(2)) {
1699
1883
  console.log("[verbaly] no target locales to export (add locales to your config)");
1700
1884
  return;
1701
1885
  }
1886
+ const note = isMobileFormat(result.format) ? "untranslated skipped" : "untranslated";
1702
1887
  console.log(`[verbaly] exported ${result.files.length} locales (${result.format}) → ${result.dir}`);
1703
- for (const file of result.files) console.log(` ${file.locale}: ${file.total} messages (${file.untranslated} untranslated) → ${file.path}`);
1888
+ for (const file of result.files) console.log(` ${file.locale}: ${file.total} messages (${file.untranslated} ${note}) → ${file.path}`);
1704
1889
  return;
1705
1890
  }
1706
1891
  if (command === "import") {
@@ -1761,7 +1946,12 @@ const COMMON_FLAGS = /* @__PURE__ */ new Set([
1761
1946
  const COMMAND_FLAGS = {
1762
1947
  init: [],
1763
1948
  doctor: [],
1764
- extract: ["prune", "dry-run"],
1949
+ extract: [
1950
+ "prune",
1951
+ "dry-run",
1952
+ "watch"
1953
+ ],
1954
+ status: [],
1765
1955
  check: [],
1766
1956
  translate: ["model", "dry-run"],
1767
1957
  export: [