@verbaly/compiler 0.10.0 → 0.12.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/LICENSE +21 -201
- package/README.md +65 -53
- package/dist/claude-BhP-eK5E.js +53 -0
- package/dist/claude-BhP-eK5E.js.map +1 -0
- package/dist/cli.js +1018 -955
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +138 -99
- package/dist/index.js +972 -949
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/dist/claude-RQATA6OI.js +0 -63
- package/dist/claude-RQATA6OI.js.map +0 -1
package/dist/cli.js
CHANGED
|
@@ -1,145 +1,151 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
2
|
+
import { parseArgs } from "node:util";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
5
|
+
import { RICH_TAGS, createVerbaly, parse, parseTags, safeHref } from "verbaly";
|
|
6
|
+
import { pathToFileURL } from "node:url";
|
|
7
|
+
import { glob } from "tinyglobby";
|
|
8
|
+
import { parse as parse$1 } from "@babel/parser";
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import MagicString from "magic-string";
|
|
11
|
+
//#region src/catalog.ts
|
|
9
12
|
function catalogPath(cfg, locale) {
|
|
10
|
-
|
|
13
|
+
return join(cfg.dir, `${locale}.json`);
|
|
11
14
|
}
|
|
12
15
|
function readCatalog(cfg, locale) {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(readFileSync(catalogPath(cfg, locale), "utf8"));
|
|
18
|
+
} catch {
|
|
19
|
+
return {};
|
|
20
|
+
}
|
|
18
21
|
}
|
|
19
22
|
function loadCatalogs(cfg) {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
+
const catalogs = {};
|
|
24
|
+
for (const locale of cfg.locales) catalogs[locale] = readCatalog(cfg, locale);
|
|
25
|
+
return catalogs;
|
|
23
26
|
}
|
|
24
27
|
function serializeCatalog(catalog) {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
const sorted = {};
|
|
29
|
+
for (const key of Object.keys(catalog).sort()) {
|
|
30
|
+
const value = catalog[key];
|
|
31
|
+
if (value !== void 0) sorted[key] = value;
|
|
32
|
+
}
|
|
33
|
+
return JSON.stringify(sorted, null, 2) + "\n";
|
|
31
34
|
}
|
|
32
35
|
function writeCatalog(cfg, locale, catalog) {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
36
|
+
const serialized = serializeCatalog(catalog);
|
|
37
|
+
mkdirSync(cfg.dir, { recursive: true });
|
|
38
|
+
writeFileSync(catalogPath(cfg, locale), serialized);
|
|
39
|
+
return serialized;
|
|
37
40
|
}
|
|
38
|
-
|
|
39
|
-
|
|
41
|
+
//#endregion
|
|
42
|
+
//#region src/check.ts
|
|
40
43
|
function check(cfg, catalogs, registry) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
44
|
+
const source = catalogs[cfg.sourceLocale] ?? {};
|
|
45
|
+
const extracted = registry.messages();
|
|
46
|
+
const unknown = [];
|
|
47
|
+
for (const [key, files] of registry.usedKeys()) if (!(extracted.has(key) || cfg.locales.some((locale) => catalogs[locale]?.[key] !== void 0))) unknown.push({
|
|
48
|
+
key,
|
|
49
|
+
files
|
|
50
|
+
});
|
|
51
|
+
const needed = /* @__PURE__ */ new Set([...extracted.keys(), ...Object.keys(source)]);
|
|
52
|
+
const missing = [];
|
|
53
|
+
for (const key of extracted.keys()) if (!source[key]) missing.push({
|
|
54
|
+
locale: cfg.sourceLocale,
|
|
55
|
+
key,
|
|
56
|
+
source: extracted.get(key)?.message
|
|
57
|
+
});
|
|
58
|
+
for (const locale of cfg.locales) {
|
|
59
|
+
if (locale === cfg.sourceLocale) continue;
|
|
60
|
+
for (const key of needed) if (!catalogs[locale]?.[key]) missing.push({
|
|
61
|
+
locale,
|
|
62
|
+
key,
|
|
63
|
+
source: source[key] ?? extracted.get(key)?.message
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
ok: missing.length === 0 && unknown.length === 0,
|
|
68
|
+
missing,
|
|
69
|
+
unknown
|
|
70
|
+
};
|
|
64
71
|
}
|
|
65
72
|
function formatCheckResult(result) {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
}
|
|
80
|
-
return lines.join("\n");
|
|
73
|
+
const lines = [];
|
|
74
|
+
if (result.missing.length > 0) {
|
|
75
|
+
lines.push("missing translations:");
|
|
76
|
+
for (const entry of result.missing) {
|
|
77
|
+
const hint = entry.source ? ` — "${truncate(entry.source, 40)}"` : "";
|
|
78
|
+
lines.push(` [${entry.locale}] ${entry.key}${hint}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (result.unknown.length > 0) {
|
|
82
|
+
lines.push("unknown keys (not in any catalog):");
|
|
83
|
+
for (const entry of result.unknown) lines.push(` ${entry.key} — used in ${entry.files.join(", ")}`);
|
|
84
|
+
}
|
|
85
|
+
return lines.join("\n");
|
|
81
86
|
}
|
|
82
87
|
function truncate(text, max) {
|
|
83
|
-
|
|
88
|
+
return text.length > max ? text.slice(0, max - 1) + "…" : text;
|
|
84
89
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
90
|
+
//#endregion
|
|
91
|
+
//#region src/params.ts
|
|
92
|
+
const PLURAL_KEYS = /* @__PURE__ */ new Set([
|
|
93
|
+
"zero",
|
|
94
|
+
"one",
|
|
95
|
+
"two",
|
|
96
|
+
"few",
|
|
97
|
+
"many"
|
|
98
|
+
]);
|
|
99
|
+
const NUMBER_FORMATS = /* @__PURE__ */ new Set([
|
|
100
|
+
"number",
|
|
101
|
+
"integer",
|
|
102
|
+
"percent",
|
|
103
|
+
"currency"
|
|
104
|
+
]);
|
|
105
|
+
const DATE_FORMATS = /* @__PURE__ */ new Set(["date", "time"]);
|
|
95
106
|
function collectParams(message) {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
107
|
+
const out = /* @__PURE__ */ new Map();
|
|
108
|
+
visit(parse(message), out);
|
|
109
|
+
return out;
|
|
99
110
|
}
|
|
100
111
|
function visit(nodes, out) {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
} else {
|
|
117
|
-
types.add("unknown");
|
|
118
|
-
}
|
|
119
|
-
}
|
|
112
|
+
for (const node of nodes) {
|
|
113
|
+
if (node.kind !== "param") continue;
|
|
114
|
+
const types = out.get(node.name) ?? /* @__PURE__ */ new Set();
|
|
115
|
+
out.set(node.name, types);
|
|
116
|
+
if (node.variants) {
|
|
117
|
+
for (const [key, body] of node.variants) {
|
|
118
|
+
if (PLURAL_KEYS.has(key) || /^=\d+$/.test(key)) types.add("number");
|
|
119
|
+
else if (key !== "other") types.add("string");
|
|
120
|
+
visit(body, out);
|
|
121
|
+
}
|
|
122
|
+
if (types.size === 0) types.add("string");
|
|
123
|
+
} else if (node.format && NUMBER_FORMATS.has(node.format)) types.add("number");
|
|
124
|
+
else if (node.format && DATE_FORMATS.has(node.format)) types.add("date");
|
|
125
|
+
else types.add("unknown");
|
|
126
|
+
}
|
|
120
127
|
}
|
|
121
128
|
function renderParamType(types) {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
129
|
+
if (types.has("unknown") || types.size === 0) return "unknown";
|
|
130
|
+
const parts = [];
|
|
131
|
+
if (types.has("number")) parts.push("number");
|
|
132
|
+
if (types.has("string")) parts.push("string");
|
|
133
|
+
if (types.has("date")) parts.push("Date | number | string");
|
|
134
|
+
return parts.join(" | ");
|
|
128
135
|
}
|
|
129
|
-
|
|
130
|
-
|
|
136
|
+
//#endregion
|
|
137
|
+
//#region src/codegen.ts
|
|
131
138
|
function generateDts(entries) {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
return `// generated by verbaly \u2014 do not edit
|
|
139
|
+
const lines = [];
|
|
140
|
+
for (const [key, message] of [...entries.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
141
|
+
const params = collectParams(message);
|
|
142
|
+
if (params.size === 0) lines.push(` ${JSON.stringify(key)}: never;`);
|
|
143
|
+
else {
|
|
144
|
+
const fields = [...params.entries()].map(([name, types]) => `${JSON.stringify(name)}: ${renderParamType(types)}`).join("; ");
|
|
145
|
+
lines.push(` ${JSON.stringify(key)}: { ${fields} };`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return `// generated by verbaly — do not edit
|
|
143
149
|
declare module 'virtual:verbaly' {
|
|
144
150
|
export interface VerbalyMessages {
|
|
145
151
|
${lines.join("\n")}
|
|
@@ -171,811 +177,870 @@ declare module 'virtual:verbaly/locale/*' {
|
|
|
171
177
|
`;
|
|
172
178
|
}
|
|
173
179
|
function writeDts(cfg, entries) {
|
|
174
|
-
|
|
180
|
+
writeFileSync(join(cfg.root, "verbaly.d.ts"), generateDts(entries));
|
|
175
181
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
import { existsSync, readFileSync as readFileSync2, readdirSync } from "fs";
|
|
179
|
-
import { join as join3, resolve } from "path";
|
|
180
|
-
import { pathToFileURL } from "url";
|
|
182
|
+
//#endregion
|
|
183
|
+
//#region src/config.ts
|
|
181
184
|
function resolveConfig(config = {}) {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
};
|
|
185
|
+
const root = resolve(config.root ?? process.cwd());
|
|
186
|
+
const dir = resolve(root, config.dir ?? "locales");
|
|
187
|
+
const sourceLocale = config.sourceLocale ?? "en";
|
|
188
|
+
const locales = /* @__PURE__ */ new Set([sourceLocale, ...config.locales ?? []]);
|
|
189
|
+
if (existsSync(dir)) {
|
|
190
|
+
for (const file of readdirSync(dir)) if (file.endsWith(".json")) locales.add(file.slice(0, -5));
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
root,
|
|
194
|
+
dir,
|
|
195
|
+
sourceLocale,
|
|
196
|
+
locales: [...locales],
|
|
197
|
+
include: config.include ?? ["src/**/*.{js,jsx,ts,tsx,mjs,mts}"],
|
|
198
|
+
exclude: config.exclude ?? ["**/node_modules/**", "**/dist/**"],
|
|
199
|
+
translate: config.translate ?? {},
|
|
200
|
+
render: config.render ?? {}
|
|
201
|
+
};
|
|
200
202
|
}
|
|
201
203
|
async function loadConfigFile(root) {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
const jsonPath = join3(root, "verbaly.config.json");
|
|
214
|
-
if (existsSync(jsonPath)) {
|
|
215
|
-
return JSON.parse(readFileSync2(jsonPath, "utf8"));
|
|
216
|
-
}
|
|
217
|
-
return {};
|
|
204
|
+
for (const name of ["verbaly.config.js", "verbaly.config.mjs"]) {
|
|
205
|
+
const path = join(root, name);
|
|
206
|
+
if (existsSync(path)) return (await import(pathToFileURL(path).href)).default ?? {};
|
|
207
|
+
}
|
|
208
|
+
for (const name of ["verbaly.config.ts", "verbaly.config.mts"]) {
|
|
209
|
+
const path = join(root, name);
|
|
210
|
+
if (existsSync(path)) return loadTsConfig(path);
|
|
211
|
+
}
|
|
212
|
+
const jsonPath = join(root, "verbaly.config.json");
|
|
213
|
+
if (existsSync(jsonPath)) return JSON.parse(readFileSync(jsonPath, "utf8"));
|
|
214
|
+
return {};
|
|
218
215
|
}
|
|
219
216
|
async function loadTsConfig(path) {
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
});
|
|
229
|
-
}
|
|
230
|
-
throw error;
|
|
231
|
-
}
|
|
217
|
+
try {
|
|
218
|
+
const { bundleRequire } = await import("bundle-require");
|
|
219
|
+
const { mod } = await bundleRequire({ filepath: path });
|
|
220
|
+
return mod.default ?? {};
|
|
221
|
+
} catch (error) {
|
|
222
|
+
if (isModuleNotFound(error, "esbuild")) throw new Error(`[verbaly] ${path} needs esbuild — install it: pnpm add -D esbuild`, { cause: error });
|
|
223
|
+
throw error;
|
|
224
|
+
}
|
|
232
225
|
}
|
|
233
226
|
function isModuleNotFound(error, name) {
|
|
234
|
-
|
|
227
|
+
return error instanceof Error && error.code === "ERR_MODULE_NOT_FOUND" && error.message.includes(name);
|
|
235
228
|
}
|
|
236
229
|
async function loadConfig(root, overrides = {}) {
|
|
237
|
-
|
|
238
|
-
|
|
230
|
+
return resolveConfig({
|
|
231
|
+
...await loadConfigFile(root),
|
|
232
|
+
...definedOnly(overrides),
|
|
233
|
+
root
|
|
234
|
+
});
|
|
239
235
|
}
|
|
240
236
|
function definedOnly(config) {
|
|
241
|
-
|
|
242
|
-
Object.entries(config).filter(([, value]) => value !== void 0)
|
|
243
|
-
);
|
|
237
|
+
return Object.fromEntries(Object.entries(config).filter(([, value]) => value !== void 0));
|
|
244
238
|
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
import { readFileSync as readFileSync3 } from "fs";
|
|
248
|
-
import { glob } from "tinyglobby";
|
|
249
|
-
|
|
250
|
-
// src/analyze.ts
|
|
251
|
-
import { parse as parse2 } from "@babel/parser";
|
|
252
|
-
|
|
253
|
-
// src/key.ts
|
|
254
|
-
import { createHash } from "crypto";
|
|
239
|
+
//#endregion
|
|
240
|
+
//#region src/key.ts
|
|
255
241
|
function stableKey(message) {
|
|
256
|
-
|
|
242
|
+
return createHash("sha256").update(message).digest("base64url").slice(0, 8);
|
|
257
243
|
}
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
244
|
+
//#endregion
|
|
245
|
+
//#region src/analyze.ts
|
|
246
|
+
const SKIP_KEYS = /* @__PURE__ */ new Set([
|
|
247
|
+
"loc",
|
|
248
|
+
"leadingComments",
|
|
249
|
+
"trailingComments",
|
|
250
|
+
"innerComments",
|
|
251
|
+
"extra"
|
|
252
|
+
]);
|
|
261
253
|
function analyze(code, file) {
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
254
|
+
const ast = parse$1(code, {
|
|
255
|
+
sourceType: "module",
|
|
256
|
+
errorRecovery: true,
|
|
257
|
+
plugins: /\.[jt]sx$/.test(file) ? ["typescript", "jsx"] : ["typescript"]
|
|
258
|
+
});
|
|
259
|
+
const tagged = [];
|
|
260
|
+
const usedKeys = [];
|
|
261
|
+
walk(ast.program, (node) => {
|
|
262
|
+
if (node.type === "TaggedTemplateExpression") {
|
|
263
|
+
const tag = node.tag;
|
|
264
|
+
const explicit = explicitId(tag);
|
|
265
|
+
if (!explicit && !isTReference(tag)) return;
|
|
266
|
+
const quasi = node.quasi;
|
|
267
|
+
const message = buildMessage(code, quasi);
|
|
268
|
+
if (!message) return;
|
|
269
|
+
tagged.push({
|
|
270
|
+
key: explicit ? explicit.key : stableKey(message.text),
|
|
271
|
+
message: message.text,
|
|
272
|
+
params: message.params,
|
|
273
|
+
start: node.start,
|
|
274
|
+
end: node.end,
|
|
275
|
+
tagStart: explicit ? explicit.refStart : tag.start,
|
|
276
|
+
tagEnd: explicit ? explicit.refEnd : tag.end,
|
|
277
|
+
file
|
|
278
|
+
});
|
|
279
|
+
} else if (node.type === "CallExpression") {
|
|
280
|
+
const callee = node.callee;
|
|
281
|
+
if (!isTReference(callee)) return;
|
|
282
|
+
const first = node.arguments[0];
|
|
283
|
+
if (first?.type === "StringLiteral") usedKeys.push({
|
|
284
|
+
key: first.value,
|
|
285
|
+
file
|
|
286
|
+
});
|
|
287
|
+
} else if (node.type === "JSXElement") handleTrans(code, node, file, tagged, usedKeys);
|
|
288
|
+
});
|
|
289
|
+
return {
|
|
290
|
+
tagged,
|
|
291
|
+
usedKeys
|
|
292
|
+
};
|
|
301
293
|
}
|
|
302
294
|
function handleTrans(code, node, file, tagged, usedKeys) {
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
295
|
+
const opening = node.openingElement;
|
|
296
|
+
const nameNode = opening.name;
|
|
297
|
+
if (nameNode.type !== "JSXIdentifier" || nameNode.name !== "Trans") return;
|
|
298
|
+
const attrs = opening.attributes;
|
|
299
|
+
const idAttr = attrs.find((a) => a.type === "JSXAttribute" && a.name.name === "id");
|
|
300
|
+
const children = node.children;
|
|
301
|
+
if (idAttr) {
|
|
302
|
+
const value = idAttr.value;
|
|
303
|
+
if (value?.type !== "StringLiteral") return;
|
|
304
|
+
const id = value.value;
|
|
305
|
+
if (attrs.length === 1 && children?.length) {
|
|
306
|
+
const built = buildTransMessage(code, children);
|
|
307
|
+
if (built?.text.trim()) {
|
|
308
|
+
tagged.push({
|
|
309
|
+
key: id,
|
|
310
|
+
message: built.text,
|
|
311
|
+
params: built.params,
|
|
312
|
+
start: node.start,
|
|
313
|
+
end: node.end,
|
|
314
|
+
tagStart: nameNode.start,
|
|
315
|
+
tagEnd: nameNode.end,
|
|
316
|
+
file,
|
|
317
|
+
jsx: {
|
|
318
|
+
name: nameNode.name,
|
|
319
|
+
components: built.components
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
usedKeys.push({
|
|
326
|
+
key: id,
|
|
327
|
+
file
|
|
328
|
+
});
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (attrs.length > 0) return;
|
|
332
|
+
if (!children?.length) return;
|
|
333
|
+
const built = buildTransMessage(code, children);
|
|
334
|
+
if (!built || !built.text.trim()) return;
|
|
335
|
+
tagged.push({
|
|
336
|
+
key: stableKey(built.text),
|
|
337
|
+
message: built.text,
|
|
338
|
+
params: built.params,
|
|
339
|
+
start: node.start,
|
|
340
|
+
end: node.end,
|
|
341
|
+
tagStart: nameNode.start,
|
|
342
|
+
tagEnd: nameNode.end,
|
|
343
|
+
file,
|
|
344
|
+
jsx: {
|
|
345
|
+
name: nameNode.name,
|
|
346
|
+
components: built.components
|
|
347
|
+
}
|
|
348
|
+
});
|
|
348
349
|
}
|
|
349
350
|
function explicitId(tag) {
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
351
|
+
if (tag.type !== "CallExpression") return void 0;
|
|
352
|
+
const callee = tag.callee;
|
|
353
|
+
if (callee.type !== "MemberExpression" || callee.computed) return void 0;
|
|
354
|
+
const prop = callee.property;
|
|
355
|
+
if (prop.type !== "Identifier" || prop.name !== "id") return void 0;
|
|
356
|
+
const obj = callee.object;
|
|
357
|
+
if (!isTReference(obj)) return void 0;
|
|
358
|
+
const args = tag.arguments;
|
|
359
|
+
const first = args[0];
|
|
360
|
+
if (args.length !== 1 || first?.type !== "StringLiteral") return void 0;
|
|
361
|
+
return {
|
|
362
|
+
key: first.value,
|
|
363
|
+
refStart: obj.start,
|
|
364
|
+
refEnd: obj.end
|
|
365
|
+
};
|
|
361
366
|
}
|
|
362
367
|
function buildTransMessage(code, children) {
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
368
|
+
const params = [];
|
|
369
|
+
const components = [];
|
|
370
|
+
const takenParams = /* @__PURE__ */ new Map();
|
|
371
|
+
const takenTags = /* @__PURE__ */ new Map();
|
|
372
|
+
function walkChildren(nodes) {
|
|
373
|
+
let text = "";
|
|
374
|
+
for (const child of nodes) if (child.type === "JSXText") text += escapeText(cleanJsxText(child.value));
|
|
375
|
+
else if (child.type === "JSXExpressionContainer") {
|
|
376
|
+
const expr = child.expression;
|
|
377
|
+
if (expr.type === "JSXEmptyExpression") continue;
|
|
378
|
+
if (expr.type === "StringLiteral") {
|
|
379
|
+
text += escapeText(expr.value);
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
if (expr.type === "TaggedTemplateExpression") return void 0;
|
|
383
|
+
const source = code.slice(expr.start, expr.end);
|
|
384
|
+
const name = uniqueName(deriveName(expr, params.length), source, takenParams);
|
|
385
|
+
params.push({
|
|
386
|
+
name,
|
|
387
|
+
start: expr.start,
|
|
388
|
+
end: expr.end
|
|
389
|
+
});
|
|
390
|
+
text += `{${name}}`;
|
|
391
|
+
} else if (child.type === "JSXElement") {
|
|
392
|
+
const opening = child.openingElement;
|
|
393
|
+
const nameNode = opening.name;
|
|
394
|
+
if (nameNode.type !== "JSXIdentifier" || nameNode.name === "Trans") return void 0;
|
|
395
|
+
const source = selfClosedSource(code, opening);
|
|
396
|
+
const tag = uniqueName(nameNode.name.toLowerCase(), source, takenTags);
|
|
397
|
+
if (!components.some((c) => c.name === tag)) components.push({
|
|
398
|
+
name: tag,
|
|
399
|
+
source
|
|
400
|
+
});
|
|
401
|
+
if (!child.closingElement) text += `<${tag}/>`;
|
|
402
|
+
else {
|
|
403
|
+
const inner = walkChildren(child.children);
|
|
404
|
+
if (inner === void 0) return void 0;
|
|
405
|
+
text += `<${tag}>${inner}</${tag}>`;
|
|
406
|
+
}
|
|
407
|
+
} else return;
|
|
408
|
+
return text;
|
|
409
|
+
}
|
|
410
|
+
const text = walkChildren(children);
|
|
411
|
+
if (text === void 0) return void 0;
|
|
412
|
+
return {
|
|
413
|
+
text,
|
|
414
|
+
params,
|
|
415
|
+
components
|
|
416
|
+
};
|
|
407
417
|
}
|
|
408
418
|
function cleanJsxText(raw) {
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
419
|
+
const lines = raw.split(/\r\n|[\r\n]/);
|
|
420
|
+
let out = "";
|
|
421
|
+
for (let i = 0; i < lines.length; i++) {
|
|
422
|
+
let line = lines[i].replace(/\t/g, " ");
|
|
423
|
+
if (i !== 0) line = line.replace(/^ +/, "");
|
|
424
|
+
if (i !== lines.length - 1) line = line.replace(/ +$/, "");
|
|
425
|
+
if (!line) continue;
|
|
426
|
+
if (out && i !== 0) out += " ";
|
|
427
|
+
out += line;
|
|
428
|
+
}
|
|
429
|
+
return out;
|
|
420
430
|
}
|
|
421
431
|
function selfClosedSource(code, opening) {
|
|
422
|
-
|
|
423
|
-
|
|
432
|
+
const src = code.slice(opening.start, opening.end);
|
|
433
|
+
return src.endsWith("/>") ? src : `${src.slice(0, -1).trimEnd()} />`;
|
|
424
434
|
}
|
|
425
435
|
function isTReference(node) {
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
436
|
+
if (node.type === "Identifier") return node.name === "t";
|
|
437
|
+
if (node.type === "MemberExpression" && !node.computed) {
|
|
438
|
+
const prop = node.property;
|
|
439
|
+
return prop.type === "Identifier" && prop.name === "t";
|
|
440
|
+
}
|
|
441
|
+
return false;
|
|
432
442
|
}
|
|
433
443
|
function buildMessage(code, quasi) {
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
444
|
+
const quasis = quasi.quasis;
|
|
445
|
+
const expressions = quasi.expressions;
|
|
446
|
+
const params = [];
|
|
447
|
+
const taken = /* @__PURE__ */ new Map();
|
|
448
|
+
let text = escapeText(cookedValue(quasis[0]));
|
|
449
|
+
for (let i = 0; i < expressions.length; i++) {
|
|
450
|
+
const expr = expressions[i];
|
|
451
|
+
if (!expr) return void 0;
|
|
452
|
+
const source = code.slice(expr.start, expr.end);
|
|
453
|
+
const name = uniqueName(deriveName(expr, i), source, taken);
|
|
454
|
+
params.push({
|
|
455
|
+
name,
|
|
456
|
+
start: expr.start,
|
|
457
|
+
end: expr.end
|
|
458
|
+
});
|
|
459
|
+
text += `{${name}}` + escapeText(cookedValue(quasis[i + 1]));
|
|
460
|
+
}
|
|
461
|
+
return {
|
|
462
|
+
text,
|
|
463
|
+
params
|
|
464
|
+
};
|
|
448
465
|
}
|
|
449
466
|
function cookedValue(element) {
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
467
|
+
if (!element) return "";
|
|
468
|
+
const value = element.value;
|
|
469
|
+
return value.cooked ?? value.raw;
|
|
453
470
|
}
|
|
454
471
|
function escapeText(text) {
|
|
455
|
-
|
|
472
|
+
return text.replace(/[{}]/g, (m) => m + m);
|
|
456
473
|
}
|
|
457
474
|
function deriveName(expr, index) {
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
475
|
+
if (expr.type === "Identifier") return expr.name;
|
|
476
|
+
if (expr.type === "MemberExpression" && !expr.computed) {
|
|
477
|
+
const prop = expr.property;
|
|
478
|
+
if (prop.type === "Identifier") return prop.name;
|
|
479
|
+
}
|
|
480
|
+
return `_${index}`;
|
|
464
481
|
}
|
|
465
482
|
function uniqueName(base, source, taken) {
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
483
|
+
let name = base;
|
|
484
|
+
let n = 2;
|
|
485
|
+
while (taken.has(name) && taken.get(name) !== source) {
|
|
486
|
+
name = `${base}${n}`;
|
|
487
|
+
n += 1;
|
|
488
|
+
}
|
|
489
|
+
taken.set(name, source);
|
|
490
|
+
return name;
|
|
474
491
|
}
|
|
475
|
-
function walk(node,
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
} else if (isNode(value)) {
|
|
484
|
-
walk(value, visit2);
|
|
485
|
-
}
|
|
486
|
-
}
|
|
492
|
+
function walk(node, visit) {
|
|
493
|
+
visit(node);
|
|
494
|
+
for (const [key, value] of Object.entries(node)) {
|
|
495
|
+
if (SKIP_KEYS.has(key)) continue;
|
|
496
|
+
if (Array.isArray(value)) {
|
|
497
|
+
for (const item of value) if (isNode(item)) walk(item, visit);
|
|
498
|
+
} else if (isNode(value)) walk(value, visit);
|
|
499
|
+
}
|
|
487
500
|
}
|
|
488
501
|
function isNode(value) {
|
|
489
|
-
|
|
502
|
+
return typeof value === "object" && value !== null && typeof value.type === "string";
|
|
490
503
|
}
|
|
491
|
-
|
|
492
|
-
|
|
504
|
+
//#endregion
|
|
505
|
+
//#region src/registry.ts
|
|
493
506
|
var MessageRegistry = class {
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
for (const used of analysis.usedKeys) {
|
|
523
|
-
const files = out.get(used.key) ?? [];
|
|
524
|
-
if (!files.includes(used.file)) files.push(used.file);
|
|
525
|
-
out.set(used.key, files);
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
return out;
|
|
529
|
-
}
|
|
507
|
+
files = /* @__PURE__ */ new Map();
|
|
508
|
+
update(file, analysis) {
|
|
509
|
+
this.files.set(file, analysis);
|
|
510
|
+
}
|
|
511
|
+
remove(file) {
|
|
512
|
+
this.files.delete(file);
|
|
513
|
+
}
|
|
514
|
+
messages() {
|
|
515
|
+
const out = /* @__PURE__ */ new Map();
|
|
516
|
+
for (const analysis of this.files.values()) for (const msg of analysis.tagged) {
|
|
517
|
+
const existing = out.get(msg.key);
|
|
518
|
+
if (existing) {
|
|
519
|
+
if (existing.message !== msg.message) console.warn(`[verbaly] key collision "${msg.key}": ${JSON.stringify(existing.message)} vs ${JSON.stringify(msg.message)} — second dropped.`);
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
out.set(msg.key, msg);
|
|
523
|
+
}
|
|
524
|
+
return out;
|
|
525
|
+
}
|
|
526
|
+
usedKeys() {
|
|
527
|
+
const out = /* @__PURE__ */ new Map();
|
|
528
|
+
for (const analysis of this.files.values()) for (const used of analysis.usedKeys) {
|
|
529
|
+
const files = out.get(used.key) ?? [];
|
|
530
|
+
if (!files.includes(used.file)) files.push(used.file);
|
|
531
|
+
out.set(used.key, files);
|
|
532
|
+
}
|
|
533
|
+
return out;
|
|
534
|
+
}
|
|
530
535
|
};
|
|
531
|
-
|
|
532
|
-
|
|
536
|
+
//#endregion
|
|
537
|
+
//#region src/extract.ts
|
|
533
538
|
async function extractProject(cfg) {
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
}
|
|
543
|
-
return registry;
|
|
539
|
+
const files = await glob(cfg.include, {
|
|
540
|
+
cwd: cfg.root,
|
|
541
|
+
ignore: cfg.exclude,
|
|
542
|
+
absolute: true
|
|
543
|
+
});
|
|
544
|
+
const registry = new MessageRegistry();
|
|
545
|
+
for (const file of files) registry.update(file, analyze(readFileSync(file, "utf8"), file));
|
|
546
|
+
return registry;
|
|
544
547
|
}
|
|
545
548
|
function syncCatalogs(cfg, catalogs, registry) {
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
}
|
|
563
|
-
}
|
|
564
|
-
}
|
|
565
|
-
return { added };
|
|
549
|
+
const added = {};
|
|
550
|
+
const source = catalogs[cfg.sourceLocale] ??= {};
|
|
551
|
+
for (const [key, msg] of registry.messages()) if (source[key] !== msg.message) {
|
|
552
|
+
source[key] = msg.message;
|
|
553
|
+
(added[cfg.sourceLocale] ??= []).push(key);
|
|
554
|
+
}
|
|
555
|
+
const needed = Object.keys(source);
|
|
556
|
+
for (const locale of cfg.locales) {
|
|
557
|
+
if (locale === cfg.sourceLocale) continue;
|
|
558
|
+
const catalog = catalogs[locale] ??= {};
|
|
559
|
+
for (const key of needed) if (catalog[key] === void 0) {
|
|
560
|
+
catalog[key] = "";
|
|
561
|
+
(added[locale] ??= []).push(key);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
return { added };
|
|
566
565
|
}
|
|
567
566
|
function pruneCatalogs(cfg, catalogs, registry) {
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
}
|
|
580
|
-
return removed;
|
|
567
|
+
const keep = /* @__PURE__ */ new Set([...registry.messages().keys(), ...registry.usedKeys().keys()]);
|
|
568
|
+
const removed = {};
|
|
569
|
+
for (const locale of cfg.locales) {
|
|
570
|
+
const catalog = catalogs[locale];
|
|
571
|
+
if (!catalog) continue;
|
|
572
|
+
for (const key of Object.keys(catalog)) if (!keep.has(key)) {
|
|
573
|
+
delete catalog[key];
|
|
574
|
+
(removed[locale] ??= []).push(key);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
return removed;
|
|
581
578
|
}
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
579
|
+
//#endregion
|
|
580
|
+
//#region src/init.ts
|
|
581
|
+
const CONFIG_NAMES = [
|
|
582
|
+
"verbaly.config.js",
|
|
583
|
+
"verbaly.config.mjs",
|
|
584
|
+
"verbaly.config.ts",
|
|
585
|
+
"verbaly.config.mts",
|
|
586
|
+
"verbaly.config.json"
|
|
587
|
+
];
|
|
588
|
+
const BUNDLERS = [
|
|
589
|
+
"vite",
|
|
590
|
+
"webpack",
|
|
591
|
+
"rollup",
|
|
592
|
+
"rspack",
|
|
593
|
+
"esbuild"
|
|
594
|
+
];
|
|
595
|
+
function detectBundler(root) {
|
|
596
|
+
const path = join(root, "package.json");
|
|
597
|
+
if (!existsSync(path)) return void 0;
|
|
598
|
+
try {
|
|
599
|
+
const pkg = JSON.parse(readFileSync(path, "utf8"));
|
|
600
|
+
const deps = {
|
|
601
|
+
...pkg.dependencies,
|
|
602
|
+
...pkg.devDependencies
|
|
603
|
+
};
|
|
604
|
+
return BUNDLERS.find((name) => deps[name]);
|
|
605
|
+
} catch {
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
function configSource(options, typescript) {
|
|
610
|
+
const fields = [` sourceLocale: '${options.sourceLocale ?? "en"}',`];
|
|
611
|
+
if (options.locales?.length) fields.push(` locales: [${options.locales.map((l) => `'${l}'`).join(", ")}],`);
|
|
612
|
+
if (options.dir) fields.push(` dir: '${options.dir}',`);
|
|
613
|
+
const body = `export default {\n${fields.join("\n")}\n}`;
|
|
614
|
+
if (typescript) return `import type { VerbalyConfig } from '@verbaly/compiler';\n\n${body} satisfies VerbalyConfig;\n`;
|
|
615
|
+
return `/** @type {import('@verbaly/compiler').VerbalyConfig} */\n${body};\n`;
|
|
616
|
+
}
|
|
617
|
+
function init(options = {}) {
|
|
618
|
+
const root = options.root ?? process.cwd();
|
|
619
|
+
const created = [];
|
|
620
|
+
const skipped = [];
|
|
621
|
+
const existing = CONFIG_NAMES.find((name) => existsSync(join(root, name)));
|
|
622
|
+
const typescript = existsSync(join(root, "tsconfig.json"));
|
|
623
|
+
const configFile = existing ?? (typescript ? "verbaly.config.ts" : "verbaly.config.mjs");
|
|
624
|
+
if (existing) skipped.push(existing);
|
|
625
|
+
else {
|
|
626
|
+
writeFileSync(join(root, configFile), configSource(options, typescript));
|
|
627
|
+
created.push(configFile);
|
|
628
|
+
}
|
|
629
|
+
const dir = join(root, options.dir ?? "locales");
|
|
630
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
631
|
+
for (const locale of /* @__PURE__ */ new Set([options.sourceLocale ?? "en", ...options.locales ?? []])) {
|
|
632
|
+
const file = join(dir, `${locale}.json`);
|
|
633
|
+
const label = relative(root, file).replaceAll("\\", "/");
|
|
634
|
+
if (existsSync(file)) skipped.push(label);
|
|
635
|
+
else {
|
|
636
|
+
writeFileSync(file, "{}\n");
|
|
637
|
+
created.push(label);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
const bundler = detectBundler(root);
|
|
641
|
+
const next = [];
|
|
642
|
+
if (bundler === "vite") next.push("install the plugin: pnpm add -D @verbaly/vite", "add verbaly() to the plugins in vite.config");
|
|
643
|
+
else if (bundler) next.push("install the plugin: pnpm add -D @verbaly/unplugin", `add the verbaly ${bundler} plugin to your build config`);
|
|
644
|
+
else next.push("run \"verbaly extract\" after writing your first t`…` message");
|
|
645
|
+
return {
|
|
646
|
+
created,
|
|
647
|
+
skipped,
|
|
648
|
+
bundler,
|
|
649
|
+
configFile,
|
|
650
|
+
next
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
//#endregion
|
|
654
|
+
//#region src/pseudo.ts
|
|
655
|
+
const PSEUDO_LOCALE = "en-XA";
|
|
656
|
+
const ACCENTS = {
|
|
657
|
+
a: "á",
|
|
658
|
+
b: "ƀ",
|
|
659
|
+
c: "ç",
|
|
660
|
+
d: "đ",
|
|
661
|
+
e: "é",
|
|
662
|
+
f: "ƒ",
|
|
663
|
+
g: "ğ",
|
|
664
|
+
h: "ĥ",
|
|
665
|
+
i: "í",
|
|
666
|
+
j: "ĵ",
|
|
667
|
+
k: "ķ",
|
|
668
|
+
l: "ĺ",
|
|
669
|
+
m: "ɱ",
|
|
670
|
+
n: "ñ",
|
|
671
|
+
o: "ó",
|
|
672
|
+
p: "þ",
|
|
673
|
+
q: "ǫ",
|
|
674
|
+
r: "ŕ",
|
|
675
|
+
s: "š",
|
|
676
|
+
t: "ţ",
|
|
677
|
+
u: "ú",
|
|
678
|
+
v: "ṽ",
|
|
679
|
+
w: "ŵ",
|
|
680
|
+
x: "ẋ",
|
|
681
|
+
y: "ý",
|
|
682
|
+
z: "ž",
|
|
683
|
+
A: "Á",
|
|
684
|
+
B: "Ɓ",
|
|
685
|
+
C: "Ç",
|
|
686
|
+
D: "Đ",
|
|
687
|
+
E: "É",
|
|
688
|
+
F: "Ƒ",
|
|
689
|
+
G: "Ğ",
|
|
690
|
+
H: "Ĥ",
|
|
691
|
+
I: "Í",
|
|
692
|
+
J: "Ĵ",
|
|
693
|
+
K: "Ķ",
|
|
694
|
+
L: "Ĺ",
|
|
695
|
+
M: "Ḿ",
|
|
696
|
+
N: "Ñ",
|
|
697
|
+
O: "Ó",
|
|
698
|
+
P: "Þ",
|
|
699
|
+
Q: "Ǫ",
|
|
700
|
+
R: "Ŕ",
|
|
701
|
+
S: "Š",
|
|
702
|
+
T: "Ţ",
|
|
703
|
+
U: "Ú",
|
|
704
|
+
V: "Ṽ",
|
|
705
|
+
W: "Ŵ",
|
|
706
|
+
X: "Ẍ",
|
|
707
|
+
Y: "Ý",
|
|
708
|
+
Z: "Ž"
|
|
638
709
|
};
|
|
639
|
-
|
|
710
|
+
const TAG_AT = /<\/?[a-zA-Z][\w-]*\/?>/y;
|
|
640
711
|
function pseudoLocalize(message) {
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
const pad = "~".repeat(Math.ceil(letters / 3));
|
|
677
|
-
return `\u27E6${out}${pad ? " " + pad : ""}\u27E7`;
|
|
712
|
+
let out = "";
|
|
713
|
+
let letters = 0;
|
|
714
|
+
let i = 0;
|
|
715
|
+
while (i < message.length) {
|
|
716
|
+
const two = message.slice(i, i + 2);
|
|
717
|
+
if (two === "{{" || two === "}}" || two === "||" || two === "##") {
|
|
718
|
+
out += two;
|
|
719
|
+
i += 2;
|
|
720
|
+
continue;
|
|
721
|
+
}
|
|
722
|
+
const ch = message[i];
|
|
723
|
+
if (ch === "{") {
|
|
724
|
+
const end = matchBrace(message, i);
|
|
725
|
+
out += message.slice(i, end);
|
|
726
|
+
i = end;
|
|
727
|
+
continue;
|
|
728
|
+
}
|
|
729
|
+
if (ch === "<") {
|
|
730
|
+
TAG_AT.lastIndex = i;
|
|
731
|
+
const m = TAG_AT.exec(message);
|
|
732
|
+
if (m) {
|
|
733
|
+
out += m[0];
|
|
734
|
+
i += m[0].length;
|
|
735
|
+
continue;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
const mapped = ACCENTS[ch];
|
|
739
|
+
if (mapped) {
|
|
740
|
+
out += mapped;
|
|
741
|
+
letters += 1;
|
|
742
|
+
} else out += ch;
|
|
743
|
+
i += 1;
|
|
744
|
+
}
|
|
745
|
+
const pad = "~".repeat(Math.ceil(letters / 3));
|
|
746
|
+
return `⟦${out}${pad ? " " + pad : ""}⟧`;
|
|
678
747
|
}
|
|
679
748
|
function matchBrace(message, start) {
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
749
|
+
let depth = 0;
|
|
750
|
+
for (let i = start; i < message.length; i++) {
|
|
751
|
+
const two = message.slice(i, i + 2);
|
|
752
|
+
if (two === "{{" || two === "}}") {
|
|
753
|
+
i += 1;
|
|
754
|
+
continue;
|
|
755
|
+
}
|
|
756
|
+
const ch = message[i];
|
|
757
|
+
if (ch === "{") depth += 1;
|
|
758
|
+
else if (ch === "}") {
|
|
759
|
+
depth -= 1;
|
|
760
|
+
if (depth === 0) return i + 1;
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
return message.length;
|
|
695
764
|
}
|
|
696
765
|
function pseudoCatalogs(cfg, catalogs, locale = PSEUDO_LOCALE) {
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
catalogs[locale] = target;
|
|
703
|
-
return Object.keys(target).filter((key) => target[key]);
|
|
766
|
+
const source = catalogs[cfg.sourceLocale] ?? {};
|
|
767
|
+
const target = {};
|
|
768
|
+
for (const [key, msg] of Object.entries(source)) target[key] = msg ? pseudoLocalize(msg) : "";
|
|
769
|
+
catalogs[locale] = target;
|
|
770
|
+
return Object.keys(target).filter((key) => target[key]);
|
|
704
771
|
}
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
"meta",
|
|
723
|
-
"param",
|
|
724
|
-
"source",
|
|
725
|
-
"track",
|
|
726
|
-
"wbr"
|
|
772
|
+
//#endregion
|
|
773
|
+
//#region src/render.ts
|
|
774
|
+
const VOID_TAGS = /* @__PURE__ */ new Set([
|
|
775
|
+
"area",
|
|
776
|
+
"base",
|
|
777
|
+
"br",
|
|
778
|
+
"col",
|
|
779
|
+
"embed",
|
|
780
|
+
"hr",
|
|
781
|
+
"img",
|
|
782
|
+
"input",
|
|
783
|
+
"link",
|
|
784
|
+
"meta",
|
|
785
|
+
"param",
|
|
786
|
+
"source",
|
|
787
|
+
"track",
|
|
788
|
+
"wbr"
|
|
727
789
|
]);
|
|
728
|
-
|
|
729
|
-
|
|
790
|
+
const START_TAG = /<([a-zA-Z][a-zA-Z0-9-]*)((?:"[^"]*"|'[^']*'|[^"'>])*)>/g;
|
|
791
|
+
const ATTR = /([^\s=/"'<>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
|
|
730
792
|
function renderHtml(html, options) {
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
793
|
+
const attr = options.attribute ?? "data-verbaly";
|
|
794
|
+
const argsAttr = `${attr}-args`;
|
|
795
|
+
const attrsAttr = `${attr}-attr`;
|
|
796
|
+
const richAttr = `${attr}-rich`;
|
|
797
|
+
const linksAttr = `${attr}-links`;
|
|
798
|
+
const richTags = new Set(options.richTags ?? RICH_TAGS);
|
|
799
|
+
const globalLinks = options.richLinks;
|
|
800
|
+
const sourceLocale = options.sourceLocale ?? "en";
|
|
801
|
+
const messages = {};
|
|
802
|
+
for (const [locale, catalog] of Object.entries(options.catalogs)) {
|
|
803
|
+
const clean = {};
|
|
804
|
+
for (const [key, msg] of Object.entries(catalog)) if (msg) clean[key] = msg;
|
|
805
|
+
messages[locale] = clean;
|
|
806
|
+
}
|
|
807
|
+
const v = createVerbaly({
|
|
808
|
+
locale: options.locale,
|
|
809
|
+
fallback: sourceLocale,
|
|
810
|
+
messages
|
|
811
|
+
});
|
|
812
|
+
const t = v.t;
|
|
813
|
+
const ms = new MagicString(html);
|
|
814
|
+
const missing = /* @__PURE__ */ new Set();
|
|
815
|
+
const skip = protectedRanges(html);
|
|
816
|
+
const inSkip = (index) => skip.some(([from, to]) => index >= from && index < to);
|
|
817
|
+
START_TAG.lastIndex = 0;
|
|
818
|
+
let m;
|
|
819
|
+
while ((m = START_TAG.exec(html)) !== null) {
|
|
820
|
+
if (inSkip(m.index)) continue;
|
|
821
|
+
const [full, rawName, attrChunk] = m;
|
|
822
|
+
const tagName = rawName.toLowerCase();
|
|
823
|
+
const openEnd = m.index + full.length;
|
|
824
|
+
const chunkStart = m.index + 1 + rawName.length;
|
|
825
|
+
if (tagName === "html" && options.setLang !== false) {
|
|
826
|
+
setAttribute(ms, html, chunkStart, openEnd, attrChunk, "lang", options.locale);
|
|
827
|
+
continue;
|
|
828
|
+
}
|
|
829
|
+
const attrs = parseAttrs(attrChunk);
|
|
830
|
+
const key = attrs.get(attr);
|
|
831
|
+
const attrMapRaw = attrs.get(attrsAttr);
|
|
832
|
+
if (key === void 0 && attrMapRaw === void 0) continue;
|
|
833
|
+
const args = parseArgs$1(attrs.get(argsAttr));
|
|
834
|
+
if (key) {
|
|
835
|
+
if (!v.has(key)) missing.add(key);
|
|
836
|
+
else if (!VOID_TAGS.has(tagName) && !attrChunk.trimEnd().endsWith("/")) {
|
|
837
|
+
const close = findClose(html, tagName, openEnd, inSkip);
|
|
838
|
+
if (close) {
|
|
839
|
+
const text = t(key, args);
|
|
840
|
+
const own = parseArgs$1(attrs.get(linksAttr));
|
|
841
|
+
const links = own ? globalLinks ? {
|
|
842
|
+
...globalLinks,
|
|
843
|
+
...own
|
|
844
|
+
} : own : globalLinks;
|
|
845
|
+
const content = attrs.has(richAttr) ? richToHtml(parseTags(text), richTags, links) : escapeHtml(text);
|
|
846
|
+
if (html.slice(openEnd, close.contentEnd) !== content) if (openEnd === close.contentEnd) ms.appendLeft(openEnd, content);
|
|
847
|
+
else ms.overwrite(openEnd, close.contentEnd, content);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
if (attrMapRaw !== void 0) {
|
|
852
|
+
const map = parseArgs$1(attrMapRaw);
|
|
853
|
+
if (map) for (const [name, attrKey] of Object.entries(map)) {
|
|
854
|
+
if (typeof attrKey !== "string" || name.toLowerCase().startsWith("on")) continue;
|
|
855
|
+
if (!v.has(attrKey)) {
|
|
856
|
+
missing.add(attrKey);
|
|
857
|
+
continue;
|
|
858
|
+
}
|
|
859
|
+
setAttribute(ms, html, chunkStart, openEnd, attrChunk, name, t(attrKey, args));
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
return {
|
|
864
|
+
html: ms.toString(),
|
|
865
|
+
missing: [...missing]
|
|
866
|
+
};
|
|
796
867
|
}
|
|
797
868
|
async function renderSite(cfg, options = {}) {
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
869
|
+
const site = join(cfg.root, options.site ?? "dist");
|
|
870
|
+
const locales = options.locales ?? cfg.locales;
|
|
871
|
+
const catalogs = loadCatalogs(cfg);
|
|
872
|
+
const files = await glob("**/*.html", {
|
|
873
|
+
cwd: site,
|
|
874
|
+
absolute: true,
|
|
875
|
+
ignore: locales.map((locale) => `${locale}/**`)
|
|
876
|
+
});
|
|
877
|
+
const missing = {};
|
|
878
|
+
for (const file of files) {
|
|
879
|
+
const html = readFileSync(file, "utf8");
|
|
880
|
+
const rel = relative(site, file);
|
|
881
|
+
for (const locale of locales) {
|
|
882
|
+
const result = renderHtml(html, {
|
|
883
|
+
locale,
|
|
884
|
+
catalogs,
|
|
885
|
+
sourceLocale: cfg.sourceLocale,
|
|
886
|
+
attribute: options.attribute,
|
|
887
|
+
richTags: options.richTags,
|
|
888
|
+
richLinks: options.richLinks ?? cfg.render.links
|
|
889
|
+
});
|
|
890
|
+
for (const key of result.missing) {
|
|
891
|
+
const list = missing[locale] ??= [];
|
|
892
|
+
if (!list.includes(key)) list.push(key);
|
|
893
|
+
}
|
|
894
|
+
const out = locale === cfg.sourceLocale ? file : join(site, locale, rel);
|
|
895
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
896
|
+
writeFileSync(out, result.html);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
return {
|
|
900
|
+
files: files.length,
|
|
901
|
+
locales: [...locales],
|
|
902
|
+
missing
|
|
903
|
+
};
|
|
828
904
|
}
|
|
829
905
|
function parseAttrs(chunk) {
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
}
|
|
836
|
-
return attrs;
|
|
906
|
+
const attrs = /* @__PURE__ */ new Map();
|
|
907
|
+
ATTR.lastIndex = 0;
|
|
908
|
+
let m;
|
|
909
|
+
while ((m = ATTR.exec(chunk)) !== null) attrs.set(m[1].toLowerCase(), m[2] ?? m[3] ?? m[4] ?? "");
|
|
910
|
+
return attrs;
|
|
837
911
|
}
|
|
838
|
-
function parseArgs(raw) {
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
912
|
+
function parseArgs$1(raw) {
|
|
913
|
+
if (!raw) return void 0;
|
|
914
|
+
try {
|
|
915
|
+
return JSON.parse(decodeEntities(raw));
|
|
916
|
+
} catch {
|
|
917
|
+
console.warn(`[verbaly] invalid args JSON: ${raw}`);
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
846
920
|
}
|
|
847
921
|
function protectedRanges(html) {
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
922
|
+
const ranges = [];
|
|
923
|
+
const re = /<!--[\s\S]*?-->|<script\b[\s\S]*?<\/script\s*>|<style\b[\s\S]*?<\/style\s*>/gi;
|
|
924
|
+
let m;
|
|
925
|
+
while ((m = re.exec(html)) !== null) {
|
|
926
|
+
const openEnd = m[0].startsWith("<!--") ? m.index : html.indexOf(">", m.index) + 1;
|
|
927
|
+
ranges.push([openEnd, m.index + m[0].length]);
|
|
928
|
+
}
|
|
929
|
+
return ranges;
|
|
856
930
|
}
|
|
857
931
|
function findClose(html, tagName, from, inSkip) {
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
} else if (!m[0].endsWith("/>")) {
|
|
871
|
-
depth += 1;
|
|
872
|
-
}
|
|
873
|
-
}
|
|
874
|
-
return null;
|
|
932
|
+
const re = new RegExp(`<${tagName}(?=[\\s/>])(?:"[^"]*"|'[^']*'|[^"'>])*>|</${tagName}\\s*>`, "gi");
|
|
933
|
+
re.lastIndex = from;
|
|
934
|
+
let depth = 1;
|
|
935
|
+
let m;
|
|
936
|
+
while ((m = re.exec(html)) !== null) {
|
|
937
|
+
if (inSkip(m.index)) continue;
|
|
938
|
+
if (m[0][1] === "/") {
|
|
939
|
+
depth -= 1;
|
|
940
|
+
if (depth === 0) return { contentEnd: m.index };
|
|
941
|
+
} else if (!m[0].endsWith("/>")) depth += 1;
|
|
942
|
+
}
|
|
943
|
+
return null;
|
|
875
944
|
}
|
|
876
945
|
function setAttribute(ms, html, chunkStart, openEnd, attrChunk, name, value) {
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
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}"`);
|
|
946
|
+
const escaped = escapeAttr(value);
|
|
947
|
+
const existing = new RegExp(`(\\s${name}\\s*=\\s*)("[^"]*"|'[^']*'|[^\\s>]+)`, "i").exec(attrChunk);
|
|
948
|
+
if (existing) {
|
|
949
|
+
const valueStart = chunkStart + existing.index + existing[1].length;
|
|
950
|
+
const valueEnd = valueStart + existing[2].length;
|
|
951
|
+
if (html.slice(valueStart, valueEnd) !== `"${escaped}"`) ms.overwrite(valueStart, valueEnd, `"${escaped}"`);
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
const insertAt = openEnd - (html.slice(openEnd - 2, openEnd) === "/>" ? 2 : 1);
|
|
955
|
+
ms.appendLeft(insertAt, ` ${name}="${escaped}"`);
|
|
892
956
|
}
|
|
893
|
-
function richToHtml(nodes, allowed) {
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
957
|
+
function richToHtml(nodes, allowed, links) {
|
|
958
|
+
let out = "";
|
|
959
|
+
for (const node of nodes) if (typeof node === "string") out += escapeHtml(node);
|
|
960
|
+
else if (links?.[node.name] !== void 0) {
|
|
961
|
+
const link = links[node.name];
|
|
962
|
+
const { href, target, rel } = typeof link === "string" ? { href: link } : link;
|
|
963
|
+
const safe = safeHref(href);
|
|
964
|
+
let attrs = safe !== void 0 ? ` href="${escapeAttr(safe)}"` : "";
|
|
965
|
+
if (target) attrs += ` target="${escapeAttr(target)}"`;
|
|
966
|
+
if (rel) attrs += ` rel="${escapeAttr(rel)}"`;
|
|
967
|
+
out += `<a${attrs}>${richToHtml(node.children, allowed, links)}</a>`;
|
|
968
|
+
} else if (allowed.has(node.name)) out += `<${node.name}>${richToHtml(node.children, allowed, links)}</${node.name}>`;
|
|
969
|
+
else out += richToHtml(node.children, allowed, links);
|
|
970
|
+
return out;
|
|
905
971
|
}
|
|
906
972
|
function escapeHtml(text) {
|
|
907
|
-
|
|
973
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
908
974
|
}
|
|
909
975
|
function escapeAttr(text) {
|
|
910
|
-
|
|
976
|
+
return escapeHtml(text).replace(/"/g, """);
|
|
911
977
|
}
|
|
912
978
|
function decodeEntities(text) {
|
|
913
|
-
|
|
979
|
+
return text.replace(/"/g, "\"").replace(/'/g, "'").replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&");
|
|
914
980
|
}
|
|
915
|
-
|
|
916
|
-
|
|
981
|
+
//#endregion
|
|
982
|
+
//#region src/translate.ts
|
|
917
983
|
async function translateCatalogs(cfg, catalogs, provider, options = {}) {
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
984
|
+
const batchSize = options.batchSize ?? 20;
|
|
985
|
+
const targets = (options.locales ?? cfg.locales).filter((locale) => locale !== cfg.sourceLocale && cfg.locales.includes(locale));
|
|
986
|
+
const source = catalogs[cfg.sourceLocale] ?? {};
|
|
987
|
+
const result = {
|
|
988
|
+
translated: {},
|
|
989
|
+
invalid: {},
|
|
990
|
+
pending: {}
|
|
991
|
+
};
|
|
992
|
+
for (const locale of targets) {
|
|
993
|
+
const catalog = catalogs[locale] ??= {};
|
|
994
|
+
const missing = Object.keys(source).filter((key) => source[key] && !catalog[key]);
|
|
995
|
+
if (missing.length === 0) continue;
|
|
996
|
+
if (options.dryRun) {
|
|
997
|
+
result.pending[locale] = missing;
|
|
998
|
+
continue;
|
|
999
|
+
}
|
|
1000
|
+
for (let i = 0; i < missing.length; i += batchSize) {
|
|
1001
|
+
const keys = missing.slice(i, i + batchSize);
|
|
1002
|
+
const messages = Object.fromEntries(keys.map((key) => [key, source[key]]));
|
|
1003
|
+
const out = await provider({
|
|
1004
|
+
sourceLocale: cfg.sourceLocale,
|
|
1005
|
+
targetLocale: locale,
|
|
1006
|
+
messages
|
|
1007
|
+
});
|
|
1008
|
+
for (const key of keys) {
|
|
1009
|
+
const text = out[key];
|
|
1010
|
+
if (typeof text === "string" && text.trim() && structureMatches(source[key], text)) {
|
|
1011
|
+
catalog[key] = text;
|
|
1012
|
+
(result.translated[locale] ??= []).push(key);
|
|
1013
|
+
} else (result.invalid[locale] ??= []).push(key);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
return result;
|
|
952
1018
|
}
|
|
953
1019
|
function structureMatches(source, translated) {
|
|
954
|
-
|
|
1020
|
+
return sameMembers(paramNames(source), paramNames(translated)) && sameMembers(tagTokens(source), tagTokens(translated));
|
|
955
1021
|
}
|
|
956
1022
|
function paramNames(message) {
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
1023
|
+
try {
|
|
1024
|
+
return [...collectParams(message).keys()].sort();
|
|
1025
|
+
} catch {
|
|
1026
|
+
return ["\0invalid"];
|
|
1027
|
+
}
|
|
962
1028
|
}
|
|
963
|
-
|
|
1029
|
+
const TAG = /<(\/?)([a-zA-Z][\w-]*)(\/?)>/g;
|
|
964
1030
|
function tagTokens(message) {
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
}
|
|
969
|
-
return out.sort();
|
|
1031
|
+
const out = [];
|
|
1032
|
+
for (const match of message.matchAll(TAG)) out.push(`${match[1]}${match[2]}${match[3]}`);
|
|
1033
|
+
return out.sort();
|
|
970
1034
|
}
|
|
971
1035
|
function sameMembers(a, b) {
|
|
972
|
-
|
|
1036
|
+
return a.length === b.length && a.every((value, i) => value === b[i]);
|
|
973
1037
|
}
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
1038
|
+
//#endregion
|
|
1039
|
+
//#region src/cli.ts
|
|
1040
|
+
const HELP = `verbaly — i18n compiler
|
|
977
1041
|
|
|
978
1042
|
Usage:
|
|
1043
|
+
verbaly init scaffold config + locale catalogs (detects your bundler)
|
|
979
1044
|
verbaly extract scan sources, update catalogs and types
|
|
980
1045
|
verbaly check verify translations are complete (CI)
|
|
981
1046
|
verbaly translate fill missing translations via a provider (default: claude)
|
|
@@ -997,131 +1062,129 @@ Config file: verbaly.config.{js,mjs,ts,mts,json} at root (flags win).
|
|
|
997
1062
|
The claude provider needs @anthropic-ai/sdk installed and ANTHROPIC_API_KEY set.
|
|
998
1063
|
`;
|
|
999
1064
|
async function main() {
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
}
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
return;
|
|
1112
|
-
}
|
|
1113
|
-
console.error(`[verbaly] unknown command "${command}"
|
|
1114
|
-
${HELP}`);
|
|
1115
|
-
process.exitCode = 1;
|
|
1065
|
+
const { positionals, values } = parseArgs({
|
|
1066
|
+
allowPositionals: true,
|
|
1067
|
+
options: {
|
|
1068
|
+
root: { type: "string" },
|
|
1069
|
+
dir: { type: "string" },
|
|
1070
|
+
source: { type: "string" },
|
|
1071
|
+
locales: { type: "string" },
|
|
1072
|
+
prune: { type: "boolean" },
|
|
1073
|
+
model: { type: "string" },
|
|
1074
|
+
locale: { type: "string" },
|
|
1075
|
+
site: { type: "string" },
|
|
1076
|
+
"dry-run": { type: "boolean" },
|
|
1077
|
+
help: {
|
|
1078
|
+
type: "boolean",
|
|
1079
|
+
short: "h"
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
});
|
|
1083
|
+
const command = positionals[0];
|
|
1084
|
+
if (values.help || !command) {
|
|
1085
|
+
console.log(HELP);
|
|
1086
|
+
process.exitCode = command ? 0 : 1;
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
if (command === "init") {
|
|
1090
|
+
const result = init({
|
|
1091
|
+
root: values.root,
|
|
1092
|
+
dir: values.dir,
|
|
1093
|
+
sourceLocale: values.source,
|
|
1094
|
+
locales: values.locales?.split(",")
|
|
1095
|
+
});
|
|
1096
|
+
if (result.created.length) console.log(`[verbaly] created: ${result.created.join(", ")}`);
|
|
1097
|
+
if (result.skipped.length) console.log(` kept (already there): ${result.skipped.join(", ")}`);
|
|
1098
|
+
if (result.bundler) console.log(` detected bundler: ${result.bundler}`);
|
|
1099
|
+
console.log([" next steps:", ...result.next.map((step, i) => ` ${i + 1}. ${step}`)].join("\n"));
|
|
1100
|
+
return;
|
|
1101
|
+
}
|
|
1102
|
+
const cfg = await loadConfig(values.root ?? process.cwd(), {
|
|
1103
|
+
dir: values.dir,
|
|
1104
|
+
sourceLocale: values.source,
|
|
1105
|
+
locales: values.locales?.split(",")
|
|
1106
|
+
});
|
|
1107
|
+
if (command === "extract") {
|
|
1108
|
+
const registry = await extractProject(cfg);
|
|
1109
|
+
const catalogs = loadCatalogs(cfg);
|
|
1110
|
+
if (values.prune) {
|
|
1111
|
+
const removed = pruneCatalogs(cfg, catalogs, registry);
|
|
1112
|
+
for (const [locale, keys] of Object.entries(removed)) console.log(` ${locale}: -${keys.length} pruned`);
|
|
1113
|
+
}
|
|
1114
|
+
const { added } = syncCatalogs(cfg, catalogs, registry);
|
|
1115
|
+
for (const locale of cfg.locales) writeCatalog(cfg, locale, catalogs[locale] ?? {});
|
|
1116
|
+
writeDts(cfg, new Map(Object.entries(catalogs[cfg.sourceLocale] ?? {})));
|
|
1117
|
+
const total = registry.messages().size;
|
|
1118
|
+
console.log(`[verbaly] ${total} messages · locales: ${cfg.locales.join(", ")}`);
|
|
1119
|
+
for (const [locale, keys] of Object.entries(added)) console.log(` ${locale}: +${keys.length}`);
|
|
1120
|
+
return;
|
|
1121
|
+
}
|
|
1122
|
+
if (command === "check") {
|
|
1123
|
+
const registry = await extractProject(cfg);
|
|
1124
|
+
const result = check(cfg, loadCatalogs(cfg), registry);
|
|
1125
|
+
if (result.ok) {
|
|
1126
|
+
console.log("[verbaly] all translations complete ✓");
|
|
1127
|
+
return;
|
|
1128
|
+
}
|
|
1129
|
+
console.error(`[verbaly] check failed\n${formatCheckResult(result)}`);
|
|
1130
|
+
process.exitCode = 1;
|
|
1131
|
+
return;
|
|
1132
|
+
}
|
|
1133
|
+
if (command === "translate") {
|
|
1134
|
+
const catalogs = loadCatalogs(cfg);
|
|
1135
|
+
const result = await translateCatalogs(cfg, catalogs, await resolveProvider(cfg, values.model), {
|
|
1136
|
+
locales: values.locales?.split(","),
|
|
1137
|
+
batchSize: cfg.translate.batchSize,
|
|
1138
|
+
dryRun: values["dry-run"]
|
|
1139
|
+
});
|
|
1140
|
+
if (values["dry-run"]) {
|
|
1141
|
+
const entries = Object.entries(result.pending);
|
|
1142
|
+
if (entries.length === 0) {
|
|
1143
|
+
console.log("[verbaly] nothing to translate ✓");
|
|
1144
|
+
return;
|
|
1145
|
+
}
|
|
1146
|
+
for (const [locale, keys] of entries) console.log(` ${locale}: ${keys.length} missing — ${keys.join(", ")}`);
|
|
1147
|
+
return;
|
|
1148
|
+
}
|
|
1149
|
+
for (const locale of Object.keys(result.translated)) {
|
|
1150
|
+
writeCatalog(cfg, locale, catalogs[locale] ?? {});
|
|
1151
|
+
console.log(` ${locale}: +${result.translated[locale].length} translated`);
|
|
1152
|
+
}
|
|
1153
|
+
for (const [locale, keys] of Object.entries(result.invalid)) console.warn(` ${locale}: ${keys.length} rejected (params/tags not preserved): ${keys.join(", ")}`);
|
|
1154
|
+
if (Object.keys(result.translated).length === 0 && Object.keys(result.invalid).length === 0) console.log("[verbaly] nothing to translate ✓");
|
|
1155
|
+
return;
|
|
1156
|
+
}
|
|
1157
|
+
if (command === "render") {
|
|
1158
|
+
const result = await renderSite(cfg, {
|
|
1159
|
+
site: values.site,
|
|
1160
|
+
locales: values.locales?.split(",")
|
|
1161
|
+
});
|
|
1162
|
+
console.log(`[verbaly] ${result.files} pages × ${result.locales.length} locales (${result.locales.join(", ")})`);
|
|
1163
|
+
for (const [locale, keys] of Object.entries(result.missing)) console.warn(` ${locale}: ${keys.length} keys not pre-filled — ${keys.join(", ")}`);
|
|
1164
|
+
return;
|
|
1165
|
+
}
|
|
1166
|
+
if (command === "pseudo") {
|
|
1167
|
+
const catalogs = loadCatalogs(cfg);
|
|
1168
|
+
const locale = values.locale ?? "en-XA";
|
|
1169
|
+
const keys = pseudoCatalogs(cfg, catalogs, locale);
|
|
1170
|
+
writeCatalog(cfg, locale, catalogs[locale] ?? {});
|
|
1171
|
+
console.log(`[verbaly] ${keys.length} messages pseudo-localized → ${locale}`);
|
|
1172
|
+
return;
|
|
1173
|
+
}
|
|
1174
|
+
console.error(`[verbaly] unknown command "${command}"\n${HELP}`);
|
|
1175
|
+
process.exitCode = 1;
|
|
1116
1176
|
}
|
|
1117
1177
|
async function resolveProvider(cfg, model) {
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1178
|
+
const configured = cfg.translate.provider;
|
|
1179
|
+
if (typeof configured === "function") return configured;
|
|
1180
|
+
const { claudeProvider } = await import("./claude-BhP-eK5E.js");
|
|
1181
|
+
return claudeProvider({ model: model ?? cfg.translate.model });
|
|
1122
1182
|
}
|
|
1123
1183
|
main().catch((error) => {
|
|
1124
|
-
|
|
1125
|
-
|
|
1184
|
+
console.error("[verbaly]", error instanceof Error ? error.message : error);
|
|
1185
|
+
process.exitCode = 1;
|
|
1126
1186
|
});
|
|
1187
|
+
//#endregion
|
|
1188
|
+
export {};
|
|
1189
|
+
|
|
1127
1190
|
//# sourceMappingURL=cli.js.map
|