@verbaly/compiler 0.25.0 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{claude-C-ETlqsW.js → claude-h9WA0ppg.js} +4 -2
- package/dist/claude-h9WA0ppg.js.map +1 -0
- package/dist/cli.js +452 -26
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +51 -4
- package/dist/index.js +363 -17
- package/dist/index.js.map +1 -1
- package/package.json +4 -2
- package/dist/claude-C-ETlqsW.js.map +0 -1
package/dist/cli.js
CHANGED
|
@@ -8,6 +8,7 @@ import { glob } from "tinyglobby";
|
|
|
8
8
|
import { parse as parse$1 } from "@babel/parser";
|
|
9
9
|
import { createHash } from "node:crypto";
|
|
10
10
|
import MagicString from "magic-string";
|
|
11
|
+
import "picomatch";
|
|
11
12
|
//#region src/catalog.ts
|
|
12
13
|
function catalogPath(cfg, locale) {
|
|
13
14
|
return join(cfg.dir, `${locale}.json`);
|
|
@@ -98,6 +99,57 @@ function formatCheckResult(result) {
|
|
|
98
99
|
function truncate(text, max) {
|
|
99
100
|
return text.length > max ? text.slice(0, max - 1) + "…" : text;
|
|
100
101
|
}
|
|
102
|
+
function githubCheckAnnotations(result, registry, root) {
|
|
103
|
+
const messages = registry.messages();
|
|
104
|
+
const contents = /* @__PURE__ */ new Map();
|
|
105
|
+
const readSource = (file) => {
|
|
106
|
+
if (!contents.has(file)) try {
|
|
107
|
+
contents.set(file, readFileSync(file, "utf8"));
|
|
108
|
+
} catch {
|
|
109
|
+
contents.set(file, void 0);
|
|
110
|
+
}
|
|
111
|
+
return contents.get(file);
|
|
112
|
+
};
|
|
113
|
+
const lines = [];
|
|
114
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
115
|
+
for (const entry of result.missing) {
|
|
116
|
+
const grouped = byKey.get(entry.key);
|
|
117
|
+
if (grouped) grouped.locales.push(entry.locale);
|
|
118
|
+
else byKey.set(entry.key, {
|
|
119
|
+
...entry,
|
|
120
|
+
locales: [entry.locale]
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
for (const entry of byKey.values()) {
|
|
124
|
+
const origin = messages.get(entry.key);
|
|
125
|
+
const hint = entry.source ? `: "${truncate(entry.source, 60)}"` : "";
|
|
126
|
+
const text = escapeData(`missing [${entry.locales.join(", ")}] ${entry.key}${hint}`);
|
|
127
|
+
if (origin) {
|
|
128
|
+
const file = relative(root, origin.file).replaceAll("\\", "/");
|
|
129
|
+
const content = readSource(origin.file);
|
|
130
|
+
const line = content === void 0 ? void 0 : lineAt(content, origin.start);
|
|
131
|
+
lines.push(`::error file=${escapeProperty(file)}${line ? `,line=${line}` : ""}::${text}`);
|
|
132
|
+
} else lines.push(`::error::${text}`);
|
|
133
|
+
}
|
|
134
|
+
for (const entry of result.unknown) {
|
|
135
|
+
const text = escapeData(`unknown key "${entry.key}" (not in any catalog)`);
|
|
136
|
+
const file = entry.files[0];
|
|
137
|
+
lines.push(file ? `::error file=${escapeProperty(relative(root, file).replaceAll("\\", "/"))}::${text}` : `::error::${text}`);
|
|
138
|
+
}
|
|
139
|
+
return lines;
|
|
140
|
+
}
|
|
141
|
+
function lineAt(content, offset) {
|
|
142
|
+
let line = 1;
|
|
143
|
+
const end = Math.min(offset, content.length);
|
|
144
|
+
for (let i = 0; i < end; i++) if (content[i] === "\n") line += 1;
|
|
145
|
+
return line;
|
|
146
|
+
}
|
|
147
|
+
function escapeData(text) {
|
|
148
|
+
return text.replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A");
|
|
149
|
+
}
|
|
150
|
+
function escapeProperty(text) {
|
|
151
|
+
return escapeData(text).replaceAll(":", "%3A").replaceAll(",", "%2C");
|
|
152
|
+
}
|
|
101
153
|
//#endregion
|
|
102
154
|
//#region src/params.ts
|
|
103
155
|
const PLURAL_KEYS = /* @__PURE__ */ new Set([
|
|
@@ -199,12 +251,13 @@ declare module 'virtual:verbaly/locale/*' {
|
|
|
199
251
|
}
|
|
200
252
|
`;
|
|
201
253
|
}
|
|
202
|
-
function writeDts(cfg, catalog) {
|
|
203
|
-
|
|
254
|
+
function writeDts(cfg, catalog, file) {
|
|
255
|
+
file ??= join(cfg.root, "verbaly.d.ts");
|
|
204
256
|
const content = generateDts(catalog);
|
|
205
257
|
try {
|
|
206
258
|
if (readFileSync(file, "utf8") === content) return;
|
|
207
259
|
} catch {}
|
|
260
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
208
261
|
writeFileSync(file, content);
|
|
209
262
|
}
|
|
210
263
|
//#endregion
|
|
@@ -334,15 +387,16 @@ function resolveConfig(config = {}) {
|
|
|
334
387
|
const sourceLocale = config.sourceLocale ?? "en";
|
|
335
388
|
const locales = /* @__PURE__ */ new Set([sourceLocale, ...config.locales ?? []]);
|
|
336
389
|
if (existsSync(dir)) {
|
|
337
|
-
for (const file of readdirSync(dir)) if (file.endsWith(".json") && file !== `en-XA.json`) locales.add(file.slice(0, -5));
|
|
390
|
+
for (const file of readdirSync(dir)) if (file.endsWith(".json") && !file.startsWith(".") && file !== `en-XA.json`) locales.add(file.slice(0, -5));
|
|
338
391
|
}
|
|
339
392
|
return {
|
|
340
393
|
root,
|
|
341
394
|
dir,
|
|
342
395
|
sourceLocale,
|
|
343
396
|
locales: [...locales],
|
|
344
|
-
include: config.include ?? ["{src,app}/**/*.{js,jsx,ts,tsx,mjs,mts,svelte,vue}"],
|
|
397
|
+
include: config.include ?? ["{src,app}/**/*.{js,jsx,ts,tsx,mjs,mts,svelte,vue,astro}"],
|
|
345
398
|
exclude: config.exclude ?? ["**/node_modules/**", "**/dist/**"],
|
|
399
|
+
dts: typeof config.dts === "string" ? resolve(root, config.dts) : config.dts,
|
|
346
400
|
translate: config.translate ?? {},
|
|
347
401
|
render: config.render ?? {}
|
|
348
402
|
};
|
|
@@ -705,13 +759,14 @@ function isNode(value) {
|
|
|
705
759
|
}
|
|
706
760
|
//#endregion
|
|
707
761
|
//#region src/sfc.ts
|
|
708
|
-
const SFC_FILE_RE = /\.(?:svelte|vue)$/;
|
|
762
|
+
const SFC_FILE_RE = /\.(?:svelte|vue|astro)$/;
|
|
709
763
|
function analyzeFile(code, file) {
|
|
710
764
|
return SFC_FILE_RE.test(file) ? analyzeSfc(code, file) : analyze(code, file);
|
|
711
765
|
}
|
|
712
766
|
const SCRIPT_RE = /(<script\b[^>]*>)([\s\S]*?)(<\/script\s*>)/gi;
|
|
713
767
|
const STYLE_RE = /<style\b[^>]*>[\s\S]*?<\/style\s*>/gi;
|
|
714
768
|
const COMMENT_RE = /<!--[\s\S]*?-->/g;
|
|
769
|
+
const FRONTMATTER_RE = /^(\uFEFF?\s*---\r?\n)([\s\S]*?)\r?\n---(?=\r?\n|$)/;
|
|
715
770
|
const CANDIDATE_SVELTE = /(?<![\w$])\$?t(?=[`(]|\.id\()/g;
|
|
716
771
|
const CANDIDATE_VUE = /(?<![\w$])t(?=[`(]|\.id\()/g;
|
|
717
772
|
function analyzeSfc(code, file) {
|
|
@@ -719,12 +774,14 @@ function analyzeSfc(code, file) {
|
|
|
719
774
|
const options = svelte ? { tNames: ["t", "$t"] } : void 0;
|
|
720
775
|
const tagged = [];
|
|
721
776
|
const usedKeys = [];
|
|
777
|
+
const frontmatter = file.endsWith(".astro") ? FRONTMATTER_RE.exec(code) : null;
|
|
778
|
+
if (frontmatter?.[2]) merge(analyzeSegment(frontmatter[2], file, options), frontmatter[1].length, false);
|
|
722
779
|
for (const match of code.matchAll(SCRIPT_RE)) {
|
|
723
780
|
const content = match[2];
|
|
724
781
|
if (!content) continue;
|
|
725
782
|
merge(analyzeSegment(content, file, options), match.index + match[1].length, false);
|
|
726
783
|
}
|
|
727
|
-
const markup = blank(blank(blank(code, SCRIPT_RE), STYLE_RE), COMMENT_RE);
|
|
784
|
+
const markup = blank(blank(blank(frontmatter ? " ".repeat(frontmatter[0].length) + code.slice(frontmatter[0].length) : code, SCRIPT_RE), STYLE_RE), COMMENT_RE);
|
|
728
785
|
const candidates = svelte ? CANDIDATE_SVELTE : CANDIDATE_VUE;
|
|
729
786
|
let consumedTo = 0;
|
|
730
787
|
for (const match of markup.matchAll(candidates)) {
|
|
@@ -854,6 +911,7 @@ function pruneCatalogs(cfg, catalogs, registry) {
|
|
|
854
911
|
//#endregion
|
|
855
912
|
//#region src/init.ts
|
|
856
913
|
const BUNDLERS = [
|
|
914
|
+
"astro",
|
|
857
915
|
"vite",
|
|
858
916
|
"webpack",
|
|
859
917
|
"rollup",
|
|
@@ -907,7 +965,8 @@ function init(options = {}) {
|
|
|
907
965
|
}
|
|
908
966
|
const bundler = detectBundler(root);
|
|
909
967
|
const next = [];
|
|
910
|
-
if (bundler === "
|
|
968
|
+
if (bundler === "astro") next.push("install the integration: pnpm add -D @verbaly/astro", "add verbaly() to the integrations in astro.config");
|
|
969
|
+
else if (bundler === "vite") next.push("install the plugin: pnpm add -D @verbaly/vite", "add verbaly() to the plugins in vite.config");
|
|
911
970
|
else if (bundler) next.push("install the plugin: pnpm add -D @verbaly/unplugin", `add the verbaly ${bundler} plugin to your build config`);
|
|
912
971
|
else next.push("run \"verbaly extract\" after writing your first t`…` message");
|
|
913
972
|
return {
|
|
@@ -980,8 +1039,8 @@ async function doctor(cfg) {
|
|
|
980
1039
|
else if (wired) ok("plugin", `${deps["@verbaly/vite"] ? "@verbaly/vite" : "@verbaly/unplugin"} installed for ${bundler}`);
|
|
981
1040
|
else if (bundler === "vite") warn("plugin", "vite detected but @verbaly/vite is not installed", "pnpm add -D @verbaly/vite and add verbaly() to the plugins in vite.config");
|
|
982
1041
|
else warn("plugin", `${bundler} detected but @verbaly/unplugin is not installed`, `pnpm add -D @verbaly/unplugin and add the verbaly ${bundler} plugin to your build config`);
|
|
983
|
-
if (source) {
|
|
984
|
-
const dtsPath = join(cfg.root, "verbaly.d.ts");
|
|
1042
|
+
if (source && cfg.dts !== false) {
|
|
1043
|
+
const dtsPath = cfg.dts ?? join(cfg.root, "verbaly.d.ts");
|
|
985
1044
|
if (!existsSync(dtsPath)) warn("types", "verbaly.d.ts has not been generated", "run `npx verbaly extract`");
|
|
986
1045
|
else if (readFileSync(dtsPath, "utf8") !== generateDts(source)) warn("types", "verbaly.d.ts is stale", "run `npx verbaly extract`");
|
|
987
1046
|
else ok("types", "verbaly.d.ts is up to date");
|
|
@@ -1024,6 +1083,66 @@ function readDeps(root) {
|
|
|
1024
1083
|
}
|
|
1025
1084
|
}
|
|
1026
1085
|
//#endregion
|
|
1086
|
+
//#region src/drafts.ts
|
|
1087
|
+
const DRAFTS_FILE = ".verbaly-drafts.json";
|
|
1088
|
+
function draftsPath(cfg) {
|
|
1089
|
+
return join(cfg.dir, DRAFTS_FILE);
|
|
1090
|
+
}
|
|
1091
|
+
function loadDrafts(cfg) {
|
|
1092
|
+
let content;
|
|
1093
|
+
try {
|
|
1094
|
+
content = readFileSync(draftsPath(cfg), "utf8");
|
|
1095
|
+
} catch {
|
|
1096
|
+
return {};
|
|
1097
|
+
}
|
|
1098
|
+
try {
|
|
1099
|
+
return JSON.parse(content.replace(/^\uFEFF/, ""));
|
|
1100
|
+
} catch (error) {
|
|
1101
|
+
throw new Error(`[verbaly] ${draftsPath(cfg)} is not valid JSON, fix or delete the file`, { cause: error });
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
function serializeDrafts(drafts) {
|
|
1105
|
+
const sorted = {};
|
|
1106
|
+
for (const locale of Object.keys(drafts).sort()) {
|
|
1107
|
+
const keys = [...new Set(drafts[locale])].sort();
|
|
1108
|
+
if (keys.length) sorted[locale] = keys;
|
|
1109
|
+
}
|
|
1110
|
+
return JSON.stringify(sorted, null, 2) + "\n";
|
|
1111
|
+
}
|
|
1112
|
+
function saveDrafts(cfg, drafts) {
|
|
1113
|
+
const serialized = serializeDrafts(drafts);
|
|
1114
|
+
const path = draftsPath(cfg);
|
|
1115
|
+
try {
|
|
1116
|
+
if (readFileSync(path, "utf8") === serialized) return;
|
|
1117
|
+
} catch {
|
|
1118
|
+
mkdirSync(cfg.dir, { recursive: true });
|
|
1119
|
+
}
|
|
1120
|
+
writeFileSync(path, serialized);
|
|
1121
|
+
}
|
|
1122
|
+
function markDrafts(drafts, locale, keys) {
|
|
1123
|
+
if (!keys.length) return;
|
|
1124
|
+
drafts[locale] = [.../* @__PURE__ */ new Set([...drafts[locale] ?? [], ...keys])];
|
|
1125
|
+
}
|
|
1126
|
+
function clearDrafts(drafts, locale, keys) {
|
|
1127
|
+
if (!drafts[locale]) return;
|
|
1128
|
+
if (!keys) {
|
|
1129
|
+
delete drafts[locale];
|
|
1130
|
+
return;
|
|
1131
|
+
}
|
|
1132
|
+
const drop = new Set(keys);
|
|
1133
|
+
drafts[locale] = drafts[locale].filter((key) => !drop.has(key));
|
|
1134
|
+
if (!drafts[locale].length) delete drafts[locale];
|
|
1135
|
+
}
|
|
1136
|
+
function effectiveDrafts(drafts, catalogs) {
|
|
1137
|
+
const out = {};
|
|
1138
|
+
for (const [locale, keys] of Object.entries(drafts)) {
|
|
1139
|
+
const catalog = catalogs[locale] ?? {};
|
|
1140
|
+
const live = keys.filter((key) => catalog[key]);
|
|
1141
|
+
if (live.length) out[locale] = live;
|
|
1142
|
+
}
|
|
1143
|
+
return out;
|
|
1144
|
+
}
|
|
1145
|
+
//#endregion
|
|
1027
1146
|
//#region src/mobile.ts
|
|
1028
1147
|
function androidValuesDir(locale, sourceLocale) {
|
|
1029
1148
|
if (locale === sourceLocale) return "values";
|
|
@@ -1084,10 +1203,12 @@ async function translateCatalogs(cfg, catalogs, provider, options = {}) {
|
|
|
1084
1203
|
for (let i = 0; i < missing.length; i += batchSize) {
|
|
1085
1204
|
const keys = missing.slice(i, i + batchSize);
|
|
1086
1205
|
const messages = Object.fromEntries(keys.map((key) => [key, source[key]]));
|
|
1206
|
+
const origins = options.origins ? Object.fromEntries(keys.filter((key) => options.origins[key]).map((key) => [key, options.origins[key]])) : void 0;
|
|
1087
1207
|
const out = await provider({
|
|
1088
1208
|
sourceLocale: cfg.sourceLocale,
|
|
1089
1209
|
targetLocale: locale,
|
|
1090
|
-
messages
|
|
1210
|
+
messages,
|
|
1211
|
+
origins
|
|
1091
1212
|
});
|
|
1092
1213
|
for (const key of keys) {
|
|
1093
1214
|
const text = out[key];
|
|
@@ -1611,9 +1732,10 @@ function decodeEntities(text) {
|
|
|
1611
1732
|
}
|
|
1612
1733
|
//#endregion
|
|
1613
1734
|
//#region src/status.ts
|
|
1614
|
-
function status(cfg, catalogs, registry) {
|
|
1735
|
+
function status(cfg, catalogs, registry, drafts = {}) {
|
|
1615
1736
|
const source = catalogs[cfg.sourceLocale] ?? {};
|
|
1616
1737
|
const needed = /* @__PURE__ */ new Set([...registry.messages().keys(), ...Object.keys(source)]);
|
|
1738
|
+
const live = effectiveDrafts(drafts, catalogs);
|
|
1617
1739
|
const locales = [];
|
|
1618
1740
|
for (const locale of cfg.locales) {
|
|
1619
1741
|
if (locale === cfg.sourceLocale) continue;
|
|
@@ -1622,7 +1744,8 @@ function status(cfg, catalogs, registry) {
|
|
|
1622
1744
|
locales.push({
|
|
1623
1745
|
locale,
|
|
1624
1746
|
translated,
|
|
1625
|
-
total: needed.size
|
|
1747
|
+
total: needed.size,
|
|
1748
|
+
drafts: live[locale]?.length ?? 0
|
|
1626
1749
|
});
|
|
1627
1750
|
}
|
|
1628
1751
|
return {
|
|
@@ -1637,14 +1760,15 @@ function formatStatusResult(result) {
|
|
|
1637
1760
|
lines.push(" no target locales (add locales to your config)");
|
|
1638
1761
|
return lines.join("\n");
|
|
1639
1762
|
}
|
|
1640
|
-
for (const { locale, translated, total } of result.locales) {
|
|
1763
|
+
for (const { locale, translated, total, drafts } of result.locales) {
|
|
1641
1764
|
const pct = total === 0 ? 100 : Math.floor(translated / total * 100);
|
|
1642
1765
|
const mark = translated === total ? " ✓" : "";
|
|
1643
|
-
|
|
1766
|
+
const draftNote = drafts > 0 ? `, ${drafts} unreviewed` : "";
|
|
1767
|
+
lines.push(` ${locale}: ${translated}/${total} translated (${pct}%${draftNote})${mark}`);
|
|
1644
1768
|
}
|
|
1645
1769
|
return lines.join("\n");
|
|
1646
1770
|
}
|
|
1647
|
-
const SOURCE_FILE_RE = /\.(?:[cm]?[jt]sx?|svelte|vue)$/;
|
|
1771
|
+
const SOURCE_FILE_RE = /\.(?:[cm]?[jt]sx?|svelte|vue|astro)$/;
|
|
1648
1772
|
//#endregion
|
|
1649
1773
|
//#region src/watch.ts
|
|
1650
1774
|
function watchProject(cfg, run, options = {}) {
|
|
@@ -1687,16 +1811,230 @@ function watchProject(cfg, run, options = {}) {
|
|
|
1687
1811
|
};
|
|
1688
1812
|
}
|
|
1689
1813
|
//#endregion
|
|
1814
|
+
//#region src/wrap.ts
|
|
1815
|
+
const WRAP_ATTRS = /* @__PURE__ */ new Set([
|
|
1816
|
+
"title",
|
|
1817
|
+
"alt",
|
|
1818
|
+
"placeholder",
|
|
1819
|
+
"aria-label"
|
|
1820
|
+
]);
|
|
1821
|
+
const T_NAMES = /* @__PURE__ */ new Set(["t"]);
|
|
1822
|
+
const LETTER = /\p{L}/u;
|
|
1823
|
+
async function wrapProject(cfg, options = {}) {
|
|
1824
|
+
const files = (await glob(cfg.include, {
|
|
1825
|
+
cwd: cfg.root,
|
|
1826
|
+
ignore: cfg.exclude,
|
|
1827
|
+
absolute: true
|
|
1828
|
+
})).filter((file) => /\.[jt]sx$/.test(file));
|
|
1829
|
+
const result = {
|
|
1830
|
+
files: files.length,
|
|
1831
|
+
changed: [],
|
|
1832
|
+
wrapped: [],
|
|
1833
|
+
skipped: []
|
|
1834
|
+
};
|
|
1835
|
+
for (const file of files) {
|
|
1836
|
+
const code = readFileSync(file, "utf8");
|
|
1837
|
+
const label = relative(cfg.root, file).replaceAll("\\", "/");
|
|
1838
|
+
const out = wrapCode(code, label);
|
|
1839
|
+
result.wrapped.push(...out.wrapped);
|
|
1840
|
+
result.skipped.push(...out.skipped);
|
|
1841
|
+
if (out.code !== void 0) {
|
|
1842
|
+
result.changed.push(label);
|
|
1843
|
+
if (options.write) writeFileSync(file, out.code);
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
return result;
|
|
1847
|
+
}
|
|
1848
|
+
function wrapCode(code, file) {
|
|
1849
|
+
const wrapped = [];
|
|
1850
|
+
const skipped = [];
|
|
1851
|
+
let ast;
|
|
1852
|
+
try {
|
|
1853
|
+
ast = parse$1(code, {
|
|
1854
|
+
sourceType: "module",
|
|
1855
|
+
errorRecovery: true,
|
|
1856
|
+
plugins: ["typescript", "jsx"]
|
|
1857
|
+
}).program;
|
|
1858
|
+
} catch {
|
|
1859
|
+
return {
|
|
1860
|
+
wrapped,
|
|
1861
|
+
skipped
|
|
1862
|
+
};
|
|
1863
|
+
}
|
|
1864
|
+
const s = new MagicString(code);
|
|
1865
|
+
const visited = /* @__PURE__ */ new Set();
|
|
1866
|
+
let changed = false;
|
|
1867
|
+
walk(ast, (node) => {
|
|
1868
|
+
if (node.type !== "JSXElement" && node.type !== "JSXFragment") return;
|
|
1869
|
+
if (visited.has(node)) return;
|
|
1870
|
+
processElement(node);
|
|
1871
|
+
});
|
|
1872
|
+
return changed ? {
|
|
1873
|
+
code: s.toString(),
|
|
1874
|
+
wrapped,
|
|
1875
|
+
skipped
|
|
1876
|
+
} : {
|
|
1877
|
+
wrapped,
|
|
1878
|
+
skipped
|
|
1879
|
+
};
|
|
1880
|
+
function processElement(node) {
|
|
1881
|
+
visited.add(node);
|
|
1882
|
+
if (node.type === "JSXElement") {
|
|
1883
|
+
const opening = node.openingElement;
|
|
1884
|
+
const name = opening.name;
|
|
1885
|
+
if (name.type === "JSXIdentifier" && name.name === "Trans") return markSubtree(node);
|
|
1886
|
+
if (hasVerbalyAttr(opening)) return markSubtree(node);
|
|
1887
|
+
wrapAttributes(opening);
|
|
1888
|
+
}
|
|
1889
|
+
const children = node.children ?? [];
|
|
1890
|
+
const hasElementChild = children.some((c) => c.type === "JSXElement" || c.type === "JSXFragment");
|
|
1891
|
+
const hasText = children.some((c) => c.type === "JSXText" && LETTER.test(c.value) || c.type === "JSXExpressionContainer" && c.expression.type === "StringLiteral" && LETTER.test(c.expression.value));
|
|
1892
|
+
if (hasElementChild && hasText) {
|
|
1893
|
+
skip(node, snippet(children), "mixed text and markup: wrap by hand or use <Trans>");
|
|
1894
|
+
return markSubtree(node);
|
|
1895
|
+
}
|
|
1896
|
+
if (hasText) {
|
|
1897
|
+
wrapSegment(node, children);
|
|
1898
|
+
return markSubtree(node);
|
|
1899
|
+
}
|
|
1900
|
+
for (const child of children) if (child.type === "JSXElement" || child.type === "JSXFragment") processElement(child);
|
|
1901
|
+
}
|
|
1902
|
+
function wrapSegment(parent, children) {
|
|
1903
|
+
const meaningful = children.filter((c) => c.type === "JSXText" && c.value.trim() !== "" || c.type === "JSXExpressionContainer" && c.expression.type !== "JSXEmptyExpression");
|
|
1904
|
+
if (meaningful.length === 0) return;
|
|
1905
|
+
const first = meaningful[0];
|
|
1906
|
+
const last = meaningful[meaningful.length - 1];
|
|
1907
|
+
let template = "";
|
|
1908
|
+
let report = "";
|
|
1909
|
+
for (let i = children.indexOf(first); i <= children.indexOf(last); i++) {
|
|
1910
|
+
const child = children[i];
|
|
1911
|
+
if (child.type === "JSXText") {
|
|
1912
|
+
let value = cleanJsxText(child.value);
|
|
1913
|
+
if (child === first) value = value.trimStart();
|
|
1914
|
+
if (child === last) value = value.trimEnd();
|
|
1915
|
+
template += escapeTemplate(value);
|
|
1916
|
+
report += value;
|
|
1917
|
+
} else if (child.type === "JSXExpressionContainer") {
|
|
1918
|
+
const expr = child.expression;
|
|
1919
|
+
if (expr.type === "JSXEmptyExpression") continue;
|
|
1920
|
+
if (usesT(expr)) {
|
|
1921
|
+
skip(parent, snippet(children), "already uses t: finish it by hand");
|
|
1922
|
+
return;
|
|
1923
|
+
}
|
|
1924
|
+
if (containsJsx(expr)) {
|
|
1925
|
+
skip(parent, snippet(children), "an expression renders markup: wrap by hand or use <Trans>");
|
|
1926
|
+
markSubtree(child);
|
|
1927
|
+
return;
|
|
1928
|
+
}
|
|
1929
|
+
if (expr.type === "StringLiteral") {
|
|
1930
|
+
template += escapeTemplate(expr.value);
|
|
1931
|
+
report += expr.value;
|
|
1932
|
+
} else {
|
|
1933
|
+
const source = code.slice(expr.start, expr.end);
|
|
1934
|
+
template += "${" + source + "}";
|
|
1935
|
+
report += "${" + source + "}";
|
|
1936
|
+
}
|
|
1937
|
+
} else return;
|
|
1938
|
+
}
|
|
1939
|
+
if (!LETTER.test(report)) return;
|
|
1940
|
+
const start = first.type === "JSXText" ? first.start + leadingWs(first.value) : first.start;
|
|
1941
|
+
const end = last.type === "JSXText" ? last.end - trailingWs(last.value) : last.end;
|
|
1942
|
+
s.overwrite(start, end, "{t`" + template + "`}");
|
|
1943
|
+
changed = true;
|
|
1944
|
+
wrapped.push({
|
|
1945
|
+
file,
|
|
1946
|
+
line: line(first),
|
|
1947
|
+
text: report,
|
|
1948
|
+
kind: "text"
|
|
1949
|
+
});
|
|
1950
|
+
}
|
|
1951
|
+
function wrapAttributes(opening) {
|
|
1952
|
+
for (const attr of opening.attributes) {
|
|
1953
|
+
if (attr.type !== "JSXAttribute") continue;
|
|
1954
|
+
const name = attr.name;
|
|
1955
|
+
if (name.type !== "JSXIdentifier" || !WRAP_ATTRS.has(name.name)) continue;
|
|
1956
|
+
const value = attr.value;
|
|
1957
|
+
if (value?.type !== "StringLiteral") continue;
|
|
1958
|
+
const raw = value.value;
|
|
1959
|
+
if (!LETTER.test(raw)) continue;
|
|
1960
|
+
s.overwrite(value.start, value.end, "{t`" + escapeTemplate(raw) + "`}");
|
|
1961
|
+
changed = true;
|
|
1962
|
+
wrapped.push({
|
|
1963
|
+
file,
|
|
1964
|
+
line: line(value),
|
|
1965
|
+
text: raw,
|
|
1966
|
+
kind: "attribute",
|
|
1967
|
+
attribute: name.name
|
|
1968
|
+
});
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
function skip(node, text, reason) {
|
|
1972
|
+
skipped.push({
|
|
1973
|
+
file,
|
|
1974
|
+
line: line(node),
|
|
1975
|
+
text,
|
|
1976
|
+
reason
|
|
1977
|
+
});
|
|
1978
|
+
}
|
|
1979
|
+
function snippet(children) {
|
|
1980
|
+
const start = children[0]?.start ?? 0;
|
|
1981
|
+
const end = children[children.length - 1]?.end ?? start;
|
|
1982
|
+
const text = code.slice(start, end).replace(/\s+/g, " ").trim();
|
|
1983
|
+
return text.length > 60 ? text.slice(0, 59) + "…" : text;
|
|
1984
|
+
}
|
|
1985
|
+
function markSubtree(node) {
|
|
1986
|
+
walk(node, (n) => {
|
|
1987
|
+
if (n.type === "JSXElement" || n.type === "JSXFragment") visited.add(n);
|
|
1988
|
+
});
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1991
|
+
function hasVerbalyAttr(opening) {
|
|
1992
|
+
return opening.attributes.some((attr) => {
|
|
1993
|
+
if (attr.type !== "JSXAttribute") return false;
|
|
1994
|
+
const name = attr.name;
|
|
1995
|
+
return name.type === "JSXIdentifier" && name.name.startsWith("data-verbaly");
|
|
1996
|
+
});
|
|
1997
|
+
}
|
|
1998
|
+
function usesT(node) {
|
|
1999
|
+
let found = false;
|
|
2000
|
+
walk(node, (n) => {
|
|
2001
|
+
if (n.type === "TaggedTemplateExpression" && isTReference(n.tag, T_NAMES)) found = true;
|
|
2002
|
+
if (n.type === "CallExpression" && isTReference(n.callee, T_NAMES)) found = true;
|
|
2003
|
+
});
|
|
2004
|
+
return found;
|
|
2005
|
+
}
|
|
2006
|
+
function containsJsx(node) {
|
|
2007
|
+
let found = false;
|
|
2008
|
+
walk(node, (n) => {
|
|
2009
|
+
if (n.type === "JSXElement" || n.type === "JSXFragment") found = true;
|
|
2010
|
+
});
|
|
2011
|
+
return found;
|
|
2012
|
+
}
|
|
2013
|
+
function escapeTemplate(text) {
|
|
2014
|
+
return text.replace(/[\\`]/g, "\\$&").replace(/\$\{/g, "\\${");
|
|
2015
|
+
}
|
|
2016
|
+
function leadingWs(text) {
|
|
2017
|
+
return text.length - text.trimStart().length;
|
|
2018
|
+
}
|
|
2019
|
+
function trailingWs(text) {
|
|
2020
|
+
return text.length - text.trimEnd().length;
|
|
2021
|
+
}
|
|
2022
|
+
function line(node) {
|
|
2023
|
+
return node.loc?.start?.line ?? 1;
|
|
2024
|
+
}
|
|
2025
|
+
//#endregion
|
|
1690
2026
|
//#region src/run.ts
|
|
1691
2027
|
const HELP = `verbaly · i18n compiler
|
|
1692
2028
|
|
|
1693
2029
|
Usage:
|
|
1694
2030
|
verbaly init scaffold config + locale catalogs (detects your bundler)
|
|
1695
2031
|
verbaly doctor diagnose the setup (config, catalogs, plugin, types, keys)
|
|
2032
|
+
verbaly wrap find hardcoded JSX text and wrap it in t\`…\` (report; --write applies)
|
|
1696
2033
|
verbaly extract scan sources, update catalogs and types
|
|
1697
2034
|
verbaly status translation coverage per locale, at a glance
|
|
1698
2035
|
verbaly check verify translations are complete (CI)
|
|
1699
2036
|
verbaly translate fill missing translations via a provider (default: claude)
|
|
2037
|
+
verbaly review list machine translations awaiting review (--approve marks them reviewed)
|
|
1700
2038
|
verbaly export write translator files (XLIFF 2.0, CSV) or mobile resources (Android, iOS)
|
|
1701
2039
|
verbaly import <files…> fill catalogs back from translated XLIFF/CSV files
|
|
1702
2040
|
verbaly pseudo generate a pseudo-locale catalog for i18n QA (default: en-XA)
|
|
@@ -1709,6 +2047,11 @@ Options:
|
|
|
1709
2047
|
--locales <csv> extra locales; for translate: target locales to fill
|
|
1710
2048
|
--prune drop keys no longer referenced (extract)
|
|
1711
2049
|
--watch keep extracting as source files change (extract)
|
|
2050
|
+
--write apply the rewrites instead of only reporting (wrap)
|
|
2051
|
+
--json machine-readable output (status)
|
|
2052
|
+
--drafts also fail on unreviewed machine translations (check)
|
|
2053
|
+
--approve mark listed drafts as reviewed (review)
|
|
2054
|
+
--reporter <name> failure format: text (default) or github annotations (check)
|
|
1712
2055
|
--model <id> model override for the claude provider (translate)
|
|
1713
2056
|
--dry-run list what would happen, write nothing (translate, import, extract)
|
|
1714
2057
|
--format <f> export format: xliff (default), csv, android-xml or ios-strings (export)
|
|
@@ -1736,6 +2079,11 @@ async function runCli(args = process.argv.slice(2)) {
|
|
|
1736
2079
|
locales: { type: "string" },
|
|
1737
2080
|
prune: { type: "boolean" },
|
|
1738
2081
|
watch: { type: "boolean" },
|
|
2082
|
+
write: { type: "boolean" },
|
|
2083
|
+
json: { type: "boolean" },
|
|
2084
|
+
drafts: { type: "boolean" },
|
|
2085
|
+
approve: { type: "boolean" },
|
|
2086
|
+
reporter: { type: "string" },
|
|
1739
2087
|
model: { type: "string" },
|
|
1740
2088
|
locale: { type: "string" },
|
|
1741
2089
|
site: { type: "string" },
|
|
@@ -1818,7 +2166,7 @@ async function runCli(args = process.argv.slice(2)) {
|
|
|
1818
2166
|
const { added } = syncCatalogs(cfg, catalogs, registry);
|
|
1819
2167
|
if (!dryRun) {
|
|
1820
2168
|
for (const locale of cfg.locales) writeCatalog(cfg, locale, catalogs[locale] ?? {});
|
|
1821
|
-
writeDts(cfg, catalogs[cfg.sourceLocale] ?? {});
|
|
2169
|
+
if (cfg.dts !== false) writeDts(cfg, catalogs[cfg.sourceLocale] ?? {}, cfg.dts);
|
|
1822
2170
|
}
|
|
1823
2171
|
const total = registry.messages().size;
|
|
1824
2172
|
console.log(`[verbaly] ${total} messages · locales: ${cfg.locales.join(", ")}${dryRun ? " (dry run, nothing written)" : ""}`);
|
|
@@ -1831,19 +2179,58 @@ async function runCli(args = process.argv.slice(2)) {
|
|
|
1831
2179
|
}
|
|
1832
2180
|
return;
|
|
1833
2181
|
}
|
|
2182
|
+
if (command === "wrap") {
|
|
2183
|
+
const result = await wrapProject(cfg, { write: values.write });
|
|
2184
|
+
if (result.wrapped.length === 0 && result.skipped.length === 0) {
|
|
2185
|
+
console.log(`[verbaly] nothing to wrap (${result.files} files scanned) ✓`);
|
|
2186
|
+
return;
|
|
2187
|
+
}
|
|
2188
|
+
const verb = values.write ? "wrapped" : "would wrap";
|
|
2189
|
+
const note = values.write ? "" : " (report only, use --write to apply)";
|
|
2190
|
+
console.log(`[verbaly] ${verb} ${result.wrapped.length} texts in ${result.changed.length} files${note}`);
|
|
2191
|
+
for (const entry of result.wrapped) {
|
|
2192
|
+
const attr = entry.kind === "attribute" ? `${entry.attribute} → ` : "";
|
|
2193
|
+
console.log(` ${entry.file}:${entry.line} ${attr}"${entry.text}"`);
|
|
2194
|
+
}
|
|
2195
|
+
if (result.skipped.length > 0) {
|
|
2196
|
+
console.log(" needs a human:");
|
|
2197
|
+
for (const entry of result.skipped) console.log(` ${entry.file}:${entry.line} "${entry.text}" (${entry.reason})`);
|
|
2198
|
+
}
|
|
2199
|
+
if (values.write && result.changed.length > 0) console.log(" next: make t available where TS complains (React: const t = useT()), then run verbaly extract");
|
|
2200
|
+
return;
|
|
2201
|
+
}
|
|
1834
2202
|
if (command === "status") {
|
|
1835
2203
|
const registry = await extractProject(cfg);
|
|
1836
|
-
|
|
2204
|
+
const result = status(cfg, loadCatalogs(cfg), registry, loadDrafts(cfg));
|
|
2205
|
+
console.log(values.json ? JSON.stringify(result) : formatStatusResult(result));
|
|
1837
2206
|
return;
|
|
1838
2207
|
}
|
|
1839
2208
|
if (command === "check") {
|
|
2209
|
+
const reporter = values.reporter ?? "text";
|
|
2210
|
+
if (reporter !== "text" && reporter !== "github") {
|
|
2211
|
+
console.error(`[verbaly] unknown reporter "${values.reporter}", use text or github`);
|
|
2212
|
+
process.exitCode = 1;
|
|
2213
|
+
return;
|
|
2214
|
+
}
|
|
1840
2215
|
const registry = await extractProject(cfg);
|
|
1841
|
-
const
|
|
1842
|
-
|
|
2216
|
+
const catalogs = loadCatalogs(cfg);
|
|
2217
|
+
const result = check(cfg, catalogs, registry);
|
|
2218
|
+
const unreviewed = values.drafts ? effectiveDrafts(loadDrafts(cfg), catalogs) : {};
|
|
2219
|
+
const draftKeys = Object.entries(unreviewed);
|
|
2220
|
+
if (result.ok && draftKeys.length === 0) {
|
|
1843
2221
|
console.log("[verbaly] all translations complete ✓");
|
|
1844
2222
|
return;
|
|
1845
2223
|
}
|
|
1846
|
-
|
|
2224
|
+
if (result.ok && draftKeys.length > 0) {
|
|
2225
|
+
for (const [locale, keys] of draftKeys) console.error(` [${locale}] ${keys.length} unreviewed: ${keys.join(", ")}`);
|
|
2226
|
+
console.error("[verbaly] check failed: machine translations awaiting review (run verbaly review --approve)");
|
|
2227
|
+
process.exitCode = 1;
|
|
2228
|
+
return;
|
|
2229
|
+
}
|
|
2230
|
+
if (reporter === "github") {
|
|
2231
|
+
for (const line of githubCheckAnnotations(result, registry, cfg.root)) console.error(line);
|
|
2232
|
+
console.error(`[verbaly] check failed: ${result.missing.length} missing, ${result.unknown.length} unknown`);
|
|
2233
|
+
} else console.error(`[verbaly] check failed\n${formatCheckResult(result)}`);
|
|
1847
2234
|
process.exitCode = 1;
|
|
1848
2235
|
return;
|
|
1849
2236
|
}
|
|
@@ -1852,7 +2239,8 @@ async function runCli(args = process.argv.slice(2)) {
|
|
|
1852
2239
|
const result = await translateCatalogs(cfg, catalogs, await resolveProvider(cfg, values.model), {
|
|
1853
2240
|
locales: values.locales?.split(","),
|
|
1854
2241
|
batchSize: cfg.translate.batchSize,
|
|
1855
|
-
dryRun: values["dry-run"]
|
|
2242
|
+
dryRun: values["dry-run"],
|
|
2243
|
+
origins: values["dry-run"] ? void 0 : await collectOrigins(cfg)
|
|
1856
2244
|
});
|
|
1857
2245
|
if (values["dry-run"]) {
|
|
1858
2246
|
const entries = Object.entries(result.pending);
|
|
@@ -1863,14 +2251,43 @@ async function runCli(args = process.argv.slice(2)) {
|
|
|
1863
2251
|
for (const [locale, keys] of entries) console.log(` ${locale}: ${keys.length} missing: ${keys.join(", ")}`);
|
|
1864
2252
|
return;
|
|
1865
2253
|
}
|
|
2254
|
+
const drafts = loadDrafts(cfg);
|
|
1866
2255
|
for (const locale of Object.keys(result.translated)) {
|
|
1867
2256
|
writeCatalog(cfg, locale, catalogs[locale] ?? {});
|
|
1868
|
-
|
|
2257
|
+
markDrafts(drafts, locale, result.translated[locale]);
|
|
2258
|
+
console.log(` ${locale}: +${result.translated[locale].length} translated (draft)`);
|
|
1869
2259
|
}
|
|
2260
|
+
if (Object.keys(result.translated).length > 0) saveDrafts(cfg, drafts);
|
|
1870
2261
|
for (const [locale, keys] of Object.entries(result.invalid)) console.warn(` ${locale}: ${keys.length} rejected (params/tags not preserved): ${keys.join(", ")}`);
|
|
1871
2262
|
if (Object.keys(result.translated).length === 0 && Object.keys(result.invalid).length === 0) console.log("[verbaly] nothing to translate ✓");
|
|
1872
2263
|
return;
|
|
1873
2264
|
}
|
|
2265
|
+
if (command === "review") {
|
|
2266
|
+
const catalogs = loadCatalogs(cfg);
|
|
2267
|
+
const drafts = loadDrafts(cfg);
|
|
2268
|
+
const live = effectiveDrafts(drafts, catalogs);
|
|
2269
|
+
const targets = values.locale ? { [values.locale]: live[values.locale] ?? [] } : live;
|
|
2270
|
+
const entries = Object.entries(targets).filter(([, keys]) => keys.length);
|
|
2271
|
+
if (entries.length === 0) {
|
|
2272
|
+
console.log("[verbaly] no machine translations awaiting review ✓");
|
|
2273
|
+
return;
|
|
2274
|
+
}
|
|
2275
|
+
if (values.approve) {
|
|
2276
|
+
let count = 0;
|
|
2277
|
+
for (const [locale, keys] of entries) {
|
|
2278
|
+
clearDrafts(drafts, locale, keys);
|
|
2279
|
+
count += keys.length;
|
|
2280
|
+
console.log(` ${locale}: ${keys.length} approved`);
|
|
2281
|
+
}
|
|
2282
|
+
saveDrafts(cfg, drafts);
|
|
2283
|
+
console.log(`[verbaly] ${count} translations marked reviewed ✓`);
|
|
2284
|
+
return;
|
|
2285
|
+
}
|
|
2286
|
+
const total = entries.reduce((sum, [, keys]) => sum + keys.length, 0);
|
|
2287
|
+
console.log(`[verbaly] ${total} machine translations awaiting review (--approve to accept)`);
|
|
2288
|
+
for (const [locale, keys] of entries) console.log(` ${locale}: ${keys.join(", ")}`);
|
|
2289
|
+
return;
|
|
2290
|
+
}
|
|
1874
2291
|
if (command === "export") {
|
|
1875
2292
|
const format = values.format ?? "xliff";
|
|
1876
2293
|
if (![
|
|
@@ -1917,11 +2334,18 @@ async function runCli(args = process.argv.slice(2)) {
|
|
|
1917
2334
|
overwrite: values.overwrite,
|
|
1918
2335
|
dryRun: values["dry-run"]
|
|
1919
2336
|
});
|
|
2337
|
+
const drafts = loadDrafts(cfg);
|
|
2338
|
+
let draftsChanged = false;
|
|
1920
2339
|
for (const [locale, keys] of Object.entries(result.imported)) {
|
|
1921
|
-
if (!values["dry-run"])
|
|
2340
|
+
if (!values["dry-run"]) {
|
|
2341
|
+
writeCatalog(cfg, locale, catalogs[locale] ?? {});
|
|
2342
|
+
clearDrafts(drafts, locale, keys);
|
|
2343
|
+
draftsChanged = true;
|
|
2344
|
+
}
|
|
1922
2345
|
const verb = values["dry-run"] ? "would import" : "imported";
|
|
1923
2346
|
console.log(` ${locale}: +${keys.length} ${verb}`);
|
|
1924
2347
|
}
|
|
2348
|
+
if (draftsChanged) saveDrafts(cfg, drafts);
|
|
1925
2349
|
for (const [locale, keys] of Object.entries(result.skipped)) console.log(` ${locale}: ${keys.length} already translated, kept (use --overwrite to replace)`);
|
|
1926
2350
|
for (const [locale, keys] of Object.entries(result.rejected)) console.warn(` ${locale}: ${keys.length} rejected (params/tags not preserved): ${keys.join(", ")}`);
|
|
1927
2351
|
for (const [locale, keys] of Object.entries(result.unknown)) console.warn(` ${locale}: ${keys.length} unknown keys ignored (not in the source catalog): ${keys.join(", ")}`);
|
|
@@ -1967,9 +2391,11 @@ const COMMAND_FLAGS = {
|
|
|
1967
2391
|
"dry-run",
|
|
1968
2392
|
"watch"
|
|
1969
2393
|
],
|
|
1970
|
-
|
|
1971
|
-
|
|
2394
|
+
wrap: ["write"],
|
|
2395
|
+
status: ["json"],
|
|
2396
|
+
check: ["reporter", "drafts"],
|
|
1972
2397
|
translate: ["model", "dry-run"],
|
|
2398
|
+
review: ["approve", "locale"],
|
|
1973
2399
|
export: [
|
|
1974
2400
|
"format",
|
|
1975
2401
|
"out",
|
|
@@ -2008,7 +2434,7 @@ async function collectOrigins(cfg) {
|
|
|
2008
2434
|
async function resolveProvider(cfg, model) {
|
|
2009
2435
|
const configured = cfg.translate.provider;
|
|
2010
2436
|
if (typeof configured === "function") return configured;
|
|
2011
|
-
const { claudeProvider } = await import("./claude-
|
|
2437
|
+
const { claudeProvider } = await import("./claude-h9WA0ppg.js");
|
|
2012
2438
|
return claudeProvider({ model: model ?? cfg.translate.model });
|
|
2013
2439
|
}
|
|
2014
2440
|
//#endregion
|