@verbaly/compiler 0.22.0 → 0.24.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
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,7 +54,7 @@ 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.
56
58
 
57
59
  ## 📱 Mobile resources
58
60
 
@@ -67,9 +69,9 @@ Your source locale becomes the platform default (`values/strings.xml`, `en.lproj
67
69
 
68
70
  ## 📄 Static rendering (SSG)
69
71
 
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.
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.
71
73
 
72
- 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):
73
75
 
74
76
  ```ts
75
77
  // verbaly.config.ts
@@ -78,15 +80,15 @@ render: { links: { docs: { href: '/docs', target: '_blank', rel: 'noopener' } }
78
80
 
79
81
  Per-element `data-verbaly-links='{"repo":"https://…"}'` merges over the config map.
80
82
 
81
- **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.
82
84
 
83
85
  ## 🔍 Pseudo-localization
84
86
 
85
- `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`.
86
88
 
87
89
  📖 Docs: **https://verbaly-web.vercel.app/docs/cli**
88
90
 
89
- > ⚠️ Early development (`0.x`) API not stable yet.
91
+ > ⚠️ Early development (`0.x`): API not stable yet.
90
92
 
91
93
  ## License
92
94
 
@@ -1 +1 @@
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"}
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"],"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
@@ -1,8 +1,8 @@
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
- import { RICH_TAGS, createVerbaly, parse, parseTags, safeHref } from "verbaly";
5
+ import { RICH_TAGS, createVerbaly, normalizeLink, parse, parseTags, safeAttribute } from "verbaly";
6
6
  import { pathToFileURL } from "node:url";
7
7
  import { glob } from "tinyglobby";
8
8
  import { parse as parse$1 } from "@babel/parser";
@@ -40,8 +40,13 @@ function serializeCatalog(catalog) {
40
40
  }
41
41
  function writeCatalog(cfg, locale, catalog) {
42
42
  const serialized = serializeCatalog(catalog);
43
- mkdirSync(cfg.dir, { recursive: true });
44
- 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);
45
50
  return serialized;
46
51
  }
47
52
  //#endregion
@@ -143,8 +148,6 @@ function renderParamType(types) {
143
148
  ]) members.add(m);
144
149
  return [...members].join(" | ");
145
150
  }
