@verbaly/compiler 0.9.0 → 0.11.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/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";
@@ -195,7 +195,8 @@ function resolveConfig(config = {}) {
195
195
  locales: [...locales],
196
196
  include: config.include ?? ["src/**/*.{js,jsx,ts,tsx,mjs,mts}"],
197
197
  exclude: config.exclude ?? ["**/node_modules/**", "**/dist/**"],
198
- translate: config.translate ?? {}
198
+ translate: config.translate ?? {},
199
+ render: config.render ?? {}
199
200
  };
200
201
  }
201
202
  async function loadConfigFile(root) {
@@ -580,6 +581,357 @@ function pruneCatalogs(cfg, catalogs, registry) {
580
581
  return removed;
581
582
  }
582
583
 
584
+ // src/pseudo.ts
585
+ var PSEUDO_LOCALE = "en-XA";
586
+ var ACCENTS = {
587
+ a: "\xE1",
588
+ b: "\u0180",
589
+ c: "\xE7",
590
+ d: "\u0111",
591
+ e: "\xE9",
592
+ f: "\u0192",
593
+ g: "\u011F",
594
+ h: "\u0125",
595
+ i: "\xED",
596
+ j: "\u0135",
597
+ k: "\u0137",
598
+ l: "\u013A",
599
+ m: "\u0271",
600
+ n: "\xF1",
601
+ o: "\xF3",
602
+ p: "\xFE",
603
+ q: "\u01EB",
604
+ r: "\u0155",
605
+ s: "\u0161",
606
+ t: "\u0163",
607
+ u: "\xFA",
608
+ v: "\u1E7D",
609
+ w: "\u0175",
610
+ x: "\u1E8B",
611
+ y: "\xFD",
612
+ z: "\u017E",
613
+ A: "\xC1",
614
+ B: "\u0181",
615
+ C: "\xC7",
616
+ D: "\u0110",
617
+ E: "\xC9",
618
+ F: "\u0191",
619
+ G: "\u011E",
620
+ H: "\u0124",
621
+ I: "\xCD",
622
+ J: "\u0134",
623
+ K: "\u0136",
624
+ L: "\u0139",
625
+ M: "\u1E3E",
626
+ N: "\xD1",
627
+ O: "\xD3",
628
+ P: "\xDE",
629
+ Q: "\u01EA",
630
+ R: "\u0154",
631
+ S: "\u0160",
632
+ T: "\u0162",
633
+ U: "\xDA",
634
+ V: "\u1E7C",
635
+ W: "\u0174",
636
+ X: "\u1E8C",
637
+ Y: "\xDD",
638
+ Z: "\u017D"
639
+ };
640
+ var TAG_AT = /<\/?[a-zA-Z][\w-]*\/?>/y;
641
+ function pseudoLocalize(message) {
642
+ let out = "";
643
+ let letters = 0;
644
+ let i = 0;
645
+ while (i < message.length) {
646
+ const two = message.slice(i, i + 2);
647
+ if (two === "{{" || two === "}}" || two === "||" || two === "##") {
648
+ out += two;
649
+ i += 2;
650
+ continue;
651
+ }
652
+ const ch = message[i];
653
+ if (ch === "{") {
654
+ const end = matchBrace(message, i);
655
+ out += message.slice(i, end);
656
+ i = end;
657
+ continue;
658
+ }
659
+ if (ch === "<") {
660
+ TAG_AT.lastIndex = i;
661
+ const m = TAG_AT.exec(message);
662
+ if (m) {
663
+ out += m[0];
664
+ i += m[0].length;
665
+ continue;
666
+ }
667
+ }
668
+ const mapped = ACCENTS[ch];
669
+ if (mapped) {
670
+ out += mapped;
671
+ letters += 1;
672
+ } else {
673
+ out += ch;
674
+ }
675
+ i += 1;
676
+ }
677
+ const pad = "~".repeat(Math.ceil(letters / 3));
678
+ return `\u27E6${out}${pad ? " " + pad : ""}\u27E7`;
679
+ }
680
+ function matchBrace(message, start) {
681
+ let depth = 0;
682
+ for (let i = start; i < message.length; i++) {
683
+ const two = message.slice(i, i + 2);
684
+ if (two === "{{" || two === "}}") {
685
+ i += 1;
686
+ continue;
687
+ }
688
+ const ch = message[i];
689
+ if (ch === "{") depth += 1;
690
+ else if (ch === "}") {
691
+ depth -= 1;
692
+ if (depth === 0) return i + 1;
693
+ }
694
+ }
695
+ return message.length;
696
+ }
697
+ function pseudoCatalogs(cfg, catalogs, locale = PSEUDO_LOCALE) {
698
+ const source = catalogs[cfg.sourceLocale] ?? {};
699
+ const target = {};
700
+ for (const [key, msg] of Object.entries(source)) {
701
+ target[key] = msg ? pseudoLocalize(msg) : "";
702
+ }
703
+ catalogs[locale] = target;
704
+ return Object.keys(target).filter((key) => target[key]);
705
+ }
706
+
707
+ // src/render.ts
708
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
709
+ import { dirname, join as join4, relative } from "path";
710
+ import MagicString from "magic-string";
711
+ import { glob as glob2 } from "tinyglobby";
712
+ import {
713
+ createVerbaly,
714
+ parseTags,
715
+ RICH_TAGS,
716
+ safeHref
717
+ } from "verbaly";
718
+ var VOID_TAGS = /* @__PURE__ */ new Set([
719
+ "area",
720
+ "base",
721
+ "br",
722
+ "col",
723
+ "embed",
724
+ "hr",
725
+ "img",
726
+ "input",
727
+ "link",
728
+ "meta",
729
+ "param",
730
+ "source",
731
+ "track",
732
+ "wbr"
733
+ ]);
734
+ var START_TAG = /<([a-zA-Z][a-zA-Z0-9-]*)((?:"[^"]*"|'[^']*'|[^"'>])*)>/g;
735
+ var ATTR = /([^\s=/"'<>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
736
+ function renderHtml(html, options) {
737
+ const attr = options.attribute ?? "data-verbaly";
738
+ const argsAttr = `${attr}-args`;
739
+ const attrsAttr = `${attr}-attr`;
740
+ const richAttr = `${attr}-rich`;
741
+ const linksAttr = `${attr}-links`;
742
+ const richTags = new Set(options.richTags ?? RICH_TAGS);
743
+ const globalLinks = options.richLinks;
744
+ const sourceLocale = options.sourceLocale ?? "en";
745
+ const messages = {};
746
+ for (const [locale, catalog] of Object.entries(options.catalogs)) {
747
+ const clean = {};
748
+ for (const [key, msg] of Object.entries(catalog)) if (msg) clean[key] = msg;
749
+ messages[locale] = clean;
750
+ }
751
+ const v = createVerbaly({ locale: options.locale, fallback: sourceLocale, messages });
752
+ const t = v.t;
753
+ const ms = new MagicString(html);
754
+ const missing = /* @__PURE__ */ new Set();
755
+ const skip = protectedRanges(html);
756
+ const inSkip = (index) => skip.some(([from, to]) => index >= from && index < to);
757
+ START_TAG.lastIndex = 0;
758
+ let m;
759
+ while ((m = START_TAG.exec(html)) !== null) {
760
+ if (inSkip(m.index)) continue;
761
+ const [full, rawName, attrChunk] = m;
762
+ const tagName = rawName.toLowerCase();
763
+ const openEnd = m.index + full.length;
764
+ const chunkStart = m.index + 1 + rawName.length;
765
+ if (tagName === "html" && options.setLang !== false) {
766
+ setAttribute(ms, html, chunkStart, openEnd, attrChunk, "lang", options.locale);
767
+ continue;
768
+ }
769
+ const attrs = parseAttrs(attrChunk);
770
+ const key = attrs.get(attr);
771
+ const attrMapRaw = attrs.get(attrsAttr);
772
+ if (key === void 0 && attrMapRaw === void 0) continue;
773
+ const args = parseArgs(attrs.get(argsAttr));
774
+ if (key) {
775
+ if (!v.has(key)) {
776
+ missing.add(key);
777
+ } else if (!VOID_TAGS.has(tagName) && !attrChunk.trimEnd().endsWith("/")) {
778
+ const close = findClose(html, tagName, openEnd, inSkip);
779
+ if (close) {
780
+ const text = t(key, args);
781
+ const own = parseArgs(attrs.get(linksAttr));
782
+ const links = own ? globalLinks ? { ...globalLinks, ...own } : own : globalLinks;
783
+ const content = attrs.has(richAttr) ? richToHtml(parseTags(text), richTags, links) : escapeHtml(text);
784
+ if (html.slice(openEnd, close.contentEnd) !== content) {
785
+ if (openEnd === close.contentEnd) ms.appendLeft(openEnd, content);
786
+ else ms.overwrite(openEnd, close.contentEnd, content);
787
+ }
788
+ }
789
+ }
790
+ }
791
+ if (attrMapRaw !== void 0) {
792
+ const map = parseArgs(attrMapRaw);
793
+ if (map) {
794
+ for (const [name, attrKey] of Object.entries(map)) {
795
+ if (typeof attrKey !== "string" || name.toLowerCase().startsWith("on")) continue;
796
+ if (!v.has(attrKey)) {
797
+ missing.add(attrKey);
798
+ continue;
799
+ }
800
+ setAttribute(ms, html, chunkStart, openEnd, attrChunk, name, t(attrKey, args));
801
+ }
802
+ }
803
+ }
804
+ }
805
+ return { html: ms.toString(), missing: [...missing] };
806
+ }
807
+ async function renderSite(cfg, options = {}) {
808
+ const site = join4(cfg.root, options.site ?? "dist");
809
+ const locales = options.locales ?? cfg.locales;
810
+ const catalogs = loadCatalogs(cfg);
811
+ const files = await glob2("**/*.html", {
812
+ cwd: site,
813
+ absolute: true,
814
+ ignore: locales.map((locale) => `${locale}/**`)
815
+ });
816
+ const missing = {};
817
+ for (const file of files) {
818
+ const html = readFileSync4(file, "utf8");
819
+ const rel = relative(site, file);
820
+ for (const locale of locales) {
821
+ const result = renderHtml(html, {
822
+ locale,
823
+ catalogs,
824
+ sourceLocale: cfg.sourceLocale,
825
+ attribute: options.attribute,
826
+ richTags: options.richTags,
827
+ richLinks: options.richLinks ?? cfg.render.links
828
+ });
829
+ for (const key of result.missing) {
830
+ const list = missing[locale] ??= [];
831
+ if (!list.includes(key)) list.push(key);
832
+ }
833
+ const out = locale === cfg.sourceLocale ? file : join4(site, locale, rel);
834
+ mkdirSync2(dirname(out), { recursive: true });
835
+ writeFileSync3(out, result.html);
836
+ }
837
+ }
838
+ return { files: files.length, locales: [...locales], missing };
839
+ }
840
+ function parseAttrs(chunk) {
841
+ const attrs = /* @__PURE__ */ new Map();
842
+ ATTR.lastIndex = 0;
843
+ let m;
844
+ while ((m = ATTR.exec(chunk)) !== null) {
845
+ attrs.set(m[1].toLowerCase(), m[2] ?? m[3] ?? m[4] ?? "");
846
+ }
847
+ return attrs;
848
+ }
849
+ function parseArgs(raw) {
850
+ if (!raw) return void 0;
851
+ try {
852
+ return JSON.parse(decodeEntities(raw));
853
+ } catch {
854
+ console.warn(`[verbaly] invalid args JSON: ${raw}`);
855
+ return void 0;
856
+ }
857
+ }
858
+ function protectedRanges(html) {
859
+ const ranges = [];
860
+ const re = /<!--[\s\S]*?-->|<script\b[\s\S]*?<\/script\s*>|<style\b[\s\S]*?<\/style\s*>/gi;
861
+ let m;
862
+ while ((m = re.exec(html)) !== null) {
863
+ const openEnd = m[0].startsWith("<!--") ? m.index : html.indexOf(">", m.index) + 1;
864
+ ranges.push([openEnd, m.index + m[0].length]);
865
+ }
866
+ return ranges;
867
+ }
868
+ function findClose(html, tagName, from, inSkip) {
869
+ const re = new RegExp(
870
+ `<${tagName}(?=[\\s/>])(?:"[^"]*"|'[^']*'|[^"'>])*>|</${tagName}\\s*>`,
871
+ "gi"
872
+ );
873
+ re.lastIndex = from;
874
+ let depth = 1;
875
+ let m;
876
+ while ((m = re.exec(html)) !== null) {
877
+ if (inSkip(m.index)) continue;
878
+ if (m[0][1] === "/") {
879
+ depth -= 1;
880
+ if (depth === 0) return { contentEnd: m.index };
881
+ } else if (!m[0].endsWith("/>")) {
882
+ depth += 1;
883
+ }
884
+ }
885
+ return null;
886
+ }
887
+ function setAttribute(ms, html, chunkStart, openEnd, attrChunk, name, value) {
888
+ const escaped = escapeAttr(value);
889
+ const existing = new RegExp(`(\\s${name}\\s*=\\s*)("[^"]*"|'[^']*'|[^\\s>]+)`, "i").exec(
890
+ attrChunk
891
+ );
892
+ if (existing) {
893
+ const valueStart = chunkStart + existing.index + existing[1].length;
894
+ const valueEnd = valueStart + existing[2].length;
895
+ if (html.slice(valueStart, valueEnd) !== `"${escaped}"`) {
896
+ ms.overwrite(valueStart, valueEnd, `"${escaped}"`);
897
+ }
898
+ return;
899
+ }
900
+ const selfClosing = html.slice(openEnd - 2, openEnd) === "/>";
901
+ const insertAt = openEnd - (selfClosing ? 2 : 1);
902
+ ms.appendLeft(insertAt, ` ${name}="${escaped}"`);
903
+ }
904
+ function richToHtml(nodes, allowed, links) {
905
+ let out = "";
906
+ for (const node of nodes) {
907
+ if (typeof node === "string") {
908
+ out += escapeHtml(node);
909
+ } else if (links?.[node.name] !== void 0) {
910
+ const link = links[node.name];
911
+ const { href, target, rel } = typeof link === "string" ? { href: link } : link;
912
+ const safe = safeHref(href);
913
+ let attrs = safe !== void 0 ? ` href="${escapeAttr(safe)}"` : "";
914
+ if (target) attrs += ` target="${escapeAttr(target)}"`;
915
+ if (rel) attrs += ` rel="${escapeAttr(rel)}"`;
916
+ out += `<a${attrs}>${richToHtml(node.children, allowed, links)}</a>`;
917
+ } else if (allowed.has(node.name)) {
918
+ out += `<${node.name}>${richToHtml(node.children, allowed, links)}</${node.name}>`;
919
+ } else {
920
+ out += richToHtml(node.children, allowed, links);
921
+ }
922
+ }
923
+ return out;
924
+ }
925
+ function escapeHtml(text) {
926
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
927
+ }
928
+ function escapeAttr(text) {
929
+ return escapeHtml(text).replace(/"/g, "&quot;");
930
+ }
931
+ function decodeEntities(text) {
932
+ return text.replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
933
+ }
934
+
583
935
  // src/translate.ts
584
936
  async function translateCatalogs(cfg, catalogs, provider, options = {}) {
585
937
  const batchSize = options.batchSize ?? 20;
@@ -646,6 +998,8 @@ Usage:
646
998
  verbaly extract scan sources, update catalogs and types
647
999
  verbaly check verify translations are complete (CI)
648
1000
  verbaly translate fill missing translations via a provider (default: claude)
1001
+ verbaly pseudo generate a pseudo-locale catalog for i18n QA (default: en-XA)
1002
+ verbaly render pre-fill data-verbaly HTML per locale (SSG, kills the FOUC)
649
1003
 
650
1004
  Options:
651
1005
  --root <path> project root (default: cwd)
@@ -655,12 +1009,14 @@ Options:
655
1009
  --prune drop keys no longer referenced (extract)
656
1010
  --model <id> model override for the claude provider (translate)
657
1011
  --dry-run list what would be translated, write nothing (translate)
1012
+ --locale <id> pseudo-locale id (pseudo, default: en-XA)
1013
+ --site <path> built site directory (render, default: dist)
658
1014
 
659
1015
  Config file: verbaly.config.{js,mjs,ts,mts,json} at root (flags win).
660
1016
  The claude provider needs @anthropic-ai/sdk installed and ANTHROPIC_API_KEY set.
661
1017
  `;
662
1018
  async function main() {
663
- const { positionals, values } = parseArgs({
1019
+ const { positionals, values } = parseArgs2({
664
1020
  allowPositionals: true,
665
1021
  options: {
666
1022
  root: { type: "string" },
@@ -669,6 +1025,8 @@ async function main() {
669
1025
  locales: { type: "string" },
670
1026
  prune: { type: "boolean" },
671
1027
  model: { type: "string" },
1028
+ locale: { type: "string" },
1029
+ site: { type: "string" },
672
1030
  "dry-run": { type: "boolean" },
673
1031
  help: { type: "boolean", short: "h" }
674
1032
  }
@@ -750,6 +1108,27 @@ ${formatCheckResult(result)}`);
750
1108
  }
751
1109
  return;
752
1110
  }
1111
+ if (command === "render") {
1112
+ const result = await renderSite(cfg, {
1113
+ site: values.site,
1114
+ locales: values.locales?.split(",")
1115
+ });
1116
+ console.log(
1117
+ `[verbaly] ${result.files} pages \xD7 ${result.locales.length} locales (${result.locales.join(", ")})`
1118
+ );
1119
+ for (const [locale, keys] of Object.entries(result.missing)) {
1120
+ console.warn(` ${locale}: ${keys.length} keys not pre-filled \u2014 ${keys.join(", ")}`);
1121
+ }
1122
+ return;
1123
+ }
1124
+ if (command === "pseudo") {
1125
+ const catalogs = loadCatalogs(cfg);
1126
+ const locale = values.locale ?? PSEUDO_LOCALE;
1127
+ const keys = pseudoCatalogs(cfg, catalogs, locale);
1128
+ writeCatalog(cfg, locale, catalogs[locale] ?? {});
1129
+ console.log(`[verbaly] ${keys.length} messages pseudo-localized \u2192 ${locale}`);
1130
+ return;
1131
+ }
753
1132
  console.error(`[verbaly] unknown command "${command}"
754
1133
  ${HELP}`);
755
1134
  process.exitCode = 1;
@@ -757,7 +1136,7 @@ ${HELP}`);
757
1136
  async function resolveProvider(cfg, model) {
758
1137
  const configured = cfg.translate.provider;
759
1138
  if (typeof configured === "function") return configured;
760
- const { claudeProvider } = await import("./claude-RQATA6OI.js");
1139
+ const { claudeProvider } = await import("./claude-XSRCRBAQ.js");
761
1140
  return claudeProvider({ model: model ?? cfg.translate.model });
762
1141
  }
763
1142
  main().catch((error) => {