@verbaly/compiler 0.14.0 β†’ 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 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
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 = {
@@ -908,22 +1149,19 @@ function renderHtml(html, options) {
908
1149
  const richTags = new Set(options.richTags ?? RICH_TAGS);
909
1150
  const globalLinks = options.richLinks;
910
1151
  const sourceLocale = options.sourceLocale ?? "en";
911
- const messages = {};
912
- for (const [locale, catalog] of Object.entries(options.catalogs)) {
913
- const clean = {};
914
- for (const [key, msg] of Object.entries(catalog)) if (msg) clean[key] = msg;
915
- messages[locale] = clean;
916
- }
917
1152
  const v = createVerbaly({
918
1153
  locale: options.locale,
919
1154
  fallback: sourceLocale,
920
- messages
1155
+ messages: options.catalogs
921
1156
  });
922
1157
  const t = v.t;
923
1158
  const ms = new MagicString(html);
924
1159
  const missing = /* @__PURE__ */ new Set();
925
1160
  const skip = protectedRanges(html);
926
1161
  const inSkip = (index) => skip.some(([from, to]) => index >= from && index < to);
1162
+ const selfUrl = options.alternates?.find((a) => a.hreflang === options.locale)?.href;
1163
+ const sourceUrl = options.alternates?.find((a) => a.hreflang === "x-default")?.href;
1164
+ const rewriteUrl = selfUrl !== void 0 && selfUrl !== sourceUrl;
927
1165
  START_TAG.lastIndex = 0;
928
1166
  let m;
929
1167
  while ((m = START_TAG.exec(html)) !== null) {
@@ -937,6 +1175,8 @@ function renderHtml(html, options) {
937
1175
  continue;
938
1176
  }
939
1177
  const attrs = parseAttrs(attrChunk);
1178
+ if (rewriteUrl && tagName === "link" && attrs.get("rel")?.toLowerCase() === "canonical" && decodeEntities(attrs.get("href") ?? "") === sourceUrl) setAttribute(ms, html, chunkStart, openEnd, attrChunk, "href", selfUrl);
1179
+ if (rewriteUrl && tagName === "meta" && attrs.get("property") === "og:url" && decodeEntities(attrs.get("content") ?? "") === sourceUrl) setAttribute(ms, html, chunkStart, openEnd, attrChunk, "content", selfUrl);
940
1180
  const key = attrs.get(attr);
941
1181
  const attrMapRaw = attrs.get(attrsAttr);
942
1182
  if (key === void 0 && attrMapRaw === void 0) continue;
@@ -1149,63 +1389,6 @@ function decodeEntities(text) {
1149
1389
  return text.replace(/&quot;/g, "\"").replace(/&#39;/g, "'").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
1150
1390
  }
1151
1391
  //#endregion
1152
- //#region src/translate.ts
1153
- async function translateCatalogs(cfg, catalogs, provider, options = {}) {
1154
- const batchSize = options.batchSize ?? 20;
1155
- const targets = (options.locales ?? cfg.locales).filter((locale) => locale !== cfg.sourceLocale && cfg.locales.includes(locale));
1156
- const source = catalogs[cfg.sourceLocale] ?? {};
1157
- const result = {
1158
- translated: {},
1159
- invalid: {},
1160
- pending: {}
1161
- };
1162
- for (const locale of targets) {
1163
- const catalog = catalogs[locale] ??= {};
1164
- const missing = Object.keys(source).filter((key) => source[key] && !catalog[key]);
1165
- if (missing.length === 0) continue;
1166
- if (options.dryRun) {
1167
- result.pending[locale] = missing;
1168
- continue;
1169
- }
1170
- for (let i = 0; i < missing.length; i += batchSize) {
1171
- const keys = missing.slice(i, i + batchSize);
1172
- const messages = Object.fromEntries(keys.map((key) => [key, source[key]]));
1173
- const out = await provider({
1174
- sourceLocale: cfg.sourceLocale,
1175
- targetLocale: locale,
1176
- messages
1177
- });
1178
- for (const key of keys) {
1179
- const text = out[key];
1180
- if (typeof text === "string" && text.trim() && structureMatches(source[key], text)) {
1181
- catalog[key] = text;
1182
- (result.translated[locale] ??= []).push(key);
1183
- } else (result.invalid[locale] ??= []).push(key);
1184
- }
1185
- }
1186
- }
1187
- return result;
1188
- }
1189
- function structureMatches(source, translated) {
1190
- return sameMembers(paramNames(source), paramNames(translated)) && sameMembers(tagTokens(source), tagTokens(translated));
1191
- }
1192
- function paramNames(message) {
1193
- try {
1194
- return [...collectParams(message).keys()].sort();
1195
- } catch {
1196
- return ["\0invalid"];
1197
- }
1198
- }
1199
- const TAG = /<(\/?)([a-zA-Z][\w-]*)(\/?)>/g;
1200
- function tagTokens(message) {
1201
- const out = [];
1202
- for (const match of message.matchAll(TAG)) out.push(`${match[1]}${match[2]}${match[3]}`);
1203
- return out.sort();
1204
- }
1205
- function sameMembers(a, b) {
1206
- return a.length === b.length && a.every((value, i) => value === b[i]);
1207
- }
1208
- //#endregion
1209
1392
  //#region src/cli.ts
1210
1393
  const HELP = `verbaly β€” i18n compiler
1211
1394
 
@@ -1215,6 +1398,8 @@ Usage:
1215
1398
  verbaly extract scan sources, update catalogs and types
1216
1399
  verbaly check verify translations are complete (CI)
1217
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
1218
1403
  verbaly pseudo generate a pseudo-locale catalog for i18n QA (default: en-XA)
1219
1404
  verbaly render pre-fill data-verbaly HTML per locale (SSG, kills the FOUC)
1220
1405
 
@@ -1225,8 +1410,12 @@ Options:
1225
1410
  --locales <csv> extra locales; for translate: target locales to fill
1226
1411
  --prune drop keys no longer referenced (extract)
1227
1412
  --model <id> model override for the claude provider (translate)
1228
- --dry-run list what would be translated, write nothing (translate)
1229
- --locale <id> pseudo-locale id (pseudo, default: en-XA)
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)
1230
1419
  --site <path> built site directory (render, default: dist)
1231
1420
  --attribute <name> base data attribute (render, default: data-verbaly)
1232
1421
  --base-url <url> site origin β€” enables hreflang alternates (render)
@@ -1253,6 +1442,10 @@ async function main() {
1253
1442
  sitemap: { type: "boolean" },
1254
1443
  clean: { type: "boolean" },
1255
1444
  "dry-run": { type: "boolean" },
1445
+ format: { type: "string" },
1446
+ out: { type: "string" },
1447
+ missing: { type: "boolean" },
1448
+ overwrite: { type: "boolean" },
1256
1449
  help: {
1257
1450
  type: "boolean",
1258
1451
  short: "h"
@@ -1355,6 +1548,51 @@ async function main() {
1355
1548
  if (Object.keys(result.translated).length === 0 && Object.keys(result.invalid).length === 0) console.log("[verbaly] nothing to translate βœ“");
1356
1549
  return;
1357
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
+ }
1358
1596
  if (command === "render") {
1359
1597
  const result = await renderSite(cfg, {
1360
1598
  site: values.site,