@verbaly/compiler 0.8.0 → 0.10.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
@@ -21,10 +21,29 @@ The compiler behind [Verbaly](https://github.com/AronSoto/verbaly): AST extracti
21
21
  npx verbaly extract # sync catalogs + types
22
22
  npx verbaly check # exit 1 if anything is missing (CI)
23
23
  npx verbaly extract --prune # drop orphaned keys
24
+ npx verbaly translate # fill missing translations via Claude (or your provider)
25
+ npx verbaly pseudo # generate a pseudo-locale catalog for i18n QA (en-XA)
26
+ npx verbaly render # pre-fill data-verbaly HTML per locale (SSG — kills the FOUC)
24
27
  ```
25
28
 
26
29
  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.
27
30
 
31
+ ## Machine translation
32
+
33
+ `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`:
34
+
35
+ ```ts
36
+ translate: { provider: async ({ sourceLocale, targetLocale, messages }) => ({ ...translated }) }
37
+ ```
38
+
39
+ ## Static rendering (SSG)
40
+
41
+ `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.
42
+
43
+ ## Pseudo-localization
44
+
45
+ `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 — the same structural validation as `translate`.
46
+
28
47
  📖 Docs: **https://verbaly-web.vercel.app/docs/cli**
29
48
 
30
49
  > ⚠️ Early development (`0.x`) — API not stable yet.
@@ -0,0 +1,63 @@
1
+ // src/providers/claude.ts
2
+ var DEFAULT_MODEL = "claude-sonnet-5";
3
+ var SYSTEM = `You translate UI strings for the Verbaly i18n library.
4
+ Rules:
5
+ - Translate only the human-readable text, naturally for the target locale.
6
+ - 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 {{ }} || ##.
7
+ - Keys are opaque identifiers: return exactly the same keys, never translate or rename them.`;
8
+ function claudeProvider(options = {}) {
9
+ return async (request) => {
10
+ const Anthropic = await loadSdk();
11
+ const client = new Anthropic(options.apiKey ? { apiKey: options.apiKey } : {});
12
+ const response = await client.messages.create({
13
+ model: options.model ?? DEFAULT_MODEL,
14
+ max_tokens: options.maxTokens ?? 16e3,
15
+ thinking: { type: "disabled" },
16
+ system: SYSTEM,
17
+ messages: [{ role: "user", content: buildPrompt(request) }],
18
+ output_config: { format: batchFormat(request) }
19
+ });
20
+ const text = response.content.find((block) => block.type === "text")?.text ?? "{}";
21
+ return JSON.parse(text);
22
+ };
23
+ }
24
+ function buildPrompt(request) {
25
+ return `Translate each value from "${request.sourceLocale}" to "${request.targetLocale}". Return a JSON object with the same keys and translated values.
26
+
27
+ ` + JSON.stringify(request.messages, null, 2);
28
+ }
29
+ function batchFormat(request) {
30
+ const keys = Object.keys(request.messages);
31
+ return {
32
+ type: "json_schema",
33
+ schema: {
34
+ type: "object",
35
+ properties: Object.fromEntries(keys.map((key) => [key, { type: "string" }])),
36
+ required: keys,
37
+ additionalProperties: false
38
+ }
39
+ };
40
+ }
41
+ async function loadSdk() {
42
+ try {
43
+ const mod = await import("@anthropic-ai/sdk");
44
+ return mod.default;
45
+ } catch (error) {
46
+ if (isModuleNotFound(error, "@anthropic-ai/sdk")) {
47
+ throw new Error(
48
+ "[verbaly] the claude translate provider needs @anthropic-ai/sdk \u2014 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",
49
+ { cause: error }
50
+ );
51
+ }
52
+ throw error;
53
+ }
54
+ }
55
+ function isModuleNotFound(error, name) {
56
+ return error instanceof Error && error.code === "ERR_MODULE_NOT_FOUND" && error.message.includes(name);
57
+ }
58
+ export {
59
+ batchFormat,
60
+ buildPrompt,
61
+ claudeProvider
62
+ };
63
+ //# sourceMappingURL=claude-RQATA6OI.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/providers/claude.ts"],"sourcesContent":["import type { TranslateProvider, TranslateRequest } from '../translate';\r\n\r\nexport interface ClaudeProviderOptions {\r\n model?: string;\r\n apiKey?: string;\r\n maxTokens?: number;\r\n}\r\n\r\n// balanced default for translation\r\nconst DEFAULT_MODEL = 'claude-sonnet-5';\r\n\r\nconst SYSTEM = `You translate UI strings for the Verbaly i18n library.\r\nRules:\r\n- Translate only the human-readable text, naturally for the target locale.\r\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 {{ }} || ##.\r\n- Keys are opaque identifiers: return exactly the same keys, never translate or rename them.`;\r\n\r\nexport function claudeProvider(options: ClaudeProviderOptions = {}): TranslateProvider {\r\n return async (request: TranslateRequest) => {\r\n const Anthropic = await loadSdk();\r\n const client = new Anthropic(options.apiKey ? { apiKey: options.apiKey } : {});\r\n const response = await client.messages.create({\r\n model: options.model ?? DEFAULT_MODEL,\r\n max_tokens: options.maxTokens ?? 16000,\r\n thinking: { type: 'disabled' },\r\n system: SYSTEM,\r\n messages: [{ role: 'user', content: buildPrompt(request) }],\r\n output_config: { format: batchFormat(request) },\r\n });\r\n const text = response.content.find((block) => block.type === 'text')?.text ?? '{}';\r\n return JSON.parse(text) as Record<string, string>;\r\n };\r\n}\r\n\r\nexport function buildPrompt(request: TranslateRequest): string {\r\n return (\r\n `Translate each value from \"${request.sourceLocale}\" to \"${request.targetLocale}\". ` +\r\n `Return a JSON object with the same keys and translated values.\\n\\n` +\r\n JSON.stringify(request.messages, null, 2)\r\n );\r\n}\r\n\r\nexport function batchFormat(request: TranslateRequest) {\r\n const keys = Object.keys(request.messages);\r\n return {\r\n type: 'json_schema' as const,\r\n schema: {\r\n type: 'object',\r\n properties: Object.fromEntries(keys.map((key) => [key, { type: 'string' }])),\r\n required: keys,\r\n additionalProperties: false,\r\n },\r\n };\r\n}\r\n\r\nasync function loadSdk(): Promise<typeof import('@anthropic-ai/sdk').default> {\r\n try {\r\n const mod = await import('@anthropic-ai/sdk');\r\n return mod.default;\r\n } catch (error) {\r\n if (isModuleNotFound(error, '@anthropic-ai/sdk')) {\r\n throw new Error(\r\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',\r\n { cause: error },\r\n );\r\n }\r\n throw error;\r\n }\r\n}\r\n\r\nfunction isModuleNotFound(error: unknown, name: string): boolean {\r\n return (\r\n error instanceof Error &&\r\n (error as { code?: string }).code === 'ERR_MODULE_NOT_FOUND' &&\r\n error.message.includes(name)\r\n );\r\n}\r\n"],"mappings":";AASA,IAAM,gBAAgB;AAEtB,IAAM,SAAS;AAAA;AAAA;AAAA;AAAA;AAMR,SAAS,eAAe,UAAiC,CAAC,GAAsB;AACrF,SAAO,OAAO,YAA8B;AAC1C,UAAM,YAAY,MAAM,QAAQ;AAChC,UAAM,SAAS,IAAI,UAAU,QAAQ,SAAS,EAAE,QAAQ,QAAQ,OAAO,IAAI,CAAC,CAAC;AAC7E,UAAM,WAAW,MAAM,OAAO,SAAS,OAAO;AAAA,MAC5C,OAAO,QAAQ,SAAS;AAAA,MACxB,YAAY,QAAQ,aAAa;AAAA,MACjC,UAAU,EAAE,MAAM,WAAW;AAAA,MAC7B,QAAQ;AAAA,MACR,UAAU,CAAC,EAAE,MAAM,QAAQ,SAAS,YAAY,OAAO,EAAE,CAAC;AAAA,MAC1D,eAAe,EAAE,QAAQ,YAAY,OAAO,EAAE;AAAA,IAChD,CAAC;AACD,UAAM,OAAO,SAAS,QAAQ,KAAK,CAAC,UAAU,MAAM,SAAS,MAAM,GAAG,QAAQ;AAC9E,WAAO,KAAK,MAAM,IAAI;AAAA,EACxB;AACF;AAEO,SAAS,YAAY,SAAmC;AAC7D,SACE,8BAA8B,QAAQ,YAAY,SAAS,QAAQ,YAAY;AAAA;AAAA,IAE/E,KAAK,UAAU,QAAQ,UAAU,MAAM,CAAC;AAE5C;AAEO,SAAS,YAAY,SAA2B;AACrD,QAAM,OAAO,OAAO,KAAK,QAAQ,QAAQ;AACzC,SAAO;AAAA,IACL,MAAM;AAAA,IACN,QAAQ;AAAA,MACN,MAAM;AAAA,MACN,YAAY,OAAO,YAAY,KAAK,IAAI,CAAC,QAAQ,CAAC,KAAK,EAAE,MAAM,SAAS,CAAC,CAAC,CAAC;AAAA,MAC3E,UAAU;AAAA,MACV,sBAAsB;AAAA,IACxB;AAAA,EACF;AACF;AAEA,eAAe,UAA+D;AAC5E,MAAI;AACF,UAAM,MAAM,MAAM,OAAO,mBAAmB;AAC5C,WAAO,IAAI;AAAA,EACb,SAAS,OAAO;AACd,QAAI,iBAAiB,OAAO,mBAAmB,GAAG;AAChD,YAAM,IAAI;AAAA,QACR;AAAA,QACA,EAAE,OAAO,MAAM;AAAA,MACjB;AAAA,IACF;AACA,UAAM;AAAA,EACR;AACF;AAEA,SAAS,iBAAiB,OAAgB,MAAuB;AAC/D,SACE,iBAAiB,SAChB,MAA4B,SAAS,0BACtC,MAAM,QAAQ,SAAS,IAAI;AAE/B;","names":[]}
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { parseArgs } from "util";
4
+ import { parseArgs as parseArgs2 } from "util";
5
5
 
6
6
  // src/catalog.ts
7
7
  import { mkdirSync, readFileSync, writeFileSync } from "fs";
@@ -194,7 +194,8 @@ function resolveConfig(config = {}) {
194
194
  sourceLocale,
195
195
  locales: [...locales],
196
196
  include: config.include ?? ["src/**/*.{js,jsx,ts,tsx,mjs,mts}"],
197
- exclude: config.exclude ?? ["**/node_modules/**", "**/dist/**"]
197
+ exclude: config.exclude ?? ["**/node_modules/**", "**/dist/**"],
198
+ translate: config.translate ?? {}
198
199
  };
199
200
  }
