@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/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
- 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
@@ -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 === "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");
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
- lines.push(` ${locale}: ${translated}/${total} translated (${pct}%)${mark}`);
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
- console.log(formatStatusResult(status(cfg, loadCatalogs(cfg), registry)));
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 result = check(cfg, loadCatalogs(cfg), registry);
1842
- if (result.ok) {
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
- console.error(`[verbaly] check failed\n${formatCheckResult(result)}`);
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
- console.log(` ${locale}: +${result.translated[locale].length} translated`);
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"]) writeCatalog(cfg, locale, catalogs[locale] ?? {});
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
- status: [],
1971
- check: [],
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-C-ETlqsW.js");
2437
+ const { claudeProvider } = await import("./claude-h9WA0ppg.js");
2012
2438
  return claudeProvider({ model: model ?? cfg.translate.model });
2013
2439
  }
2014
2440
  //#endregion