@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 +19 -0
- package/dist/claude-RQATA6OI.js +63 -0
- package/dist/claude-RQATA6OI.js.map +1 -0
- package/dist/cli.js +472 -7
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +64 -1
- package/dist/index.js +462 -3
- package/dist/index.js.map +1 -1
- package/package.json +7 -2
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
908
|
+
}
|
|
909
|
+
function escapeAttr(text) {
|
|
910
|
+
return escapeHtml(text).replace(/"/g, """);
|
|
911
|
+
}
|
|
912
|
+
function decodeEntities(text) {
|
|
913
|
+
return text.replace(/"/g, '"').replace(/'/g, "'").replace(/</g, "<").replace(/>/g, ">").replace(/&/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
|
|
587
|
-
verbaly check
|
|
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
|
|
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 } =
|
|
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;
|