200
201
  async function loadConfigFile(root) {
@@ -579,24 +580,424 @@ function pruneCatalogs(cfg, catalogs, registry) {
579
580
  return removed;
580
581
  }
581
582
 
583
+ // src/pseudo.ts
584
+ var PSEUDO_LOCALE = "en-XA";
585
+ var ACCENTS = {
586
+ a: "\xE1",
587
+ b: "\u0180",
588
+ c: "\xE7",
589
+ d: "\u0111",
590
+ e: "\xE9",
591
+ f: "\u0192",
592
+ g: "\u011F",
593
+ h: "\u0125",
594
+ i: "\xED",
595
+ j: "\u0135",
596
+ k: "\u0137",
597
+ l: "\u013A",
598
+ m: "\u0271",
599
+ n: "\xF1",
600
+ o: "\xF3",
601
+ p: "\xFE",
602
+ q: "\u01EB",
603
+ r: "\u0155",
604
+ s: "\u0161",
605
+ t: "\u0163",
606
+ u: "\xFA",
607
+ v: "\u1E7D",
608
+ w: "\u0175",
609
+ x: "\u1E8B",
610
+ y: "\xFD",
611
+ z: "\u017E",
612
+ A: "\xC1",
613
+ B: "\u0181",
614
+ C: "\xC7",
615
+ D: "\u0110",
616
+ E: "\xC9",
617
+ F: "\u0191",
618
+ G: "\u011E",
619
+ H: "\u0124",
620
+ I: "\xCD",
621
+ J: "\u0134",
622
+ K: "\u0136",
623
+ L: "\u0139",
624
+ M: "\u1E3E",
625
+ N: "\xD1",
626
+ O: "\xD3",
627
+ P: "\xDE",
628
+ Q: "\u01EA",
629
+ R: "\u0154",
630
+ S: "\u0160",
631
+ T: "\u0162",
632
+ U: "\xDA",
633
+ V: "\u1E7C",
634
+ W: "\u0174",
635
+ X: "\u1E8C",
636
+ Y: "\xDD",
637
+ Z: "\u017D"
638
+ };
639
+ var TAG_AT = /<\/?[a-zA-Z][\w-]*\/?>/y;
640
+ function pseudoLocalize(message) {
641
+ let out = "";
642
+ let letters = 0;
643
+ let i = 0;
644
+ while (i < message.length) {
645
+ const two = message.slice(i, i + 2);
646
+ if (two === "{{" || two === "}}" || two === "||" || two === "##") {
647
+ out += two;
648
+ i += 2;
649
+ continue;
650
+ }
651
+ const ch = message[i];
652
+ if (ch === "{") {
653
+ const end = matchBrace(message, i);
654
+ out += message.slice(i, end);
655
+ i = end;
656
+ continue;
657
+ }
658
+ if (ch === "<") {
659
+ TAG_AT.lastIndex = i;
660
+ const m = TAG_AT.exec(message);
661
+ if (m) {
662
+ out += m[0];
663
+ i += m[0].length;
664
+ continue;
665
+ }
666
+ }
667
+ const mapped = ACCENTS[ch];
668
+ if (mapped) {
669
+ out += mapped;
670
+ letters += 1;
671
+ } else {
672
+ out += ch;
673
+ }
674
+ i += 1;
675
+ }
676
+ const pad = "~".repeat(Math.ceil(letters / 3));
677
+ return `\u27E6${out}${pad ? " " + pad : ""}\u27E7`;
678
+ }
679
+ function matchBrace(message, start) {
680
+ let depth = 0;
681
+ for (let i = start; i < message.length; i++) {
682
+ const two = message.slice(i, i + 2);
683
+ if (two === "{{" || two === "}}") {
684
+ i += 1;
685
+ continue;
686
+ }
687
+ const ch = message[i];
688
+ if (ch === "{") depth += 1;
689
+ else if (ch === "}") {
690
+ depth -= 1;
691
+ if (depth === 0) return i + 1;
692
+ }
693
+ }
694
+ return message.length;
695
+ }
696
+ function pseudoCatalogs(cfg, catalogs, locale = PSEUDO_LOCALE) {
697
+ const source = catalogs[cfg.sourceLocale] ?? {};
698
+ const target = {};
699
+ for (const [key, msg] of Object.entries(source)) {
700
+ target[key] = msg ? pseudoLocalize(msg) : "";
701
+ }
702
+ catalogs[locale] = target;
703
+ return Object.keys(target).filter((key) => target[key]);
704
+ }
705
+
706
+ // src/render.ts
707
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
708
+ import { dirname, join as join4, relative } from "path";
709
+ import MagicString from "magic-string";
710
+ import { glob as glob2 } from "tinyglobby";
711
+ import { createVerbaly, parseTags, RICH_TAGS } from "verbaly";
712
+ var VOID_TAGS = /* @__PURE__ */ new Set([
713
+ "area",
714
+ "base",
715
+ "br",
716
+ "col",
717
+ "embed",
718
+ "hr",
719
+ "img",
720
+ "input",
721
+ "link",
722
+ "meta",
723
+ "param",
724
+ "source",
725
+ "track",
726
+ "wbr"
727
+ ]);
728
+ var START_TAG = /<([a-zA-Z][a-zA-Z0-9-]*)((?:"[^"]*"|'[^']*'|[^"'>])*)>/g;
729
+ var ATTR = /([^\s=/"'<>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
730
+ function renderHtml(html, options) {
731
+ const attr = options.attribute ?? "data-verbaly";
732
+ const argsAttr = `${attr}-args`;
733
+ const attrsAttr = `${attr}-attr`;
734
+ const richAttr = `${attr}-rich`;
735
+ const richTags = new Set(options.richTags ?? RICH_TAGS);
736
+ const sourceLocale = options.sourceLocale ?? "en";
737
+ const messages = {};
738
+ for (const [locale, catalog] of Object.entries(options.catalogs)) {
739
+ const clean = {};
740
+ for (const [key, msg] of Object.entries(catalog)) if (msg) clean[key] = msg;
741
+ messages[locale] = clean;
742
+ }
743
+ const v = createVerbaly({ locale: options.locale, fallback: sourceLocale, messages });
744
+ const t = v.t;
745
+ const ms = new MagicString(html);
746
+ const missing = /* @__PURE__ */ new Set();
747
+ const skip = protectedRanges(html);
748
+ const inSkip = (index) => skip.some(([from, to]) => index >= from && index < to);
749
+ START_TAG.lastIndex = 0;
750
+ let m;
751
+ while ((m = START_TAG.exec(html)) !== null) {
752
+ if (inSkip(m.index)) continue;
753
+ const [full, rawName, attrChunk] = m;
754
+ const tagName = rawName.toLowerCase();
755
+ const openEnd = m.index + full.length;
756
+ const chunkStart = m.index + 1 + rawName.length;
757
+ if (tagName === "html" && options.setLang !== false) {
758
+ setAttribute(ms, html, chunkStart, openEnd, attrChunk, "lang", options.locale);
759
+ continue;
760
+ }
761
+ const attrs = parseAttrs(attrChunk);
762
+ const key = attrs.get(attr);
763
+ const attrMapRaw = attrs.get(attrsAttr);
764
+ if (key === void 0 && attrMapRaw === void 0) continue;
765
+ const args = parseArgs(attrs.get(argsAttr));
766
+ if (key) {
767
+ if (!v.has(key)) {
768
+ missing.add(key);
769
+ } else if (!VOID_TAGS.has(tagName) && !attrChunk.trimEnd().endsWith("/")) {
770
+ const close = findClose(html, tagName, openEnd, inSkip);
771
+ if (close) {
772
+ const text = t(key, args);
773
+ const content = attrs.has(richAttr) ? richToHtml(parseTags(text), richTags) : escapeHtml(text);
774
+ if (html.slice(openEnd, close.contentEnd) !== content) {
775
+ if (openEnd === close.contentEnd) ms.appendLeft(openEnd, content);
776
+ else ms.overwrite(openEnd, close.contentEnd, content);
777
+ }
778
+ }
779
+ }
780
+ }
781
+ if (attrMapRaw !== void 0) {
782
+ const map = parseArgs(attrMapRaw);
783
+ if (map) {
784
+ for (const [name, attrKey] of Object.entries(map)) {
785
+ if (typeof attrKey !== "string" || name.toLowerCase().startsWith("on")) continue;
786
+ if (!v.has(attrKey)) {
787
+ missing.add(attrKey);
788
+ continue;
789
+ }
790
+ setAttribute(ms, html, chunkStart, openEnd, attrChunk, name, t(attrKey, args));
791
+ }
792
+ }
793
+ }
794
+ }
795
+ return { html: ms.toString(), missing: [...missing] };
796
+ }
797
+ async function renderSite(cfg, options = {}) {
798
+ const site = join4(cfg.root, options.site ?? "dist");
799
+ const locales = options.locales ?? cfg.locales;
800
+ const catalogs = loadCatalogs(cfg);
801
+ const files = await glob2("**/*.html", {
802
+ cwd: site,
803
+ absolute: true,
804
+ ignore: locales.map((locale) => `${locale}/**`)
805
+ });
806
+ const missing = {};
807
+ for (const file of files) {
808
+ const html = readFileSync4(file, "utf8");
809
+ const rel = relative(site, file);
810
+ for (const locale of locales) {
811
+ const result = renderHtml(html, {
812
+ locale,
813
+ catalogs,
814
+ sourceLocale: cfg.sourceLocale,
815
+ attribute: options.attribute,
816
+ richTags: options.richTags
817
+ });
818
+ for (const key of result.missing) {
819
+ const list = missing[locale] ??= [];
820
+ if (!list.includes(key)) list.push(key);
821
+ }
822
+ const out = locale === cfg.sourceLocale ? file : join4(site, locale, rel);
823
+ mkdirSync2(dirname(out), { recursive: true });
824
+ writeFileSync3(out, result.html);
825
+ }
826
+ }
827
+ return { files: files.length, locales: [...locales], missing };
828
+ }
829
+ function parseAttrs(chunk) {
830
+ const attrs = /* @__PURE__ */ new Map();
831
+ ATTR.lastIndex = 0;
832
+ let m;
833
+ while ((m = ATTR.exec(chunk)) !== null) {
834
+ attrs.set(m[1].toLowerCase(), m[2] ?? m[3] ?? m[4] ?? "");
835
+ }
836
+ return attrs;
837
+ }
838
+ function parseArgs(raw) {
839
+ if (!raw) return void 0;
840
+ try {
841
+ return JSON.parse(decodeEntities(raw));
842
+ } catch {
843
+ console.warn(`[verbaly] invalid args JSON: ${raw}`);
844
+ return void 0;
845
+ }
846
+ }
847
+ function protectedRanges(html) {
848
+ const ranges = [];
849
+ const re = /<!--[\s\S]*?-->|<script\b[\s\S]*?<\/script\s*>|<style\b[\s\S]*?<\/style\s*>/gi;
850
+ let m;
851
+ while ((m = re.exec(html)) !== null) {
852
+ const openEnd = m[0].startsWith("<!--") ? m.index : html.indexOf(">", m.index) + 1;
853
+ ranges.push([openEnd, m.index + m[0].length]);
854
+ }
855
+ return ranges;
856
+ }
857
+ function findClose(html, tagName, from, inSkip) {
858
+ const re = new RegExp(
859
+ `<${tagName}(?=[\\s/>])(?:"[^"]*"|'[^']*'|[^"'>])*>|</${tagName}\\s*>`,
860
+ "gi"
861
+ );
862
+ re.lastIndex = from;
863
+ let depth = 1;
864
+ let m;
865
+ while ((m = re.exec(html)) !== null) {
866
+ if (inSkip(m.index)) continue;
867
+ if (m[0][1] === "/") {
868
+ depth -= 1;
869
+ if (depth === 0) return { contentEnd: m.index };
870
+ } else if (!m[0].endsWith("/>")) {
871
+ depth += 1;
872
+ }
873
+ }
874
+ return null;
875
+ }
876
+ function setAttribute(ms, html, chunkStart, openEnd, attrChunk, name, value) {
877
+ const escaped = escapeAttr(value);
878
+ const existing = new RegExp(`(\\s${name}\\s*=\\s*)("[^"]*"|'[^']*'|[^\\s>]+)`, "i").exec(
879
+ attrChunk
880
+ );
881
+ if (existing) {
882
+ const valueStart = chunkStart + existing.index + existing[1].length;
883
+ const valueEnd = valueStart + existing[2].length;
884
+ if (html.slice(valueStart, valueEnd) !== `"${escaped}"`) {
885
+ ms.overwrite(valueStart, valueEnd, `"${escaped}"`);
886
+ }
887
+ return;
888
+ }
889
+ const selfClosing = html.slice(openEnd - 2, openEnd) === "/>";
890
+ const insertAt = openEnd - (selfClosing ? 2 : 1);
891
+ ms.appendLeft(insertAt, ` ${name}="${escaped}"`);
892
+ }
893
+ function richToHtml(nodes, allowed) {
894
+ let out = "";
895
+ for (const node of nodes) {
896
+ if (typeof node === "string") {
897
+ out += escapeHtml(node);
898
+ } else if (allowed.has(node.name)) {
899
+ out += `<${node.name}>${richToHtml(node.children, allowed)}</${node.name}>`;
900
+ } else {
901
+ out += richToHtml(node.children, allowed);
902
+ }
903
+ }
904
+ return out;
905
+ }
906
+ function escapeHtml(text) {
907
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
908
+ }
909
+ function escapeAttr(text) {
910
+ return escapeHtml(text).replace(/"/g, "&quot;");
911
+ }
912
+ function decodeEntities(text) {
913
+ return text.replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
914
+ }
915
+
916
+ // src/translate.ts
917
+ async function translateCatalogs(cfg, catalogs, provider, options = {}) {
918
+ const batchSize = options.batchSize ?? 20;
919
+ const targets = (options.locales ?? cfg.locales).filter(
920
+ (locale) => locale !== cfg.sourceLocale && cfg.locales.includes(locale)
921
+ );
922
+ const source = catalogs[cfg.sourceLocale] ?? {};
923
+ const result = { translated: {}, invalid: {}, pending: {} };
924
+ for (const locale of targets) {
925
+ const catalog = catalogs[locale] ??= {};
926
+ const missing = Object.keys(source).filter((key) => source[key] && !catalog[key]);
927
+ if (missing.length === 0) continue;
928
+ if (options.dryRun) {
929
+ result.pending[locale] = missing;
930
+ continue;
931
+ }
932
+ for (let i = 0; i < missing.length; i += batchSize) {
933
+ const keys = missing.slice(i, i + batchSize);
934
+ const messages = Object.fromEntries(keys.map((key) => [key, source[key]]));
935
+ const out = await provider({
936
+ sourceLocale: cfg.sourceLocale,
937
+ targetLocale: locale,
938
+ messages
939
+ });
940
+ for (const key of keys) {
941
+ const text = out[key];
942
+ if (typeof text === "string" && text.trim() && structureMatches(source[key], text)) {
943
+ catalog[key] = text;
944
+ (result.translated[locale] ??= []).push(key);
945
+ } else {
946
+ (result.invalid[locale] ??= []).push(key);
947
+ }
948
+ }
949
+ }
950
+ }
951
+ return result;
952
+ }
953
+ function structureMatches(source, translated) {
954
+ return sameMembers(paramNames(source), paramNames(translated)) && sameMembers(tagTokens(source), tagTokens(translated));
955
+ }
956
+ function paramNames(message) {
957
+ try {
958
+ return [...collectParams(message).keys()].sort();
959
+ } catch {
960
+ return ["\0invalid"];
961
+ }
962
+ }
963
+ var TAG = /<(\/?)([a-zA-Z][\w-]*)(\/?)>/g;
964
+ function tagTokens(message) {
965
+ const out = [];
966
+ for (const match of message.matchAll(TAG)) {
967
+ out.push(`${match[1]}${match[2]}${match[3]}`);
968
+ }
969
+ return out.sort();
970
+ }
971
+ function sameMembers(a, b) {
972
+ return a.length === b.length && a.every((value, i) => value === b[i]);
973
+ }
974
+
582
975
  // src/cli.ts
583
976
  var HELP = `verbaly \u2014 i18n compiler
584
977
 
585
978
  Usage:
586
- verbaly extract scan sources, update catalogs and types
587
- verbaly check verify translations are complete (CI)
979
+ verbaly extract scan sources, update catalogs and types
980
+ verbaly check verify translations are complete (CI)
981
+ verbaly translate fill missing translations via a provider (default: claude)
982
+ verbaly pseudo generate a pseudo-locale catalog for i18n QA (default: en-XA)
983
+ verbaly render pre-fill data-verbaly HTML per locale (SSG, kills the FOUC)
588
984
 
589
985
  Options:
590
986
  --root <path> project root (default: cwd)
591
987
  --dir <path> catalogs directory (default: locales)
592
988
  --source <locale> source locale (default: en)
593
- --locales <csv> extra locales (default: from catalog files)
989
+ --locales <csv> extra locales; for translate: target locales to fill
594
990
  --prune drop keys no longer referenced (extract)
991
+ --model <id> model override for the claude provider (translate)
992
+ --dry-run list what would be translated, write nothing (translate)
993
+ --locale <id> pseudo-locale id (pseudo, default: en-XA)
994
+ --site <path> built site directory (render, default: dist)
595
995
 
596
- Config file: verbaly.config.{js,mjs,json} at root (flags win).
996
+ Config file: verbaly.config.{js,mjs,ts,mts,json} at root (flags win).
997
+ The claude provider needs @anthropic-ai/sdk installed and ANTHROPIC_API_KEY set.
597
998
  `;
598
999
  async function main() {
599
- const { positionals, values } = parseArgs({
1000
+ const { positionals, values } = parseArgs2({
600
1001
  allowPositionals: true,
601
1002
  options: {
602
1003
  root: { type: "string" },
@@ -604,6 +1005,10 @@ async function main() {
604
1005
  source: { type: "string" },
605
1006
  locales: { type: "string" },
606
1007
  prune: { type: "boolean" },
1008
+ model: { type: "string" },
1009
+ locale: { type: "string" },
1010
+ site: { type: "string" },
1011
+ "dry-run": { type: "boolean" },
607
1012
  help: { type: "boolean", short: "h" }
608
1013
  }
609
1014
  });
@@ -651,10 +1056,70 @@ ${formatCheckResult(result)}`);
651
1056
  process.exitCode = 1;
652
1057
  return;
653
1058
  }
1059
+ if (command === "translate") {
1060
+ const catalogs = loadCatalogs(cfg);
1061
+ const provider = await resolveProvider(cfg, values.model);
1062
+ const result = await translateCatalogs(cfg, catalogs, provider, {
1063
+ locales: values.locales?.split(","),
1064
+ batchSize: cfg.translate.batchSize,
1065
+ dryRun: values["dry-run"]
1066
+ });
1067
+ if (values["dry-run"]) {
1068
+ const entries = Object.entries(result.pending);
1069
+ if (entries.length === 0) {
1070
+ console.log("[verbaly] nothing to translate \u2713");
1071
+ return;
1072
+ }
1073
+ for (const [locale, keys] of entries) {
1074
+ console.log(` ${locale}: ${keys.length} missing \u2014 ${keys.join(", ")}`);
1075
+ }
1076
+ return;
1077
+ }
1078
+ for (const locale of Object.keys(result.translated)) {
1079
+ writeCatalog(cfg, locale, catalogs[locale] ?? {});
1080
+ console.log(` ${locale}: +${result.translated[locale].length} translated`);
1081
+ }
1082
+ for (const [locale, keys] of Object.entries(result.invalid)) {
1083
+ console.warn(
1084
+ ` ${locale}: ${keys.length} rejected (params/tags not preserved): ${keys.join(", ")}`
1085
+ );
1086
+ }
1087
+ if (Object.keys(result.translated).length === 0 && Object.keys(result.invalid).length === 0) {
1088
+ console.log("[verbaly] nothing to translate \u2713");
1089
+ }
1090
+ return;
1091
+ }
1092
+ if (command === "render") {
1093
+ const result = await renderSite(cfg, {
1094
+ site: values.site,
1095
+ locales: values.locales?.split(",")
1096
+ });
1097
+ console.log(
1098
+ `[verbaly] ${result.files} pages \xD7 ${result.locales.length} locales (${result.locales.join(", ")})`
1099
+ );
1100
+ for (const [locale, keys] of Object.entries(result.missing)) {
1101
+ console.warn(` ${locale}: ${keys.length} keys not pre-filled \u2014 ${keys.join(", ")}`);
1102
+ }
1103
+ return;
1104
+ }
1105
+ if (command === "pseudo") {
1106
+ const catalogs = loadCatalogs(cfg);
1107
+ const locale = values.locale ?? PSEUDO_LOCALE;
1108
+ const keys = pseudoCatalogs(cfg, catalogs, locale);
1109
+ writeCatalog(cfg, locale, catalogs[locale] ?? {});
1110
+ console.log(`[verbaly] ${keys.length} messages pseudo-localized \u2192 ${locale}`);
1111
+ return;
1112
+ }
654
1113
  console.error(`[verbaly] unknown command "${command}"
655
1114
  ${HELP}`);
656
1115
  process.exitCode = 1;
657
1116
  }
1117
+ async function resolveProvider(cfg, model) {
1118
+ const configured = cfg.translate.provider;
1119
+ if (typeof configured === "function") return configured;
1120
+ const { claudeProvider } = await import("./claude-RQATA6OI.js");
1121
+ return claudeProvider({ model: model ?? cfg.translate.model });
1122
+ }
658
1123
  main().catch((error) => {
659
1124
  console.error("[verbaly]", error instanceof Error ? error.message : error);
660
1125
  process.exitCode = 1;