@verbaly/compiler 0.24.0 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/cli.js +353 -21
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +40 -3
- package/dist/index.js +310 -15
- package/dist/index.js.map +1 -1
- package/package.json +6 -4
package/README.md
CHANGED
|
@@ -50,11 +50,11 @@ Catalogs are **flat JSON**: most TMS platforms (Crowdin, Lokalise, Phrase, …)
|
|
|
50
50
|
|
|
51
51
|
```bash
|
|
52
52
|
npx verbaly export # verbaly-export/<locale>.xlf (XLIFF 2.0, source + target per unit)
|
|
53
|
-
npx verbaly export --format csv # spreadsheet-friendly: key,source,target
|
|
53
|
+
npx verbaly export --format csv # spreadsheet-friendly: key,source,target,location
|
|
54
54
|
npx verbaly import verbaly-export/es.xlf # fill the catalog back
|
|
55
55
|
```
|
|
56
56
|
|
|
57
|
-
`export` writes one file per target locale with the source text alongside the current translation (`--missing` exports only the untranslated entries). `import` reads XLIFF 2.0/1.2 or CSV back and **validates every entry like `translate` does**: a translation that drops a `{param}`, a variant block or an `<em>` tag is rejected and reported, so a translator's typo can't break your UI. Existing translations are kept unless `--overwrite`; `--dry-run` previews everything.
|
|
57
|
+
`export` writes one file per target locale with the source text alongside the current translation (`--missing` exports only the untranslated entries). Every entry carries **where the text lives in your source** (XLIFF `location` notes, a `location` column in CSV), so translators and TMS tools see the context instead of guessing it. `import` reads XLIFF 2.0/1.2 or CSV back and **validates every entry like `translate` does**: a translation that drops a `{param}`, a variant block or an `<em>` tag is rejected and reported, so a translator's typo can't break your UI. Existing translations are kept unless `--overwrite`; `--dry-run` previews everything.
|
|
58
58
|
|
|
59
59
|
## 📱 Mobile resources
|
|
60
60
|
|
package/dist/cli.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
2
3
|
import { parseArgs } from "node:util";
|
|
3
4
|
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, watch, writeFileSync } from "node:fs";
|
|
4
|
-
import {
|
|
5
|
-
import { RICH_TAGS, createVerbaly, normalizeLink, parse, parseTags, safeAttribute } from "verbaly";
|
|
5
|
+
import { RICH_TAGS, createVerbaly, localeDirection, normalizeLink, parse, parseTags, safeAttribute } from "verbaly";
|
|
6
6
|
import { pathToFileURL } from "node:url";
|
|
7
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
|
|
@@ -341,8 +394,9 @@ function resolveConfig(config = {}) {
|
|
|
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
|
};
|
|
@@ -427,6 +481,15 @@ var MessageRegistry = class {
|
|
|
427
481
|
}
|
|
428
482
|
return out;
|
|
429
483
|
}
|
|
484
|
+
origins() {
|
|
485
|
+
const out = this.usedKeys();
|
|
486
|
+
for (const analysis of this.files.values()) for (const msg of analysis.tagged) {
|
|
487
|
+
const files = out.get(msg.key) ?? [];
|
|
488
|
+
if (!files.includes(msg.file)) files.push(msg.file);
|
|
489
|
+
out.set(msg.key, files);
|
|
490
|
+
}
|
|
491
|
+
return out;
|
|
492
|
+
}
|
|
430
493
|
};
|
|
431
494
|
//#endregion
|
|
432
495
|
//#region src/key.ts
|
|
@@ -696,13 +759,14 @@ function isNode(value) {
|
|
|
696
759
|
}
|
|
697
760
|
//#endregion
|
|
698
761
|
//#region src/sfc.ts
|
|
699
|
-
const SFC_FILE_RE = /\.(?:svelte|vue)$/;
|
|
762
|
+
const SFC_FILE_RE = /\.(?:svelte|vue|astro)$/;
|
|
700
763
|
function analyzeFile(code, file) {
|
|
701
764
|
return SFC_FILE_RE.test(file) ? analyzeSfc(code, file) : analyze(code, file);
|
|
702
765
|
}
|
|
703
766
|
const SCRIPT_RE = /(<script\b[^>]*>)([\s\S]*?)(<\/script\s*>)/gi;
|
|
704
767
|
const STYLE_RE = /<style\b[^>]*>[\s\S]*?<\/style\s*>/gi;
|
|
705
768
|
const COMMENT_RE = /<!--[\s\S]*?-->/g;
|
|
769
|
+
const FRONTMATTER_RE = /^(\uFEFF?\s*---\r?\n)([\s\S]*?)\r?\n---(?=\r?\n|$)/;
|
|
706
770
|
const CANDIDATE_SVELTE = /(?<![\w$])\$?t(?=[`(]|\.id\()/g;
|
|
707
771
|
const CANDIDATE_VUE = /(?<![\w$])t(?=[`(]|\.id\()/g;
|
|
708
772
|
function analyzeSfc(code, file) {
|
|
@@ -710,12 +774,14 @@ function analyzeSfc(code, file) {
|
|
|
710
774
|
const options = svelte ? { tNames: ["t", "$t"] } : void 0;
|
|
711
775
|
const tagged = [];
|
|
712
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);
|
|
713
779
|
for (const match of code.matchAll(SCRIPT_RE)) {
|
|
714
780
|
const content = match[2];
|
|
715
781
|
if (!content) continue;
|
|
716
782
|
merge(analyzeSegment(content, file, options), match.index + match[1].length, false);
|
|
717
783
|
}
|
|
718
|
-
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);
|
|
719
785
|
const candidates = svelte ? CANDIDATE_SVELTE : CANDIDATE_VUE;
|
|
720
786
|
let consumedTo = 0;
|
|
721
787
|
for (const match of markup.matchAll(candidates)) {
|
|
@@ -845,6 +911,7 @@ function pruneCatalogs(cfg, catalogs, registry) {
|
|
|
845
911
|
//#endregion
|
|
846
912
|
//#region src/init.ts
|
|
847
913
|
const BUNDLERS = [
|
|
914
|
+
"astro",
|
|
848
915
|
"vite",
|
|
849
916
|
"webpack",
|
|
850
917
|
"rollup",
|
|
@@ -898,7 +965,8 @@ function init(options = {}) {
|
|
|
898
965
|
}
|
|
899
966
|
const bundler = detectBundler(root);
|
|
900
967
|
const next = [];
|
|
901
|
-
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");
|
|
902
970
|
else if (bundler) next.push("install the plugin: pnpm add -D @verbaly/unplugin", `add the verbaly ${bundler} plugin to your build config`);
|
|
903
971
|
else next.push("run \"verbaly extract\" after writing your first t`…` message");
|
|
904
972
|
return {
|
|
@@ -971,8 +1039,8 @@ async function doctor(cfg) {
|
|
|
971
1039
|
else if (wired) ok("plugin", `${deps["@verbaly/vite"] ? "@verbaly/vite" : "@verbaly/unplugin"} installed for ${bundler}`);
|
|
972
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");
|
|
973
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`);
|
|
974
|
-
if (source) {
|
|
975
|
-
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");
|
|
976
1044
|
if (!existsSync(dtsPath)) warn("types", "verbaly.d.ts has not been generated", "run `npx verbaly extract`");
|
|
977
1045
|
else if (readFileSync(dtsPath, "utf8") !== generateDts(source)) warn("types", "verbaly.d.ts is stale", "run `npx verbaly extract`");
|
|
978
1046
|
else ok("types", "verbaly.d.ts is up to date");
|
|
@@ -1128,7 +1196,8 @@ function exportCatalogs(cfg, catalogs, options = {}) {
|
|
|
1128
1196
|
const all = Object.keys(source).filter((key) => source[key]).sort().map((key) => ({
|
|
1129
1197
|
key,
|
|
1130
1198
|
source: source[key],
|
|
1131
|
-
target: catalog[key] ?? ""
|
|
1199
|
+
target: catalog[key] ?? "",
|
|
1200
|
+
location: options.origins?.[key]
|
|
1132
1201
|
}));
|
|
1133
1202
|
const untranslated = all.filter((entry) => !entry.target);
|
|
1134
1203
|
const entries = options.missing ? untranslated : all;
|
|
@@ -1224,8 +1293,13 @@ function parseExchangeFile(file, localeOverride) {
|
|
|
1224
1293
|
throw new Error(`[verbaly] ${file}: unsupported format, expected .xlf, .xliff or .csv.`);
|
|
1225
1294
|
}
|
|
1226
1295
|
function toXliff(sourceLocale, locale, entries) {
|
|
1227
|
-
const units = entries.map(({ key, source, target }) => [
|
|
1296
|
+
const units = entries.map(({ key, source, target, location }) => [
|
|
1228
1297
|
` <unit id="${escapeXml(key)}">`,
|
|
1298
|
+
...location?.length ? [
|
|
1299
|
+
" <notes>",
|
|
1300
|
+
...location.map((file) => ` <note category="location">${escapeXml(file)}</note>`),
|
|
1301
|
+
" </notes>"
|
|
1302
|
+
] : [],
|
|
1229
1303
|
` <segment state="${target ? "translated" : "initial"}">`,
|
|
1230
1304
|
` <source>${escapeXml(source)}</source>`,
|
|
1231
1305
|
` <target>${escapeXml(target)}</target>`,
|
|
@@ -1275,8 +1349,8 @@ function unescapeXml(text) {
|
|
|
1275
1349
|
}
|
|
1276
1350
|
function toCsv(entries) {
|
|
1277
1351
|
return [
|
|
1278
|
-
"key,source,target",
|
|
1279
|
-
...entries.map(({ key, source, target }) => `${csvField(key)},${csvField(source)},${csvField(target)}`),
|
|
1352
|
+
"key,source,target,location",
|
|
1353
|
+
...entries.map(({ key, source, target, location }) => `${csvField(key)},${csvField(source)},${csvField(target)},${csvField(location?.join("; ") ?? "")}`),
|
|
1280
1354
|
""
|
|
1281
1355
|
].join("\r\n");
|
|
1282
1356
|
}
|
|
@@ -1378,6 +1452,7 @@ function renderHtml(html, options) {
|
|
|
1378
1452
|
const chunkStart = m.index + 1 + rawName.length;
|
|
1379
1453
|
if (tagName === "html" && options.setLang !== false) {
|
|
1380
1454
|
setAttribute(ms, html, chunkStart, openEnd, attrChunk, "lang", options.locale);
|
|
1455
|
+
setAttribute(ms, html, chunkStart, openEnd, attrChunk, "dir", localeDirection(options.locale));
|
|
1381
1456
|
continue;
|
|
1382
1457
|
}
|
|
1383
1458
|
const attrs = parseAttrs(attrChunk);
|
|
@@ -1628,7 +1703,7 @@ function formatStatusResult(result) {
|
|
|
1628
1703
|
}
|
|
1629
1704
|
return lines.join("\n");
|
|
1630
1705
|
}
|
|
1631
|
-
const SOURCE_FILE_RE = /\.(?:[cm]?[jt]sx?|svelte|vue)$/;
|
|
1706
|
+
const SOURCE_FILE_RE = /\.(?:[cm]?[jt]sx?|svelte|vue|astro)$/;
|
|
1632
1707
|
//#endregion
|
|
1633
1708
|
//#region src/watch.ts
|
|
1634
1709
|
function watchProject(cfg, run, options = {}) {
|
|
@@ -1671,12 +1746,225 @@ function watchProject(cfg, run, options = {}) {
|
|
|
1671
1746
|
};
|
|
1672
1747
|
}
|
|
1673
1748
|
//#endregion
|
|
1749
|
+
//#region src/wrap.ts
|
|
1750
|
+
const WRAP_ATTRS = /* @__PURE__ */ new Set([
|
|
1751
|
+
"title",
|
|
1752
|
+
"alt",
|
|
1753
|
+
"placeholder",
|
|
1754
|
+
"aria-label"
|
|
1755
|
+
]);
|
|
1756
|
+
const T_NAMES = /* @__PURE__ */ new Set(["t"]);
|
|
1757
|
+
const LETTER = /\p{L}/u;
|
|
1758
|
+
async function wrapProject(cfg, options = {}) {
|
|
1759
|
+
const files = (await glob(cfg.include, {
|
|
1760
|
+
cwd: cfg.root,
|
|
1761
|
+
ignore: cfg.exclude,
|
|
1762
|
+
absolute: true
|
|
1763
|
+
})).filter((file) => /\.[jt]sx$/.test(file));
|
|
1764
|
+
const result = {
|
|
1765
|
+
files: files.length,
|
|
1766
|
+
changed: [],
|
|
1767
|
+
wrapped: [],
|
|
1768
|
+
skipped: []
|
|
1769
|
+
};
|
|
1770
|
+
for (const file of files) {
|
|
1771
|
+
const code = readFileSync(file, "utf8");
|
|
1772
|
+
const label = relative(cfg.root, file).replaceAll("\\", "/");
|
|
1773
|
+
const out = wrapCode(code, label);
|
|
1774
|
+
result.wrapped.push(...out.wrapped);
|
|
1775
|
+
result.skipped.push(...out.skipped);
|
|
1776
|
+
if (out.code !== void 0) {
|
|
1777
|
+
result.changed.push(label);
|
|
1778
|
+
if (options.write) writeFileSync(file, out.code);
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
return result;
|
|
1782
|
+
}
|
|
1783
|
+
function wrapCode(code, file) {
|
|
1784
|
+
const wrapped = [];
|
|
1785
|
+
const skipped = [];
|
|
1786
|
+
let ast;
|
|
1787
|
+
try {
|
|
1788
|
+
ast = parse$1(code, {
|
|
1789
|
+
sourceType: "module",
|
|
1790
|
+
errorRecovery: true,
|
|
1791
|
+
plugins: ["typescript", "jsx"]
|
|
1792
|
+
}).program;
|
|
1793
|
+
} catch {
|
|
1794
|
+
return {
|
|
1795
|
+
wrapped,
|
|
1796
|
+
skipped
|
|
1797
|
+
};
|
|
1798
|
+
}
|
|
1799
|
+
const s = new MagicString(code);
|
|
1800
|
+
const visited = /* @__PURE__ */ new Set();
|
|
1801
|
+
let changed = false;
|
|
1802
|
+
walk(ast, (node) => {
|
|
1803
|
+
if (node.type !== "JSXElement" && node.type !== "JSXFragment") return;
|
|
1804
|
+
if (visited.has(node)) return;
|
|
1805
|
+
processElement(node);
|
|
1806
|
+
});
|
|
1807
|
+
return changed ? {
|
|
1808
|
+
code: s.toString(),
|
|
1809
|
+
wrapped,
|
|
1810
|
+
skipped
|
|
1811
|
+
} : {
|
|
1812
|
+
wrapped,
|
|
1813
|
+
skipped
|
|
1814
|
+
};
|
|
1815
|
+
function processElement(node) {
|
|
1816
|
+
visited.add(node);
|
|
1817
|
+
if (node.type === "JSXElement") {
|
|
1818
|
+
const opening = node.openingElement;
|
|
1819
|
+
const name = opening.name;
|
|
1820
|
+
if (name.type === "JSXIdentifier" && name.name === "Trans") return markSubtree(node);
|
|
1821
|
+
if (hasVerbalyAttr(opening)) return markSubtree(node);
|
|
1822
|
+
wrapAttributes(opening);
|
|
1823
|
+
}
|
|
1824
|
+
const children = node.children ?? [];
|
|
1825
|
+
const hasElementChild = children.some((c) => c.type === "JSXElement" || c.type === "JSXFragment");
|
|
1826
|
+
const hasText = children.some((c) => c.type === "JSXText" && LETTER.test(c.value) || c.type === "JSXExpressionContainer" && c.expression.type === "StringLiteral" && LETTER.test(c.expression.value));
|
|
1827
|
+
if (hasElementChild && hasText) {
|
|
1828
|
+
skip(node, snippet(children), "mixed text and markup: wrap by hand or use <Trans>");
|
|
1829
|
+
return markSubtree(node);
|
|
1830
|
+
}
|
|
1831
|
+
if (hasText) {
|
|
1832
|
+
wrapSegment(node, children);
|
|
1833
|
+
return markSubtree(node);
|
|
1834
|
+
}
|
|
1835
|
+
for (const child of children) if (child.type === "JSXElement" || child.type === "JSXFragment") processElement(child);
|
|
1836
|
+
}
|
|
1837
|
+
function wrapSegment(parent, children) {
|
|
1838
|
+
const meaningful = children.filter((c) => c.type === "JSXText" && c.value.trim() !== "" || c.type === "JSXExpressionContainer" && c.expression.type !== "JSXEmptyExpression");
|
|
1839
|
+
if (meaningful.length === 0) return;
|
|
1840
|
+
const first = meaningful[0];
|
|
1841
|
+
const last = meaningful[meaningful.length - 1];
|
|
1842
|
+
let template = "";
|
|
1843
|
+
let report = "";
|
|
1844
|
+
for (let i = children.indexOf(first); i <= children.indexOf(last); i++) {
|
|
1845
|
+
const child = children[i];
|
|
1846
|
+
if (child.type === "JSXText") {
|
|
1847
|
+
let value = cleanJsxText(child.value);
|
|
1848
|
+
if (child === first) value = value.trimStart();
|
|
1849
|
+
if (child === last) value = value.trimEnd();
|
|
1850
|
+
template += escapeTemplate(value);
|
|
1851
|
+
report += value;
|
|
1852
|
+
} else if (child.type === "JSXExpressionContainer") {
|
|
1853
|
+
const expr = child.expression;
|
|
1854
|
+
if (expr.type === "JSXEmptyExpression") continue;
|
|
1855
|
+
if (usesT(expr)) {
|
|
1856
|
+
skip(parent, snippet(children), "already uses t: finish it by hand");
|
|
1857
|
+
return;
|
|
1858
|
+
}
|
|
1859
|
+
if (containsJsx(expr)) {
|
|
1860
|
+
skip(parent, snippet(children), "an expression renders markup: wrap by hand or use <Trans>");
|
|
1861
|
+
markSubtree(child);
|
|
1862
|
+
return;
|
|
1863
|
+
}
|
|
1864
|
+
if (expr.type === "StringLiteral") {
|
|
1865
|
+
template += escapeTemplate(expr.value);
|
|
1866
|
+
report += expr.value;
|
|
1867
|
+
} else {
|
|
1868
|
+
const source = code.slice(expr.start, expr.end);
|
|
1869
|
+
template += "${" + source + "}";
|
|
1870
|
+
report += "${" + source + "}";
|
|
1871
|
+
}
|
|
1872
|
+
} else return;
|
|
1873
|
+
}
|
|
1874
|
+
if (!LETTER.test(report)) return;
|
|
1875
|
+
const start = first.type === "JSXText" ? first.start + leadingWs(first.value) : first.start;
|
|
1876
|
+
const end = last.type === "JSXText" ? last.end - trailingWs(last.value) : last.end;
|
|
1877
|
+
s.overwrite(start, end, "{t`" + template + "`}");
|
|
1878
|
+
changed = true;
|
|
1879
|
+
wrapped.push({
|
|
1880
|
+
file,
|
|
1881
|
+
line: line(first),
|
|
1882
|
+
text: report,
|
|
1883
|
+
kind: "text"
|
|
1884
|
+
});
|
|
1885
|
+
}
|
|
1886
|
+
function wrapAttributes(opening) {
|
|
1887
|
+
for (const attr of opening.attributes) {
|
|
1888
|
+
if (attr.type !== "JSXAttribute") continue;
|
|
1889
|
+
const name = attr.name;
|
|
1890
|
+
if (name.type !== "JSXIdentifier" || !WRAP_ATTRS.has(name.name)) continue;
|
|
1891
|
+
const value = attr.value;
|
|
1892
|
+
if (value?.type !== "StringLiteral") continue;
|
|
1893
|
+
const raw = value.value;
|
|
1894
|
+
if (!LETTER.test(raw)) continue;
|
|
1895
|
+
s.overwrite(value.start, value.end, "{t`" + escapeTemplate(raw) + "`}");
|
|
1896
|
+
changed = true;
|
|
1897
|
+
wrapped.push({
|
|
1898
|
+
file,
|
|
1899
|
+
line: line(value),
|
|
1900
|
+
text: raw,
|
|
1901
|
+
kind: "attribute",
|
|
1902
|
+
attribute: name.name
|
|
1903
|
+
});
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
1906
|
+
function skip(node, text, reason) {
|
|
1907
|
+
skipped.push({
|
|
1908
|
+
file,
|
|
1909
|
+
line: line(node),
|
|
1910
|
+
text,
|
|
1911
|
+
reason
|
|
1912
|
+
});
|
|
1913
|
+
}
|
|
1914
|
+
function snippet(children) {
|
|
1915
|
+
const start = children[0]?.start ?? 0;
|
|
1916
|
+
const end = children[children.length - 1]?.end ?? start;
|
|
1917
|
+
const text = code.slice(start, end).replace(/\s+/g, " ").trim();
|
|
1918
|
+
return text.length > 60 ? text.slice(0, 59) + "…" : text;
|
|
1919
|
+
}
|
|
1920
|
+
function markSubtree(node) {
|
|
1921
|
+
walk(node, (n) => {
|
|
1922
|
+
if (n.type === "JSXElement" || n.type === "JSXFragment") visited.add(n);
|
|
1923
|
+
});
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
1926
|
+
function hasVerbalyAttr(opening) {
|
|
1927
|
+
return opening.attributes.some((attr) => {
|
|
1928
|
+
if (attr.type !== "JSXAttribute") return false;
|
|
1929
|
+
const name = attr.name;
|
|
1930
|
+
return name.type === "JSXIdentifier" && name.name.startsWith("data-verbaly");
|
|
1931
|
+
});
|
|
1932
|
+
}
|
|
1933
|
+
function usesT(node) {
|
|
1934
|
+
let found = false;
|
|
1935
|
+
walk(node, (n) => {
|
|
1936
|
+
if (n.type === "TaggedTemplateExpression" && isTReference(n.tag, T_NAMES)) found = true;
|
|
1937
|
+
if (n.type === "CallExpression" && isTReference(n.callee, T_NAMES)) found = true;
|
|
1938
|
+
});
|
|
1939
|
+
return found;
|
|
1940
|
+
}
|
|
1941
|
+
function containsJsx(node) {
|
|
1942
|
+
let found = false;
|
|
1943
|
+
walk(node, (n) => {
|
|
1944
|
+
if (n.type === "JSXElement" || n.type === "JSXFragment") found = true;
|
|
1945
|
+
});
|
|
1946
|
+
return found;
|
|
1947
|
+
}
|
|
1948
|
+
function escapeTemplate(text) {
|
|
1949
|
+
return text.replace(/[\\`]/g, "\\$&").replace(/\$\{/g, "\\${");
|
|
1950
|
+
}
|
|
1951
|
+
function leadingWs(text) {
|
|
1952
|
+
return text.length - text.trimStart().length;
|
|
1953
|
+
}
|
|
1954
|
+
function trailingWs(text) {
|
|
1955
|
+
return text.length - text.trimEnd().length;
|
|
1956
|
+
}
|
|
1957
|
+
function line(node) {
|
|
1958
|
+
return node.loc?.start?.line ?? 1;
|
|
1959
|
+
}
|
|
1960
|
+
//#endregion
|
|
1674
1961
|
//#region src/run.ts
|
|
1675
1962
|
const HELP = `verbaly · i18n compiler
|
|
1676
1963
|
|
|
1677
1964
|
Usage:
|
|
1678
1965
|
verbaly init scaffold config + locale catalogs (detects your bundler)
|
|
1679
1966
|
verbaly doctor diagnose the setup (config, catalogs, plugin, types, keys)
|
|
1967
|
+
verbaly wrap find hardcoded JSX text and wrap it in t\`…\` (report; --write applies)
|
|
1680
1968
|
verbaly extract scan sources, update catalogs and types
|
|
1681
1969
|
verbaly status translation coverage per locale, at a glance
|
|
1682
1970
|
verbaly check verify translations are complete (CI)
|
|
@@ -1693,6 +1981,9 @@ Options:
|
|
|
1693
1981
|
--locales <csv> extra locales; for translate: target locales to fill
|
|
1694
1982
|
--prune drop keys no longer referenced (extract)
|
|
1695
1983
|
--watch keep extracting as source files change (extract)
|
|
1984
|
+
--write apply the rewrites instead of only reporting (wrap)
|
|
1985
|
+
--json machine-readable output (status)
|
|
1986
|
+
--reporter <name> failure format: text (default) or github annotations (check)
|
|
1696
1987
|
--model <id> model override for the claude provider (translate)
|
|
1697
1988
|
--dry-run list what would happen, write nothing (translate, import, extract)
|
|
1698
1989
|
--format <f> export format: xliff (default), csv, android-xml or ios-strings (export)
|
|
@@ -1720,6 +2011,9 @@ async function runCli(args = process.argv.slice(2)) {
|
|
|
1720
2011
|
locales: { type: "string" },
|
|
1721
2012
|
prune: { type: "boolean" },
|
|
1722
2013
|
watch: { type: "boolean" },
|
|
2014
|
+
write: { type: "boolean" },
|
|
2015
|
+
json: { type: "boolean" },
|
|
2016
|
+
reporter: { type: "string" },
|
|
1723
2017
|
model: { type: "string" },
|
|
1724
2018
|
locale: { type: "string" },
|
|
1725
2019
|
site: { type: "string" },
|
|
@@ -1802,7 +2096,7 @@ async function runCli(args = process.argv.slice(2)) {
|
|
|
1802
2096
|
const { added } = syncCatalogs(cfg, catalogs, registry);
|
|
1803
2097
|
if (!dryRun) {
|
|
1804
2098
|
for (const locale of cfg.locales) writeCatalog(cfg, locale, catalogs[locale] ?? {});
|
|
1805
|
-
writeDts(cfg, catalogs[cfg.sourceLocale] ?? {});
|
|
2099
|
+
if (cfg.dts !== false) writeDts(cfg, catalogs[cfg.sourceLocale] ?? {}, cfg.dts);
|
|
1806
2100
|
}
|
|
1807
2101
|
const total = registry.messages().size;
|
|
1808
2102
|
console.log(`[verbaly] ${total} messages · locales: ${cfg.locales.join(", ")}${dryRun ? " (dry run, nothing written)" : ""}`);
|
|
@@ -1815,19 +2109,49 @@ async function runCli(args = process.argv.slice(2)) {
|
|
|
1815
2109
|
}
|
|
1816
2110
|
return;
|
|
1817
2111
|
}
|
|
2112
|
+
if (command === "wrap") {
|
|
2113
|
+
const result = await wrapProject(cfg, { write: values.write });
|
|
2114
|
+
if (result.wrapped.length === 0 && result.skipped.length === 0) {
|
|
2115
|
+
console.log(`[verbaly] nothing to wrap (${result.files} files scanned) ✓`);
|
|
2116
|
+
return;
|
|
2117
|
+
}
|
|
2118
|
+
const verb = values.write ? "wrapped" : "would wrap";
|
|
2119
|
+
const note = values.write ? "" : " (report only, use --write to apply)";
|
|
2120
|
+
console.log(`[verbaly] ${verb} ${result.wrapped.length} texts in ${result.changed.length} files${note}`);
|
|
2121
|
+
for (const entry of result.wrapped) {
|
|
2122
|
+
const attr = entry.kind === "attribute" ? `${entry.attribute} → ` : "";
|
|
2123
|
+
console.log(` ${entry.file}:${entry.line} ${attr}"${entry.text}"`);
|
|
2124
|
+
}
|
|
2125
|
+
if (result.skipped.length > 0) {
|
|
2126
|
+
console.log(" needs a human:");
|
|
2127
|
+
for (const entry of result.skipped) console.log(` ${entry.file}:${entry.line} "${entry.text}" (${entry.reason})`);
|
|
2128
|
+
}
|
|
2129
|
+
if (values.write && result.changed.length > 0) console.log(" next: make t available where TS complains (React: const t = useT()), then run verbaly extract");
|
|
2130
|
+
return;
|
|
2131
|
+
}
|
|
1818
2132
|
if (command === "status") {
|
|
1819
2133
|
const registry = await extractProject(cfg);
|
|
1820
|
-
|
|
2134
|
+
const result = status(cfg, loadCatalogs(cfg), registry);
|
|
2135
|
+
console.log(values.json ? JSON.stringify(result) : formatStatusResult(result));
|
|
1821
2136
|
return;
|
|
1822
2137
|
}
|
|
1823
2138
|
if (command === "check") {
|
|
2139
|
+
const reporter = values.reporter ?? "text";
|
|
2140
|
+
if (reporter !== "text" && reporter !== "github") {
|
|
2141
|
+
console.error(`[verbaly] unknown reporter "${values.reporter}", use text or github`);
|
|
2142
|
+
process.exitCode = 1;
|
|
2143
|
+
return;
|
|
2144
|
+
}
|
|
1824
2145
|
const registry = await extractProject(cfg);
|
|
1825
2146
|
const result = check(cfg, loadCatalogs(cfg), registry);
|
|
1826
2147
|
if (result.ok) {
|
|
1827
2148
|
console.log("[verbaly] all translations complete ✓");
|
|
1828
2149
|
return;
|
|
1829
2150
|
}
|
|
1830
|
-
|
|
2151
|
+
if (reporter === "github") {
|
|
2152
|
+
for (const line of githubCheckAnnotations(result, registry, cfg.root)) console.error(line);
|
|
2153
|
+
console.error(`[verbaly] check failed: ${result.missing.length} missing, ${result.unknown.length} unknown`);
|
|
2154
|
+
} else console.error(`[verbaly] check failed\n${formatCheckResult(result)}`);
|
|
1831
2155
|
process.exitCode = 1;
|
|
1832
2156
|
return;
|
|
1833
2157
|
}
|
|
@@ -1876,7 +2200,8 @@ async function runCli(args = process.argv.slice(2)) {
|
|
|
1876
2200
|
locales: values.locales?.split(","),
|
|
1877
2201
|
format,
|
|
1878
2202
|
out: values.out,
|
|
1879
|
-
missing: values.missing
|
|
2203
|
+
missing: values.missing,
|
|
2204
|
+
origins: isMobileFormat(format) ? void 0 : await collectOrigins(cfg)
|
|
1880
2205
|
});
|
|
1881
2206
|
if (result.files.length === 0) {
|
|
1882
2207
|
console.log("[verbaly] no target locales to export (add locales to your config)");
|
|
@@ -1950,8 +2275,9 @@ const COMMAND_FLAGS = {
|
|
|
1950
2275
|
"dry-run",
|
|
1951
2276
|
"watch"
|
|
1952
2277
|
],
|
|
1953
|
-
|
|
1954
|
-
|
|
2278
|
+
wrap: ["write"],
|
|
2279
|
+
status: ["json"],
|
|
2280
|
+
check: ["reporter"],
|
|
1955
2281
|
translate: ["model", "dry-run"],
|
|
1956
2282
|
export: [
|
|
1957
2283
|
"format",
|
|
@@ -1982,6 +2308,12 @@ function rejectStrayFlags(command, values) {
|
|
|
1982
2308
|
process.exitCode = 1;
|
|
1983
2309
|
return true;
|
|
1984
2310
|
}
|
|
2311
|
+
async function collectOrigins(cfg) {
|
|
2312
|
+
const registry = await extractProject(cfg);
|
|
2313
|
+
const origins = {};
|
|
2314
|
+
for (const [key, files] of registry.origins()) origins[key] = files.map((file) => relative(cfg.root, file).replaceAll("\\", "/"));
|
|
2315
|
+
return origins;
|
|
2316
|
+
}
|
|
1985
2317
|
async function resolveProvider(cfg, model) {
|
|
1986
2318
|
const configured = cfg.translate.provider;
|
|
1987
2319
|
if (typeof configured === "function") return configured;
|