@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 +15 -13
- package/dist/cli.js +123 -18
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +21 -1
- package/dist/index.js +85 -4
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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`)
|
|
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
|
-
|
|
44
|
-
|
|
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(/"/g, "\"").replace(/'/g, "'").replace(/</g, "<").replace(/>/g, ">").replace(/&/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
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
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
|
-
|
|
1715
|
-
if (
|
|
1716
|
-
|
|
1717
|
-
|
|
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
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
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: [
|
|
1949
|
+
extract: [
|
|
1950
|
+
"prune",
|
|
1951
|
+
"dry-run",
|
|
1952
|
+
"watch"
|
|
1953
|
+
],
|
|
1954
|
+
status: [],
|
|
1850
1955
|
check: [],
|
|
1851
1956
|
translate: ["model", "dry-run"],
|
|
1852
1957
|
export: [
|