@verbaly/compiler 0.14.5 β 0.15.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 +17 -3
- package/dist/cli.js +299 -60
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +39 -1
- package/dist/index.js +243 -59
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -17,13 +17,15 @@ The compiler behind [Verbaly](https://github.com/AronSoto/verbaly): AST extracti
|
|
|
17
17
|
|
|
18
18
|
## π§° CLI
|
|
19
19
|
|
|
20
|
-
```
|
|
20
|
+
```bash
|
|
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
24
|
npx verbaly check # exit 1 if anything is missing (CI)
|
|
25
25
|
npx verbaly extract --prune # drop orphaned keys
|
|
26
26
|
npx verbaly translate # fill missing translations via Claude (or your provider)
|
|
27
|
+
npx verbaly export # write translator-ready XLIFF 2.0 / CSV files per locale
|
|
28
|
+
npx verbaly import <files> # fill catalogs back from translated XLIFF/CSV files
|
|
27
29
|
npx verbaly pseudo # generate a pseudo-locale catalog for i18n QA (en-XA)
|
|
28
30
|
npx verbaly render # pre-fill data-verbaly HTML per locale (SSG β kills the FOUC)
|
|
29
31
|
```
|
|
@@ -34,19 +36,31 @@ Reads `verbaly.config.{js,mjs,ts,mts,json}` (TS configs need `esbuild` installed
|
|
|
34
36
|
|
|
35
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`:
|
|
36
38
|
|
|
37
|
-
```
|
|
39
|
+
```ts
|
|
38
40
|
translate: {
|
|
39
41
|
provider: async ({ sourceLocale, targetLocale, messages }) => ({ ...translated });
|
|
40
42
|
}
|
|
41
43
|
```
|
|
42
44
|
|
|
45
|
+
## π Human translators & TMS
|
|
46
|
+
|
|
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:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
npx verbaly export # verbaly-export/<locale>.xlf (XLIFF 2.0, source + target per unit)
|
|
51
|
+
npx verbaly export --format csv # spreadsheet-friendly: key,source,target
|
|
52
|
+
npx verbaly import verbaly-export/es.xlf # fill the catalog back
|
|
53
|
+
```
|
|
54
|
+
|
|
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.
|
|
56
|
+
|
|
43
57
|
## π Static rendering (SSG)
|
|
44
58
|
|
|
45
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 β 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.
|
|
46
60
|
|
|
47
61
|
Named links in rich messages render as real `<a>` elements β hrefs come from config or markup, never from messages (`javascript:` blocked):
|
|
48
62
|
|
|
49
|
-
```
|
|
63
|
+
```ts
|
|
50
64
|
// verbaly.config.ts
|
|
51
65
|
render: { links: { docs: { href: '/docs', target: '_blank', rel: 'noopener' } } }
|
|
52
66
|
```
|
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { parseArgs } from "node:util";
|
|
3
3
|
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
-
import { dirname, join, relative, resolve } from "node:path";
|
|
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";
|
|
7
7
|
import { glob } from "tinyglobby";
|
|
@@ -759,6 +759,247 @@ function readDeps(root) {
|
|
|
759
759
|
}
|
|
760
760
|
}
|
|
761
761
|
//#endregion
|
|
762
|
+
//#region src/translate.ts
|
|
763
|
+
async function translateCatalogs(cfg, catalogs, provider, options = {}) {
|
|
764
|
+
const batchSize = options.batchSize ?? 20;
|
|
765
|
+
const targets = (options.locales ?? cfg.locales).filter((locale) => locale !== cfg.sourceLocale && cfg.locales.includes(locale));
|
|
766
|
+
const source = catalogs[cfg.sourceLocale] ?? {};
|
|
767
|
+
const result = {
|
|
768
|
+
translated: {},
|
|
769
|
+
invalid: {},
|
|
770
|
+
pending: {}
|
|
771
|
+
};
|
|
772
|
+
for (const locale of targets) {
|
|
773
|
+
const catalog = catalogs[locale] ??= {};
|
|
774
|
+
const missing = Object.keys(source).filter((key) => source[key] && !catalog[key]);
|
|
775
|
+
if (missing.length === 0) continue;
|
|
776
|
+
if (options.dryRun) {
|
|
777
|
+
result.pending[locale] = missing;
|
|
778
|
+
continue;
|
|
779
|
+
}
|
|
780
|
+
for (let i = 0; i < missing.length; i += batchSize) {
|
|
781
|
+
const keys = missing.slice(i, i + batchSize);
|
|
782
|
+
const messages = Object.fromEntries(keys.map((key) => [key, source[key]]));
|
|
783
|
+
const out = await provider({
|
|
784
|
+
sourceLocale: cfg.sourceLocale,
|
|
785
|
+
targetLocale: locale,
|
|
786
|
+
messages
|
|
787
|
+
});
|
|
788
|
+
for (const key of keys) {
|
|
789
|
+
const text = out[key];
|
|
790
|
+
if (typeof text === "string" && text.trim() && structureMatches(source[key], text)) {
|
|
791
|
+
catalog[key] = text;
|
|
792
|
+
(result.translated[locale] ??= []).push(key);
|
|
793
|
+
} else (result.invalid[locale] ??= []).push(key);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
return result;
|
|
798
|
+
}
|
|
799
|
+
function structureMatches(source, translated) {
|
|
800
|
+
return sameMembers(paramNames(source), paramNames(translated)) && sameMembers(tagTokens(source), tagTokens(translated));
|
|
801
|
+
}
|
|
802
|
+
function paramNames(message) {
|
|
803
|
+
try {
|
|
804
|
+
return [...collectParams(message).keys()].sort();
|
|
805
|
+
} catch {
|
|
806
|
+
return ["\0invalid"];
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
const TAG = /<(\/?)([a-zA-Z][\w-]*)(\/?)>/g;
|
|
810
|
+
function tagTokens(message) {
|
|
811
|
+
const out = [];
|
|
812
|
+
for (const match of message.matchAll(TAG)) out.push(`${match[1]}${match[2]}${match[3]}`);
|
|
813
|
+
return out.sort();
|
|
814
|
+
}
|
|
815
|
+
function sameMembers(a, b) {
|
|
816
|
+
return a.length === b.length && a.every((value, i) => value === b[i]);
|
|
817
|
+
}
|
|
818
|
+
//#endregion
|
|
819
|
+
//#region src/exchange.ts
|
|
820
|
+
function exportCatalogs(cfg, catalogs, options = {}) {
|
|
821
|
+
const format = options.format ?? "xliff";
|
|
822
|
+
const dir = resolve(cfg.root, options.out ?? "verbaly-export");
|
|
823
|
+
const source = catalogs[cfg.sourceLocale] ?? {};
|
|
824
|
+
const targets = (options.locales ?? cfg.locales).filter((locale) => locale !== cfg.sourceLocale && cfg.locales.includes(locale));
|
|
825
|
+
const files = [];
|
|
826
|
+
mkdirSync(dir, { recursive: true });
|
|
827
|
+
for (const locale of targets) {
|
|
828
|
+
const catalog = catalogs[locale] ?? {};
|
|
829
|
+
let entries = Object.keys(source).filter((key) => source[key]).sort().map((key) => ({
|
|
830
|
+
key,
|
|
831
|
+
source: source[key],
|
|
832
|
+
target: catalog[key] ?? ""
|
|
833
|
+
}));
|
|
834
|
+
if (options.missing) entries = entries.filter((entry) => !entry.target);
|
|
835
|
+
const path = join(dir, `${locale}.${format === "csv" ? "csv" : "xlf"}`);
|
|
836
|
+
writeFileSync(path, format === "csv" ? toCsv(entries) : toXliff(cfg.sourceLocale, locale, entries));
|
|
837
|
+
files.push({
|
|
838
|
+
locale,
|
|
839
|
+
path,
|
|
840
|
+
total: entries.length,
|
|
841
|
+
untranslated: entries.filter((entry) => !entry.target).length
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
return {
|
|
845
|
+
format,
|
|
846
|
+
dir,
|
|
847
|
+
files
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
function importCatalogs(cfg, catalogs, files, options = {}) {
|
|
851
|
+
const source = catalogs[cfg.sourceLocale] ?? {};
|
|
852
|
+
const result = {
|
|
853
|
+
imported: {},
|
|
854
|
+
rejected: {},
|
|
855
|
+
skipped: {},
|
|
856
|
+
unknown: {}
|
|
857
|
+
};
|
|
858
|
+
for (const file of files) {
|
|
859
|
+
const parsed = parseExchangeFile(file, options.locale);
|
|
860
|
+
const locale = parsed.locale;
|
|
861
|
+
if (!/^[a-zA-Z]{2,3}([-_][a-zA-Z0-9]+)*$/.test(locale)) throw new Error(`[verbaly] ${file}: "${locale}" doesn't look like a locale β pass --locale <id>.`);
|
|
862
|
+
if (locale === cfg.sourceLocale) throw new Error(`[verbaly] ${file} targets the source locale "${locale}" β import fills translations, not the source. Pass --locale if the detection is wrong.`);
|
|
863
|
+
const catalog = catalogs[locale] ??= {};
|
|
864
|
+
for (const [key, text] of Object.entries(parsed.entries)) {
|
|
865
|
+
if (!text.trim()) continue;
|
|
866
|
+
if (!source[key]) {
|
|
867
|
+
(result.unknown[locale] ??= []).push(key);
|
|
868
|
+
continue;
|
|
869
|
+
}
|
|
870
|
+
if (catalog[key] && !options.overwrite) {
|
|
871
|
+
(result.skipped[locale] ??= []).push(key);
|
|
872
|
+
continue;
|
|
873
|
+
}
|
|
874
|
+
if (!structureMatches(source[key], text)) {
|
|
875
|
+
(result.rejected[locale] ??= []).push(key);
|
|
876
|
+
continue;
|
|
877
|
+
}
|
|
878
|
+
if (!options.dryRun) catalog[key] = text;
|
|
879
|
+
(result.imported[locale] ??= []).push(key);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
return result;
|
|
883
|
+
}
|
|
884
|
+
function parseExchangeFile(file, localeOverride) {
|
|
885
|
+
const content = readFileSync(file, "utf8");
|
|
886
|
+
if (/\.(xlf|xliff)$/i.test(file)) {
|
|
887
|
+
const parsed = parseXliff(content);
|
|
888
|
+
const locale = localeOverride ?? parsed.locale;
|
|
889
|
+
if (!locale) throw new Error(`[verbaly] ${file} has no trgLang/target-language β pass --locale <id>.`);
|
|
890
|
+
return {
|
|
891
|
+
locale,
|
|
892
|
+
entries: parsed.entries
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
if (/\.csv$/i.test(file)) return {
|
|
896
|
+
locale: localeOverride ?? basename(file).replace(/\.csv$/i, ""),
|
|
897
|
+
entries: parseCsv(content)
|
|
898
|
+
};
|
|
899
|
+
throw new Error(`[verbaly] ${file}: unsupported format β expected .xlf, .xliff or .csv.`);
|
|
900
|
+
}
|
|
901
|
+
function toXliff(sourceLocale, locale, entries) {
|
|
902
|
+
const units = entries.map(({ key, source, target }) => [
|
|
903
|
+
` <unit id="${escapeXml(key)}">`,
|
|
904
|
+
` <segment state="${target ? "translated" : "initial"}">`,
|
|
905
|
+
` <source>${escapeXml(source)}</source>`,
|
|
906
|
+
` <target>${escapeXml(target)}</target>`,
|
|
907
|
+
" </segment>",
|
|
908
|
+
" </unit>"
|
|
909
|
+
].join("\n")).join("\n");
|
|
910
|
+
return [
|
|
911
|
+
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
|
|
912
|
+
`<xliff xmlns="urn:oasis:names:tc:xliff:document:2.0" version="2.0" srcLang="${escapeXml(sourceLocale)}" trgLang="${escapeXml(locale)}">`,
|
|
913
|
+
` <file id="verbaly" original="${escapeXml(`${locale}.json`)}">`,
|
|
914
|
+
units,
|
|
915
|
+
" </file>",
|
|
916
|
+
"</xliff>",
|
|
917
|
+
""
|
|
918
|
+
].join("\n");
|
|
919
|
+
}
|
|
920
|
+
function parseXliff(content) {
|
|
921
|
+
const locale = /\btrgLang\s*=\s*"([^"]+)"/.exec(content)?.[1] ?? /\btarget-language\s*=\s*"([^"]+)"/.exec(content)?.[1];
|
|
922
|
+
const entries = {};
|
|
923
|
+
for (const match of content.matchAll(/<(?:trans-)?unit\b([^>]*)>([\s\S]*?)<\/(?:trans-)?unit>/g)) {
|
|
924
|
+
const id = /\bid\s*=\s*"([^"]*)"/.exec(match[1])?.[1];
|
|
925
|
+
if (!id) continue;
|
|
926
|
+
const target = /<target\b[^>]*>([\s\S]*?)<\/target>/.exec(match[2])?.[1];
|
|
927
|
+
entries[unescapeXml(id)] = target === void 0 ? "" : unescapeXml(stripCdata(target));
|
|
928
|
+
}
|
|
929
|
+
return {
|
|
930
|
+
locale,
|
|
931
|
+
entries
|
|
932
|
+
};
|
|
933
|
+
}
|
|
934
|
+
function escapeXml(text) {
|
|
935
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
936
|
+
}
|
|
937
|
+
function stripCdata(text) {
|
|
938
|
+
return text.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, "$1");
|
|
939
|
+
}
|
|
940
|
+
function unescapeXml(text) {
|
|
941
|
+
return text.replace(/&(lt|gt|amp|quot|apos|#x?[0-9a-fA-F]+);/g, (_, entity) => {
|
|
942
|
+
if (entity === "lt") return "<";
|
|
943
|
+
if (entity === "gt") return ">";
|
|
944
|
+
if (entity === "amp") return "&";
|
|
945
|
+
if (entity === "quot") return "\"";
|
|
946
|
+
if (entity === "apos") return "'";
|
|
947
|
+
const code = entity.startsWith("#x") ? parseInt(entity.slice(2), 16) : parseInt(entity.slice(1), 10);
|
|
948
|
+
return String.fromCodePoint(code);
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
function toCsv(entries) {
|
|
952
|
+
return [
|
|
953
|
+
"key,source,target",
|
|
954
|
+
...entries.map(({ key, source, target }) => `${csvField(key)},${csvField(source)},${csvField(target)}`),
|
|
955
|
+
""
|
|
956
|
+
].join("\r\n");
|
|
957
|
+
}
|
|
958
|
+
function csvField(text) {
|
|
959
|
+
return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, "\"\"")}"` : text;
|
|
960
|
+
}
|
|
961
|
+
function parseCsv(content) {
|
|
962
|
+
const rows = csvRows(content);
|
|
963
|
+
const header = rows[0]?.map((cell) => cell.trim().toLowerCase()) ?? [];
|
|
964
|
+
const keyCol = header.indexOf("key");
|
|
965
|
+
const targetCol = header.indexOf("target");
|
|
966
|
+
if (keyCol === -1 || targetCol === -1) throw new Error("[verbaly] CSV needs a header row with \"key\" and \"target\" columns.");
|
|
967
|
+
const entries = {};
|
|
968
|
+
for (const row of rows.slice(1)) {
|
|
969
|
+
const key = row[keyCol];
|
|
970
|
+
if (key) entries[key] = row[targetCol] ?? "";
|
|
971
|
+
}
|
|
972
|
+
return entries;
|
|
973
|
+
}
|
|
974
|
+
function csvRows(content) {
|
|
975
|
+
const rows = [];
|
|
976
|
+
let row = [];
|
|
977
|
+
let field = "";
|
|
978
|
+
let quoted = false;
|
|
979
|
+
for (let i = 0; i < content.length; i++) {
|
|
980
|
+
const char = content[i];
|
|
981
|
+
if (quoted) if (char === "\"") if (content[i + 1] === "\"") {
|
|
982
|
+
field += "\"";
|
|
983
|
+
i++;
|
|
984
|
+
} else quoted = false;
|
|
985
|
+
else field += char;
|
|
986
|
+
else if (char === "\"") quoted = true;
|
|
987
|
+
else if (char === ",") {
|
|
988
|
+
row.push(field);
|
|
989
|
+
field = "";
|
|
990
|
+
} else if (char === "\n" || char === "\r") {
|
|
991
|
+
if (char === "\r" && content[i + 1] === "\n") i++;
|
|
992
|
+
row.push(field);
|
|
993
|
+
field = "";
|
|
994
|
+
if (row.some((cell) => cell !== "")) rows.push(row);
|
|
995
|
+
row = [];
|
|
996
|
+
} else field += char;
|
|
997
|
+
}
|
|
998
|
+
row.push(field);
|
|
999
|
+
if (row.some((cell) => cell !== "")) rows.push(row);
|
|
1000
|
+
return rows;
|
|
1001
|
+
}
|
|
1002
|
+
//#endregion
|
|
762
1003
|
//#region src/pseudo.ts
|
|
763
1004
|
const PSEUDO_LOCALE = "en-XA";
|
|
764
1005
|
const ACCENTS = {
|
|
@@ -1148,63 +1389,6 @@ function decodeEntities(text) {
|
|
|
1148
1389
|
return text.replace(/"/g, "\"").replace(/'/g, "'").replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&");
|
|
1149
1390
|
}
|
|
1150
1391
|
//#endregion
|
|
1151
|
-
//#region src/translate.ts
|
|
1152
|
-
async function translateCatalogs(cfg, catalogs, provider, options = {}) {
|
|
1153
|
-
const batchSize = options.batchSize ?? 20;
|
|
1154
|
-
const targets = (options.locales ?? cfg.locales).filter((locale) => locale !== cfg.sourceLocale && cfg.locales.includes(locale));
|
|
1155
|
-
const source = catalogs[cfg.sourceLocale] ?? {};
|
|
1156
|
-
const result = {
|
|
1157
|
-
translated: {},
|
|
1158
|
-
invalid: {},
|
|
1159
|
-
pending: {}
|
|
1160
|
-
};
|
|
1161
|
-
for (const locale of targets) {
|
|
1162
|
-
const catalog = catalogs[locale] ??= {};
|
|
1163
|
-
const missing = Object.keys(source).filter((key) => source[key] && !catalog[key]);
|
|
1164
|
-
if (missing.length === 0) continue;
|
|
1165
|
-
if (options.dryRun) {
|
|
1166
|
-
result.pending[locale] = missing;
|
|
1167
|
-
continue;
|
|
1168
|
-
}
|
|
1169
|
-
for (let i = 0; i < missing.length; i += batchSize) {
|
|
1170
|
-
const keys = missing.slice(i, i + batchSize);
|
|
1171
|
-
const messages = Object.fromEntries(keys.map((key) => [key, source[key]]));
|
|
1172
|
-
const out = await provider({
|
|
1173
|
-
sourceLocale: cfg.sourceLocale,
|
|
1174
|
-
targetLocale: locale,
|
|
1175
|
-
messages
|
|
1176
|
-
});
|
|
1177
|
-
for (const key of keys) {
|
|
1178
|
-
const text = out[key];
|
|
1179
|
-
if (typeof text === "string" && text.trim() && structureMatches(source[key], text)) {
|
|
1180
|
-
catalog[key] = text;
|
|
1181
|
-
(result.translated[locale] ??= []).push(key);
|
|
1182
|
-
} else (result.invalid[locale] ??= []).push(key);
|
|
1183
|
-
}
|
|
1184
|
-
}
|
|
1185
|
-
}
|
|
1186
|
-
return result;
|
|
1187
|
-
}
|
|
1188
|
-
function structureMatches(source, translated) {
|
|
1189
|
-
return sameMembers(paramNames(source), paramNames(translated)) && sameMembers(tagTokens(source), tagTokens(translated));
|
|
1190
|
-
}
|
|
1191
|
-
function paramNames(message) {
|
|
1192
|
-
try {
|
|
1193
|
-
return [...collectParams(message).keys()].sort();
|
|
1194
|
-
} catch {
|
|
1195
|
-
return ["\0invalid"];
|
|
1196
|
-
}
|
|
1197
|
-
}
|
|
1198
|
-
const TAG = /<(\/?)([a-zA-Z][\w-]*)(\/?)>/g;
|
|
1199
|
-
function tagTokens(message) {
|
|
1200
|
-
const out = [];
|
|
1201
|
-
for (const match of message.matchAll(TAG)) out.push(`${match[1]}${match[2]}${match[3]}`);
|
|
1202
|
-
return out.sort();
|
|
1203
|
-
}
|
|
1204
|
-
function sameMembers(a, b) {
|
|
1205
|
-
return a.length === b.length && a.every((value, i) => value === b[i]);
|
|
1206
|
-
}
|
|
1207
|
-
//#endregion
|
|
1208
1392
|
//#region src/cli.ts
|
|
1209
1393
|
const HELP = `verbaly β i18n compiler
|
|
1210
1394
|
|
|
@@ -1214,6 +1398,8 @@ Usage:
|
|
|
1214
1398
|
verbaly extract scan sources, update catalogs and types
|
|
1215
1399
|
verbaly check verify translations are complete (CI)
|
|
1216
1400
|
verbaly translate fill missing translations via a provider (default: claude)
|
|
1401
|
+
verbaly export write translator-ready files per locale (XLIFF 2.0 or CSV)
|
|
1402
|
+
verbaly import <filesβ¦> fill catalogs back from translated XLIFF/CSV files
|
|
1217
1403
|
verbaly pseudo generate a pseudo-locale catalog for i18n QA (default: en-XA)
|
|
1218
1404
|
verbaly render pre-fill data-verbaly HTML per locale (SSG, kills the FOUC)
|
|
1219
1405
|
|
|
@@ -1224,8 +1410,12 @@ Options:
|
|
|
1224
1410
|
--locales <csv> extra locales; for translate: target locales to fill
|
|
1225
1411
|
--prune drop keys no longer referenced (extract)
|
|
1226
1412
|
--model <id> model override for the claude provider (translate)
|
|
1227
|
-
--dry-run list what would
|
|
1228
|
-
--
|
|
1413
|
+
--dry-run list what would happen, write nothing (translate, import)
|
|
1414
|
+
--format <f> export format: xliff (default) or csv (export)
|
|
1415
|
+
--out <path> export directory (export, default: verbaly-export)
|
|
1416
|
+
--missing export only untranslated entries (export)
|
|
1417
|
+
--overwrite replace existing translations on import (import)
|
|
1418
|
+
--locale <id> pseudo-locale id (pseudo) / target-locale override (import)
|
|
1229
1419
|
--site <path> built site directory (render, default: dist)
|
|
1230
1420
|
--attribute <name> base data attribute (render, default: data-verbaly)
|
|
1231
1421
|
--base-url <url> site origin β enables hreflang alternates (render)
|
|
@@ -1252,6 +1442,10 @@ async function main() {
|
|
|
1252
1442
|
sitemap: { type: "boolean" },
|
|
1253
1443
|
clean: { type: "boolean" },
|
|
1254
1444
|
"dry-run": { type: "boolean" },
|
|
1445
|
+
format: { type: "string" },
|
|
1446
|
+
out: { type: "string" },
|
|
1447
|
+
missing: { type: "boolean" },
|
|
1448
|
+
overwrite: { type: "boolean" },
|
|
1255
1449
|
help: {
|
|
1256
1450
|
type: "boolean",
|
|
1257
1451
|
short: "h"
|
|
@@ -1354,6 +1548,51 @@ async function main() {
|
|
|
1354
1548
|
if (Object.keys(result.translated).length === 0 && Object.keys(result.invalid).length === 0) console.log("[verbaly] nothing to translate β");
|
|
1355
1549
|
return;
|
|
1356
1550
|
}
|
|
1551
|
+
if (command === "export") {
|
|
1552
|
+
const format = values.format ?? "xliff";
|
|
1553
|
+
if (format !== "xliff" && format !== "csv") {
|
|
1554
|
+
console.error(`[verbaly] unknown format "${values.format}" β use xliff or csv`);
|
|
1555
|
+
process.exitCode = 1;
|
|
1556
|
+
return;
|
|
1557
|
+
}
|
|
1558
|
+
const result = exportCatalogs(cfg, loadCatalogs(cfg), {
|
|
1559
|
+
locales: values.locales?.split(","),
|
|
1560
|
+
format,
|
|
1561
|
+
out: values.out,
|
|
1562
|
+
missing: values.missing
|
|
1563
|
+
});
|
|
1564
|
+
if (result.files.length === 0) {
|
|
1565
|
+
console.log("[verbaly] no target locales to export (add locales to your config)");
|
|
1566
|
+
return;
|
|
1567
|
+
}
|
|
1568
|
+
console.log(`[verbaly] exported ${result.files.length} locales (${result.format}) β ${result.dir}`);
|
|
1569
|
+
for (const file of result.files) console.log(` ${file.locale}: ${file.total} messages (${file.untranslated} untranslated) β ${file.path}`);
|
|
1570
|
+
return;
|
|
1571
|
+
}
|
|
1572
|
+
if (command === "import") {
|
|
1573
|
+
const files = positionals.slice(1);
|
|
1574
|
+
if (files.length === 0) {
|
|
1575
|
+
console.error("[verbaly] import needs at least one file: verbaly import verbaly-export/es.xlf");
|
|
1576
|
+
process.exitCode = 1;
|
|
1577
|
+
return;
|
|
1578
|
+
}
|
|
1579
|
+
const catalogs = loadCatalogs(cfg);
|
|
1580
|
+
const result = importCatalogs(cfg, catalogs, files, {
|
|
1581
|
+
locale: values.locale,
|
|
1582
|
+
overwrite: values.overwrite,
|
|
1583
|
+
dryRun: values["dry-run"]
|
|
1584
|
+
});
|
|
1585
|
+
for (const [locale, keys] of Object.entries(result.imported)) {
|
|
1586
|
+
if (!values["dry-run"]) writeCatalog(cfg, locale, catalogs[locale] ?? {});
|
|
1587
|
+
const verb = values["dry-run"] ? "would import" : "imported";
|
|
1588
|
+
console.log(` ${locale}: +${keys.length} ${verb}`);
|
|
1589
|
+
}
|
|
1590
|
+
for (const [locale, keys] of Object.entries(result.skipped)) console.log(` ${locale}: ${keys.length} already translated, kept (use --overwrite to replace)`);
|
|
1591
|
+
for (const [locale, keys] of Object.entries(result.rejected)) console.warn(` ${locale}: ${keys.length} rejected (params/tags not preserved): ${keys.join(", ")}`);
|
|
1592
|
+
for (const [locale, keys] of Object.entries(result.unknown)) console.warn(` ${locale}: ${keys.length} unknown keys ignored (not in the source catalog): ${keys.join(", ")}`);
|
|
1593
|
+
if (Object.keys(result.imported).length === 0) console.log("[verbaly] nothing to import β");
|
|
1594
|
+
return;
|
|
1595
|
+
}
|
|
1357
1596
|
if (command === "render") {
|
|
1358
1597
|
const result = await renderSite(cfg, {
|
|
1359
1598
|
site: values.site,
|