@verbaly/compiler 0.22.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
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
 
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";
@@ -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))) {
@@ -1592,6 +1595,83 @@ function decodeEntities(text) {
1592
1595
  return text.replace(/&quot;/g, "\"").replace(/&#39;/g, "'").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
1593
1596
  }
1594
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
1595
1675
  //#region src/run.ts
1596
1676
  const HELP = `verbaly · i18n compiler
1597
1677
 
@@ -1599,6 +1679,7 @@ Usage:
1599
1679
  verbaly init scaffold config + locale catalogs (detects your bundler)
1600
1680
  verbaly doctor diagnose the setup (config, catalogs, plugin, types, keys)
1601
1681
  verbaly extract scan sources, update catalogs and types
1682
+ verbaly status translation coverage per locale, at a glance
1602
1683
  verbaly check verify translations are complete (CI)
1603
1684
  verbaly translate fill missing translations via a provider (default: claude)
1604
1685
  verbaly export write translator files (XLIFF 2.0, CSV) or mobile resources (Android, iOS)
@@ -1612,6 +1693,7 @@ Options:
1612
1693
  --source <locale> source locale (default: en)
1613
1694
  --locales <csv> extra locales; for translate: target locales to fill
1614
1695
  --prune drop keys no longer referenced (extract)
1696
+ --watch keep extracting as source files change (extract)
1615
1697
  --model <id> model override for the claude provider (translate)
1616
1698
  --dry-run list what would happen, write nothing (translate, import, extract)
1617
1699
  --format <f> export format: xliff (default), csv, android-xml or ios-strings (export)
@@ -1638,6 +1720,7 @@ async function runCli(args = process.argv.slice(2)) {
1638
1720
  source: { type: "string" },
1639
1721
  locales: { type: "string" },
1640
1722
  prune: { type: "boolean" },
1723
+ watch: { type: "boolean" },
1641
1724
  model: { type: "string" },
1642
1725
  locale: { type: "string" },
1643
1726
  site: { type: "string" },
@@ -1705,20 +1788,37 @@ async function runCli(args = process.argv.slice(2)) {
1705
1788
  }
1706
1789
  if (command === "extract") {
1707
1790
  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`);
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;
1795
+ }
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}`}`);
1713
1811
  }
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] ?? {});
1812
+ await runExtract();
1813
+ if (values.watch) {
1814
+ watchProject(cfg, runExtract);
1815
+ console.log("[verbaly] watching for source changes (ctrl+c to stop)");
1718
1816
  }
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}`}`);
1817
+ return;
1818
+ }
1819
+ if (command === "status") {
1820
+ const registry = await extractProject(cfg);
1821
+ console.log(formatStatusResult(status(cfg, loadCatalogs(cfg), registry)));
1722
1822
  return;
1723
1823
  }
1724
1824
  if (command === "check") {
@@ -1846,7 +1946,12 @@ const COMMON_FLAGS = /* @__PURE__ */ new Set([
1846
1946
  const COMMAND_FLAGS = {
1847
1947
  init: [],
1848
1948
  doctor: [],
1849
- extract: ["prune", "dry-run"],
1949
+ extract: [
1950
+ "prune",
1951
+ "dry-run",
1952
+ "watch"
1953
+ ],
1954
+ status: [],
1850
1955
  check: [],
1851
1956
  translate: ["model", "dry-run"],
1852
1957
  export: [