146
- //#endregion
147
- //#region src/codegen.ts
148
151
  function generateDts(catalog) {
149
152
  const lines = [];
150
153
  for (const [key, message] of Object.entries(catalog).sort(([a], [b]) => a.localeCompare(b))) {
@@ -1404,12 +1407,13 @@ function renderHtml(html, options) {
1404
1407
  if (attrMapRaw !== void 0) {
1405
1408
  const map = parseArgs$1(attrMapRaw);
1406
1409
  if (map) for (const [name, attrKey] of Object.entries(map)) {
1407
- if (typeof attrKey !== "string" || name.toLowerCase().startsWith("on")) continue;
1410
+ if (typeof attrKey !== "string") continue;
1408
1411
  if (!v.has(attrKey)) {
1409
1412
  missing.add(attrKey);
1410
1413
  continue;
1411
1414
  }
1412
- setAttribute(ms, html, chunkStart, openEnd, attrChunk, name, t(attrKey, args));
1415
+ const value = safeAttribute(name, t(attrKey, args));
1416
+ if (value !== void 0) setAttribute(ms, html, chunkStart, openEnd, attrChunk, name, value);
1413
1417
  }
1414
1418
  }
1415
1419
  }
@@ -1571,10 +1575,8 @@ function richToHtml(nodes, allowed, links) {
1571
1575
  let out = "";
1572
1576
  for (const node of nodes) if (typeof node === "string") out += escapeHtml(node);
1573
1577
  else if (links?.[node.name] !== void 0) {
1574
- const link = links[node.name];
1575
- const { href, target, rel } = typeof link === "string" ? { href: link } : link;
1576
- const safe = safeHref(href);
1577
- let attrs = safe !== void 0 ? ` href="${escapeAttr(safe)}"` : "";
1578
+ const { href, target, rel } = normalizeLink(links[node.name]);
1579
+ let attrs = href !== void 0 ? ` href="${escapeAttr(href)}"` : "";
1578
1580
  if (target) attrs += ` target="${escapeAttr(target)}"`;
1579
1581
  if (rel) attrs += ` rel="${escapeAttr(rel)}"`;
1580
1582
  out += `<a${attrs}>${richToHtml(node.children, allowed, links)}</a>`;
@@ -1592,6 +1594,83 @@ function decodeEntities(text) {
1592
1594
  return text.replace(/&quot;/g, "\"").replace(/&#39;/g, "'").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
1593
1595
  }
1594
1596
  //#endregion
1597
+ //#region src/status.ts
1598
+ function status(cfg, catalogs, registry) {
1599
+ const source = catalogs[cfg.sourceLocale] ?? {};
1600
+ const needed = /* @__PURE__ */ new Set([...registry.messages().keys(), ...Object.keys(source)]);
1601
+ const locales = [];
1602
+ for (const locale of cfg.locales) {
1603
+ if (locale === cfg.sourceLocale) continue;
1604
+ let translated = 0;
1605
+ for (const key of needed) if (catalogs[locale]?.[key]) translated += 1;
1606
+ locales.push({
1607
+ locale,
1608
+ translated,
1609
+ total: needed.size
1610
+ });
1611
+ }
1612
+ return {
1613
+ messages: needed.size,
1614
+ source: cfg.sourceLocale,
1615
+ locales
1616
+ };
1617
+ }
1618
+ function formatStatusResult(result) {
1619
+ const lines = [`[verbaly] ${result.messages} messages · source: ${result.source}`];
1620
+ if (result.locales.length === 0) {
1621
+ lines.push(" no target locales (add locales to your config)");
1622
+ return lines.join("\n");
1623
+ }
1624
+ for (const { locale, translated, total } of result.locales) {
1625
+ const pct = total === 0 ? 100 : Math.floor(translated / total * 100);
1626
+ const mark = translated === total ? " ✓" : "";
1627
+ lines.push(` ${locale}: ${translated}/${total} translated (${pct}%)${mark}`);
1628
+ }
1629
+ return lines.join("\n");
1630
+ }
1631
+ const SOURCE_FILE_RE = /\.(?:[cm]?[jt]sx?|svelte|vue)$/;
1632
+ //#endregion
1633
+ //#region src/watch.ts
1634
+ function watchProject(cfg, run, options = {}) {
1635
+ const catalogDir = relative(cfg.root, cfg.dir).replaceAll("\\", "/");
1636
+ let timer;
1637
+ let running = false;
1638
+ let queued = false;
1639
+ async function refresh() {
1640
+ if (running) {
1641
+ queued = true;
1642
+ return;
1643
+ }
1644
+ running = true;
1645
+ try {
1646
+ await run();
1647
+ } catch (error) {
1648
+ console.warn("[verbaly] watch run failed:", error);
1649
+ } finally {
1650
+ running = false;
1651
+ if (queued) {
1652
+ queued = false;
1653
+ schedule();
1654
+ }
1655
+ }
1656
+ }
1657
+ function schedule() {
1658
+ clearTimeout(timer);
1659
+ timer = setTimeout(() => void refresh(), options.debounce ?? 150);
1660
+ }
1661
+ const watcher = watch(cfg.root, { recursive: true }, (_event, filename) => {
1662
+ if (!filename) return;
1663
+ const file = filename.replaceAll("\\", "/");
1664
+ if (file.includes("node_modules/") || file.endsWith(".d.ts")) return;
1665
+ if (catalogDir && file.startsWith(`${catalogDir}/`)) return;
1666
+ if (SOURCE_FILE_RE.test(file)) schedule();
1667
+ });
1668
+ return () => {
1669
+ clearTimeout(timer);
1670
+ watcher.close();
1671
+ };
1672
+ }
1673
+ //#endregion
1595
1674
  //#region src/run.ts
1596
1675
  const HELP = `verbaly · i18n compiler
1597
1676
 
@@ -1599,6 +1678,7 @@ Usage:
1599
1678
  verbaly init scaffold config + locale catalogs (detects your bundler)
1600
1679
  verbaly doctor diagnose the setup (config, catalogs, plugin, types, keys)
1601
1680
  verbaly extract scan sources, update catalogs and types
1681
+ verbaly status translation coverage per locale, at a glance
1602
1682
  verbaly check verify translations are complete (CI)
1603
1683
  verbaly translate fill missing translations via a provider (default: claude)
1604
1684
  verbaly export write translator files (XLIFF 2.0, CSV) or mobile resources (Android, iOS)
@@ -1612,6 +1692,7 @@ Options:
1612
1692
  --source <locale> source locale (default: en)
1613
1693
  --locales <csv> extra locales; for translate: target locales to fill
1614
1694
  --prune drop keys no longer referenced (extract)
1695
+ --watch keep extracting as source files change (extract)
1615
1696
  --model <id> model override for the claude provider (translate)
1616
1697
  --dry-run list what would happen, write nothing (translate, import, extract)
1617
1698
  --format <f> export format: xliff (default), csv, android-xml or ios-strings (export)
@@ -1638,6 +1719,7 @@ async function runCli(args = process.argv.slice(2)) {
1638
1719
  source: { type: "string" },
1639
1720
  locales: { type: "string" },
1640
1721
  prune: { type: "boolean" },
1722
+ watch: { type: "boolean" },
1641
1723
  model: { type: "string" },
1642
1724
  locale: { type: "string" },
1643
1725
  site: { type: "string" },
@@ -1705,20 +1787,37 @@ async function runCli(args = process.argv.slice(2)) {
1705
1787
  }
1706
1788
  if (command === "extract") {
1707
1789
  const dryRun = values["dry-run"];
1708
- const registry = await extractProject(cfg);
1709
- const catalogs = loadCatalogs(cfg);
1710
- if (values.prune) {
1711
- const removed = pruneCatalogs(cfg, catalogs, registry);
1712
- for (const [locale, keys] of Object.entries(removed)) console.log(dryRun ? ` ${locale}: would prune ${keys.length}: ${keys.join(", ")}` : ` ${locale}: -${keys.length} pruned`);
1790
+ if (values.watch && (dryRun || values.prune)) {
1791
+ console.error("[verbaly] --watch runs alone: prune is a deliberate one-shot action and dry-run writes nothing");
1792
+ process.exitCode = 1;
1793
+ return;
1794
+ }
1795
+ async function runExtract() {
1796
+ const registry = await extractProject(cfg);
1797
+ const catalogs = loadCatalogs(cfg);
1798
+ if (values.prune) {
1799
+ const removed = pruneCatalogs(cfg, catalogs, registry);
1800
+ for (const [locale, keys] of Object.entries(removed)) console.log(dryRun ? ` ${locale}: would prune ${keys.length}: ${keys.join(", ")}` : ` ${locale}: -${keys.length} pruned`);
1801
+ }
1802
+ const { added } = syncCatalogs(cfg, catalogs, registry);
1803
+ if (!dryRun) {
1804
+ for (const locale of cfg.locales) writeCatalog(cfg, locale, catalogs[locale] ?? {});
1805
+ writeDts(cfg, catalogs[cfg.sourceLocale] ?? {});
1806
+ }
1807
+ const total = registry.messages().size;
1808
+ console.log(`[verbaly] ${total} messages · locales: ${cfg.locales.join(", ")}${dryRun ? " (dry run, nothing written)" : ""}`);
1809
+ for (const [locale, keys] of Object.entries(added)) console.log(` ${locale}: ${dryRun ? `would add ${keys.length}` : `+${keys.length}`}`);
1713
1810
  }
1714
- const { added } = syncCatalogs(cfg, catalogs, registry);
1715
- if (!dryRun) {
1716
- for (const locale of cfg.locales) writeCatalog(cfg, locale, catalogs[locale] ?? {});
1717
- writeDts(cfg, catalogs[cfg.sourceLocale] ?? {});
1811
+ await runExtract();
1812
+ if (values.watch) {
1813
+ watchProject(cfg, runExtract);
1814
+ console.log("[verbaly] watching for source changes (ctrl+c to stop)");
1718
1815
  }
1719
- const total = registry.messages().size;
1720
- console.log(`[verbaly] ${total} messages · locales: ${cfg.locales.join(", ")}${dryRun ? " (dry run, nothing written)" : ""}`);
1721
- for (const [locale, keys] of Object.entries(added)) console.log(` ${locale}: ${dryRun ? `would add ${keys.length}` : `+${keys.length}`}`);
1816
+ return;
1817
+ }
1818
+ if (command === "status") {
1819
+ const registry = await extractProject(cfg);
1820
+ console.log(formatStatusResult(status(cfg, loadCatalogs(cfg), registry)));
1722
1821
  return;
1723
1822
  }
1724
1823
  if (command === "check") {
@@ -1846,7 +1945,12 @@ const COMMON_FLAGS = /* @__PURE__ */ new Set([
1846
1945
  const COMMAND_FLAGS = {
1847
1946
  init: [],
1848
1947
  doctor: [],
1849
- extract: ["prune", "dry-run"],
1948
+ extract: [
1949
+ "prune",
1950
+ "dry-run",
1951
+ "watch"
1952
+ ],
1953
+ status: [],
1850
1954
  check: [],
1851
1955
  translate: ["model", "dry-run"],
1852
1956
  export: [