@verbaly/compiler 0.9.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
@@ -22,6 +22,8 @@ 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
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)
25
27
  ```
26
28
 
27
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.
@@ -34,6 +36,14 @@ Reads `verbaly.config.{js,mjs,ts,mts,json}` (TS configs need `esbuild` installed
34
36
  translate: { provider: async ({ sourceLocale, targetLocale, messages }) => ({ ...translated }) }
35
37
  ```
36
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
+
37
47
  📖 Docs: **https://verbaly-web.vercel.app/docs/cli**
38
48
 
39
49
  > ⚠️ Early development (`0.x`) — API not stable yet.
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";
@@ -580,6 +580,339 @@ function pruneCatalogs(cfg, catalogs, registry) {
580
580
  return removed;
581
581
  }
582
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
+
583
916
  // src/translate.ts
584
917
  async function translateCatalogs(cfg, catalogs, provider, options = {}) {
585
918
  const batchSize = options.batchSize ?? 20;
@@ -646,6 +979,8 @@ Usage:
646
979
  verbaly extract scan sources, update catalogs and types
647
980
  verbaly check verify translations are complete (CI)
648
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)
649
984
 
650
985
  Options:
651
986
  --root <path> project root (default: cwd)
@@ -655,12 +990,14 @@ Options:
655
990
  --prune drop keys no longer referenced (extract)
656
991
  --model <id> model override for the claude provider (translate)
657
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)
658
995
 
659
996
  Config file: verbaly.config.{js,mjs,ts,mts,json} at root (flags win).
660
997
  The claude provider needs @anthropic-ai/sdk installed and ANTHROPIC_API_KEY set.
661
998
  `;
662
999
  async function main() {
663
- const { positionals, values } = parseArgs({
1000
+ const { positionals, values } = parseArgs2({
664
1001
  allowPositionals: true,
665
1002
  options: {
666
1003
  root: { type: "string" },
@@ -669,6 +1006,8 @@ async function main() {
669
1006
  locales: { type: "string" },
670
1007
  prune: { type: "boolean" },
671
1008
  model: { type: "string" },
1009
+ locale: { type: "string" },
1010
+ site: { type: "string" },
672
1011
  "dry-run": { type: "boolean" },
673
1012
  help: { type: "boolean", short: "h" }
674
1013
  }
@@ -750,6 +1089,27 @@ ${formatCheckResult(result)}`);
750
1089
  }
751
1090
  return;
752
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
+ }
753
1113
  console.error(`[verbaly] unknown command "${command}"
754
1114
  ${HELP}`);
755
1115
  process.exitCode = 1;