@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 +27 -14
- package/dist/cli.js +214 -24
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +26 -3
- package/dist/index.js +160 -5
- 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
|
-
npx verbaly export #
|
|
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,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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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`)
|
|
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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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(/"/g, "\"").replace(/'/g, "'").replace(/</g, "<").replace(/>/g, ">").replace(/&/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
|
|
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
|
|
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
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
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
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
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
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
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 (
|
|
1688
|
-
|
|
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}
|
|
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: [
|
|
1949
|
+
extract: [
|
|
1950
|
+
"prune",
|
|
1951
|
+
"dry-run",
|
|
1952
|
+
"watch"
|
|
1953
|
+
],
|
|
1954
|
+
status: [],
|
|
1765
1955
|
check: [],
|
|
1766
1956
|
translate: ["model", "dry-run"],
|
|
1767
1957
|
export: [
|