@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 +15 -13
- package/dist/claude-C-ETlqsW.js.map +1 -1
- package/dist/cli.js +129 -25
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +21 -1
- package/dist/index.js +91 -11
- 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
|
|
|
@@ -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
|
|
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,
|
|
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
|
-
|
|
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))) {
|
|
@@ -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"
|
|
1410
|
+
if (typeof attrKey !== "string") continue;
|
|
1408
1411
|
if (!v.has(attrKey)) {
|
|
1409
1412
|
missing.add(attrKey);
|
|
1410
1413
|
continue;
|
|
1411
1414
|
}
|
|
1412
|
-
|
|
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
|
|
1575
|
-
|
|
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(/"/g, "\"").replace(/'/g, "'").replace(/</g, "<").replace(/>/g, ">").replace(/&/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
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
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
|
-
|
|
1715
|
-
if (
|
|
1716
|
-
|
|
1717
|
-
|
|
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
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
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: [
|
|
1948
|
+
extract: [
|
|
1949
|
+
"prune",
|
|
1950
|
+
"dry-run",
|
|
1951
|
+
"watch"
|
|
1952
|
+
],
|
|
1953
|
+
status: [],
|
|
1850
1954
|
check: [],
|
|
1851
1955
|
translate: ["model", "dry-run"],
|
|
1852
1956
|
export: [
|