@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 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 { basename, dirname, join, relative, resolve } from "node:path";
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
- const file = join(cfg.root, "verbaly.d.ts");
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 === "vite") next.push("install the plugin: pnpm add -D @verbaly/vite", "add verbaly() to the plugins in vite.config");
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
- console.log(formatStatusResult(status(cfg, loadCatalogs(cfg), registry)));
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
- console.error(`[verbaly] check failed\n${formatCheckResult(result)}`);
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
- status: [],
1954
- check: [],
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;