glotfile 0.4.2 → 0.4.4

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.
@@ -788,14 +788,14 @@ var init_state = __esm({
788
788
 
789
789
  // src/server/adapters/options.ts
790
790
  function applyCase(canonical, style) {
791
- const sep3 = style === "lower-underscore" || style === "bcp47-underscore" ? "_" : "-";
791
+ const sep4 = style === "lower-underscore" || style === "bcp47-underscore" ? "_" : "-";
792
792
  const lower = style === "lower-hyphen" || style === "lower-underscore";
793
793
  return canonical.split(/[-_]/).map((p, i) => {
794
794
  if (lower || i === 0) return p.toLowerCase();
795
795
  if (/^[a-z]{4}$/i.test(p)) return p[0].toUpperCase() + p.slice(1).toLowerCase();
796
796
  if (/^[a-z]{2}$/i.test(p)) return p.toUpperCase();
797
797
  return p;
798
- }).join(sep3);
798
+ }).join(sep4);
799
799
  }
800
800
  function resolveLocaleToken(output, canonical, adapterDefault) {
801
801
  const mapped = output.localeMap?.[canonical];
@@ -901,6 +901,10 @@ function toI18next(value) {
901
901
  if (isIcuPluralOrSelect(value)) return value;
902
902
  return value.replace(/(?<!\{)\{(\w+)\}(?!\})/g, "{{$1}}");
903
903
  }
904
+ function toRuby(value) {
905
+ if (isIcuPluralOrSelect(value)) return value;
906
+ return value.replace(/(?<!%)\{(\w+)\}/g, "%{$1}");
907
+ }
904
908
  function placeholdersMatch(source, translation) {
905
909
  const a = extractPlaceholders(source).sort();
906
910
  const b = extractPlaceholders(translation).sort();
@@ -1457,6 +1461,203 @@ var init_vue_i18n_json = __esm({
1457
1461
  }
1458
1462
  });
1459
1463
 
1464
+ // src/server/adapters/angular-xliff.ts
1465
+ function xmlEscape2(s) {
1466
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1467
+ }
1468
+ function renderInterpolations(text, ids) {
1469
+ let out = "";
1470
+ let last = 0;
1471
+ for (const m of text.matchAll(/\{(\w+)\}/g)) {
1472
+ const name = m[1];
1473
+ let id = ids.get(name);
1474
+ if (id === void 0) {
1475
+ id = ids.size === 0 ? "INTERPOLATION" : `INTERPOLATION_${ids.size}`;
1476
+ ids.set(name, id);
1477
+ }
1478
+ out += xmlEscape2(text.slice(last, m.index)) + `<x id="${id}" equiv-text="{{${name}}}"/>`;
1479
+ last = m.index + m[0].length;
1480
+ }
1481
+ return out + xmlEscape2(text.slice(last));
1482
+ }
1483
+ function renderPluralIcu(forms, ids) {
1484
+ const cats = [
1485
+ ...Object.keys(forms).filter((c) => c.startsWith("=")),
1486
+ ...PLURAL_CATEGORIES.filter((c) => forms[c] !== void 0)
1487
+ ];
1488
+ const branches = cats.map((cat) => `${cat} {${renderInterpolations(forms[cat] ?? "", ids)}}`);
1489
+ return `{VAR_PLURAL, plural, ${branches.join(" ")}}`;
1490
+ }
1491
+ function renderEmbeddedIcu(value) {
1492
+ const renamed = value.replace(
1493
+ /\{\s*\w+\s*,\s*(plural|select|selectordinal)\s*,/g,
1494
+ (_, type) => `{${type === "plural" ? "VAR_PLURAL" : "VAR_SELECT"}, ${type},`
1495
+ );
1496
+ return xmlEscape2(renamed);
1497
+ }
1498
+ function renderScalar(value, ids) {
1499
+ return isIcuPluralOrSelect(value) ? renderEmbeddedIcu(value) : renderInterpolations(value, ids);
1500
+ }
1501
+ var DEFAULT_LOCALE_CASE7, angularXliff;
1502
+ var init_angular_xliff = __esm({
1503
+ "src/server/adapters/angular-xliff.ts"() {
1504
+ "use strict";
1505
+ init_adapters();
1506
+ init_options();
1507
+ init_placeholders();
1508
+ init_schema();
1509
+ DEFAULT_LOCALE_CASE7 = "bcp47-hyphen";
1510
+ angularXliff = {
1511
+ name: "angular-xliff",
1512
+ capabilities: {
1513
+ plural: "native",
1514
+ select: "native",
1515
+ nesting: "flat",
1516
+ metadata: true,
1517
+ placeholderStyle: "icu",
1518
+ fileGrouping: "per-locale"
1519
+ },
1520
+ defaultLocaleCase: DEFAULT_LOCALE_CASE7,
1521
+ export(state, output) {
1522
+ const files = [];
1523
+ const warnings = [];
1524
+ warnings.push(...localeCollisionWarnings(output, state.config.locales, DEFAULT_LOCALE_CASE7));
1525
+ const sourceLocale = state.config.sourceLocale;
1526
+ const sourceToken = resolveLocaleToken(output, sourceLocale, DEFAULT_LOCALE_CASE7);
1527
+ const emptyAs = resolveEmptyAs(output, "source");
1528
+ const keys = Object.keys(state.keys).sort();
1529
+ for (const locale of state.config.locales) {
1530
+ const token = resolveLocaleToken(output, locale, DEFAULT_LOCALE_CASE7);
1531
+ const units = [];
1532
+ for (const key of keys) {
1533
+ const entry = state.keys[key];
1534
+ let source;
1535
+ let target;
1536
+ const ids = /* @__PURE__ */ new Map();
1537
+ if (entry.plural) {
1538
+ const targetForms = resolveForms(entry, locale, sourceLocale, emptyAs);
1539
+ if (targetForms === null) continue;
1540
+ source = renderPluralIcu(entry.values[sourceLocale]?.forms ?? {}, ids);
1541
+ target = renderPluralIcu(targetForms, ids);
1542
+ } else {
1543
+ const targetValue = resolveScalar(entry, locale, sourceLocale, emptyAs);
1544
+ if (targetValue === null) continue;
1545
+ source = renderScalar(entry.values[sourceLocale]?.value ?? "", ids);
1546
+ target = renderScalar(targetValue, ids);
1547
+ }
1548
+ units.push(` <trans-unit id="${xmlEscape2(key)}" datatype="html">`);
1549
+ units.push(` <source>${source}</source>`);
1550
+ units.push(` <target>${target}</target>`);
1551
+ if (entry.description) {
1552
+ units.push(` <note priority="1" from="description">${xmlEscape2(entry.description)}</note>`);
1553
+ }
1554
+ units.push(` </trans-unit>`);
1555
+ }
1556
+ const contents = `<?xml version="1.0" encoding="UTF-8" ?>
1557
+ <xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
1558
+ <file source-language="${xmlEscape2(sourceToken)}" target-language="${xmlEscape2(token)}" datatype="plaintext" original="ng2.template">
1559
+ <body>
1560
+ ` + (units.length ? units.join("\n") + "\n" : "") + ` </body>
1561
+ </file>
1562
+ </xliff>
1563
+ `;
1564
+ files.push({ path: resolvePath(output.path, token), contents });
1565
+ }
1566
+ return { files, warnings };
1567
+ }
1568
+ };
1569
+ }
1570
+ });
1571
+
1572
+ // src/server/adapters/rails-yaml.ts
1573
+ function yamlString(s) {
1574
+ return '"' + s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t") + '"';
1575
+ }
1576
+ function yamlKey(k) {
1577
+ if (RESERVED_KEYS.has(k.toLowerCase())) return yamlString(k);
1578
+ return /^[A-Za-z_][A-Za-z0-9_-]*$/.test(k) ? k : yamlString(k);
1579
+ }
1580
+ function yamlMap(node, indent, level) {
1581
+ const pad = " ".repeat(indent * level);
1582
+ const lines = [];
1583
+ for (const key of Object.keys(node).sort()) {
1584
+ const v = node[key];
1585
+ if (v && typeof v === "object") {
1586
+ lines.push(`${pad}${yamlKey(key)}:`);
1587
+ lines.push(...yamlMap(v, indent, level + 1));
1588
+ } else {
1589
+ lines.push(`${pad}${yamlKey(key)}: ${yamlString(String(v))}`);
1590
+ }
1591
+ }
1592
+ return lines;
1593
+ }
1594
+ var RESERVED_KEYS, DEFAULT_LOCALE_CASE8, railsYaml;
1595
+ var init_rails_yaml = __esm({
1596
+ "src/server/adapters/rails-yaml.ts"() {
1597
+ "use strict";
1598
+ init_adapters();
1599
+ init_shared();
1600
+ init_options();
1601
+ init_placeholders();
1602
+ init_schema();
1603
+ RESERVED_KEYS = /* @__PURE__ */ new Set(["true", "false", "yes", "no", "on", "off", "null", "y", "n"]);
1604
+ DEFAULT_LOCALE_CASE8 = "bcp47-hyphen";
1605
+ railsYaml = {
1606
+ name: "rails-yaml",
1607
+ capabilities: {
1608
+ plural: "native",
1609
+ select: "lossy",
1610
+ nesting: "nested",
1611
+ metadata: false,
1612
+ placeholderStyle: "named",
1613
+ fileGrouping: "per-locale"
1614
+ },
1615
+ defaultLocaleCase: DEFAULT_LOCALE_CASE8,
1616
+ export(state, output) {
1617
+ const warnings = [];
1618
+ warnings.push(...localeCollisionWarnings(output, state.config.locales, DEFAULT_LOCALE_CASE8));
1619
+ const { indent, finalNewline } = resolveFormat(state, output);
1620
+ const emptyAs = resolveEmptyAs(output, "omit");
1621
+ const files = [];
1622
+ for (const locale of state.config.locales) {
1623
+ const flat = {};
1624
+ for (const [key, entry] of Object.entries(state.keys)) {
1625
+ if (entry.plural) {
1626
+ const forms = resolveForms(entry, locale, state.config.sourceLocale, emptyAs);
1627
+ if (!forms) continue;
1628
+ for (const cat of PLURAL_CATEGORIES) {
1629
+ const body2 = forms[cat];
1630
+ if (body2 !== void 0) flat[`${key}.${cat}`] = toRuby(body2);
1631
+ }
1632
+ } else {
1633
+ const raw = resolveScalar(entry, locale, state.config.sourceLocale, emptyAs);
1634
+ if (raw === null) continue;
1635
+ if (raw && isIcuPluralOrSelect(raw)) {
1636
+ warnings.push({
1637
+ code: "lossy-plural",
1638
+ key,
1639
+ locale,
1640
+ message: "rails-yaml cannot represent ICU plural/select; written unconverted"
1641
+ });
1642
+ }
1643
+ flat[key] = toRuby(raw);
1644
+ }
1645
+ }
1646
+ const { tree: nested, collisions } = nestKeys(flat);
1647
+ for (const c of collisions) {
1648
+ warnings.push({ code: "key-collision", key: c, locale, message: "key is both a leaf and a parent; dropped from nested output" });
1649
+ }
1650
+ const token = resolveLocaleToken(output, locale, DEFAULT_LOCALE_CASE8);
1651
+ const body = [`${yamlKey(token)}:`, ...yamlMap(nested, indent, 1)].join("\n");
1652
+ files.push({ path: resolvePath(output.path, token), contents: finalNewline ? body + "\n" : body });
1653
+ }
1654
+ files.sort((a, b) => a.path.localeCompare(b.path));
1655
+ return { files, warnings };
1656
+ }
1657
+ };
1658
+ }
1659
+ });
1660
+
1460
1661
  // src/server/adapters/index.ts
1461
1662
  function resolvePath(template, locale, namespace = "") {
1462
1663
  return template.replaceAll("{locale}", locale).replaceAll("{namespace}", namespace);
@@ -1488,7 +1689,9 @@ function getRegistry() {
1488
1689
  [i18nextJson.name]: i18nextJson,
1489
1690
  [gettextPo.name]: gettextPo,
1490
1691
  [appleStringsdict.name]: appleStringsdict,
1491
- [vueI18nJson.name]: vueI18nJson
1692
+ [vueI18nJson.name]: vueI18nJson,
1693
+ [angularXliff.name]: angularXliff,
1694
+ [railsYaml.name]: railsYaml
1492
1695
  };
1493
1696
  }
1494
1697
  function getAdapter(name) {
@@ -1507,12 +1710,14 @@ var init_adapters = __esm({
1507
1710
  init_gettext_po();
1508
1711
  init_apple_stringsdict();
1509
1712
  init_vue_i18n_json();
1713
+ init_angular_xliff();
1714
+ init_rails_yaml();
1510
1715
  }
1511
1716
  });
1512
1717
 
1513
1718
  // src/server/export-run.ts
1514
- import { readFileSync as readFileSync3 } from "fs";
1515
- import { resolve } from "path";
1719
+ import { existsSync as existsSync3, readFileSync as readFileSync3, readdirSync as readdirSync2, rmdirSync, statSync, unlinkSync } from "fs";
1720
+ import { dirname as dirname2, resolve, sep } from "path";
1516
1721
  function effectiveLocales(config) {
1517
1722
  const limit = config.exportLocales;
1518
1723
  if (!limit || limit.length === 0) return config.locales;
@@ -1523,14 +1728,81 @@ function narrowForExport(state) {
1523
1728
  if (locales.length === state.config.locales.length) return state;
1524
1729
  return { ...state, config: { ...state.config, locales } };
1525
1730
  }
1731
+ function escapeRegExp(s) {
1732
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1733
+ }
1734
+ function segmentRegExp(segment) {
1735
+ const pattern = escapeRegExp(segment).replaceAll("\\{locale\\}", "(?<locale>[A-Za-z0-9_-]+)").replaceAll("\\{namespace\\}", "[^/]*");
1736
+ return new RegExp(`^${pattern}$`);
1737
+ }
1738
+ function removeEmptyDirs(dir, stopAt) {
1739
+ let current = dir;
1740
+ while (current !== stopAt && current.startsWith(stopAt + sep)) {
1741
+ try {
1742
+ rmdirSync(current);
1743
+ } catch {
1744
+ return;
1745
+ }
1746
+ current = dirname2(current);
1747
+ }
1748
+ }
1749
+ function pruneStaleLocaleFiles(output, validTokens, projectRoot) {
1750
+ const segments = output.path.split("/").filter(Boolean);
1751
+ if (!segments.some((s) => s.includes("{locale}"))) return 0;
1752
+ const root = resolve(projectRoot);
1753
+ let deleted = 0;
1754
+ const stale = (token) => token !== void 0 && !validTokens.has(token) && LOCALE_TOKEN.test(token);
1755
+ const visit = (dir, index, locale) => {
1756
+ const segment = segments[index];
1757
+ const isLast = index === segments.length - 1;
1758
+ if (!segment.includes("{locale}") && !segment.includes("{namespace}")) {
1759
+ const next = resolve(dir, segment);
1760
+ if (isLast) {
1761
+ if (stale(locale) && existsSync3(next) && statSync(next).isFile()) {
1762
+ unlinkSync(next);
1763
+ deleted++;
1764
+ removeEmptyDirs(dir, root);
1765
+ }
1766
+ return;
1767
+ }
1768
+ visit(next, index + 1, locale);
1769
+ return;
1770
+ }
1771
+ const re = segmentRegExp(segment);
1772
+ let entries;
1773
+ try {
1774
+ entries = readdirSync2(dir, { withFileTypes: true });
1775
+ } catch {
1776
+ return;
1777
+ }
1778
+ for (const entry of entries) {
1779
+ const m = entry.name.match(re);
1780
+ if (!m) continue;
1781
+ const token = m.groups?.locale ?? locale;
1782
+ if (isLast) {
1783
+ if (!entry.isFile() || !stale(token)) continue;
1784
+ unlinkSync(resolve(dir, entry.name));
1785
+ deleted++;
1786
+ removeEmptyDirs(dir, root);
1787
+ } else if (entry.isDirectory()) {
1788
+ visit(resolve(dir, entry.name), index + 1, token);
1789
+ }
1790
+ }
1791
+ };
1792
+ visit(root, 0, void 0);
1793
+ return deleted;
1794
+ }
1526
1795
  function exportToDisk(state, projectRoot, opts) {
1796
+ const allLocales = state.config.locales;
1527
1797
  state = narrowForExport(state);
1528
1798
  const outputs = opts?.adapter ? state.config.outputs.filter((o) => o.adapter === opts.adapter) : state.config.outputs;
1529
1799
  const warnings = [];
1530
1800
  let written = 0;
1531
1801
  let skipped = 0;
1802
+ let deleted = 0;
1532
1803
  for (const output of outputs) {
1533
- const result = getAdapter(output.adapter).export(state, output);
1804
+ const adapter = getAdapter(output.adapter);
1805
+ const result = adapter.export(state, output);
1534
1806
  warnings.push(...result.warnings);
1535
1807
  const writtenPaths = /* @__PURE__ */ new Set();
1536
1808
  for (const f of result.files) {
@@ -1552,14 +1824,19 @@ function exportToDisk(state, projectRoot, opts) {
1552
1824
  writeFileAtomic(abs, f.contents);
1553
1825
  written++;
1554
1826
  }
1827
+ const validTokens = new Set(allLocales.map((l) => resolveLocaleToken(output, l, adapter.defaultLocaleCase)));
1828
+ deleted += pruneStaleLocaleFiles(output, validTokens, projectRoot);
1555
1829
  }
1556
- return { written, skipped, warnings };
1830
+ return { written, skipped, deleted, warnings };
1557
1831
  }
1832
+ var LOCALE_TOKEN;
1558
1833
  var init_export_run = __esm({
1559
1834
  "src/server/export-run.ts"() {
1560
1835
  "use strict";
1561
1836
  init_adapters();
1837
+ init_options();
1562
1838
  init_atomic_write();
1839
+ LOCALE_TOKEN = /^[a-z]{2,3}([_-][a-z0-9]+)*$/i;
1563
1840
  }
1564
1841
  });
1565
1842
 
@@ -2232,13 +2509,13 @@ var init_ai = __esm({
2232
2509
  });
2233
2510
 
2234
2511
  // src/server/glotfile-dir.ts
2235
- import { existsSync as existsSync3, mkdirSync as mkdirSync3, writeFileSync as writeFileSync2 } from "fs";
2512
+ import { existsSync as existsSync4, mkdirSync as mkdirSync3, writeFileSync as writeFileSync2 } from "fs";
2236
2513
  import { resolve as resolve2 } from "path";
2237
2514
  function ensureGlotfileDir(projectRoot) {
2238
2515
  const dir = resolve2(projectRoot, ".glotfile");
2239
2516
  mkdirSync3(dir, { recursive: true });
2240
2517
  const ignore = resolve2(dir, ".gitignore");
2241
- if (!existsSync3(ignore)) {
2518
+ if (!existsSync4(ignore)) {
2242
2519
  try {
2243
2520
  writeFileSync2(ignore, "*\n");
2244
2521
  } catch {
@@ -2275,7 +2552,9 @@ function coerceAi(raw) {
2275
2552
  contextBatchSize: typeof a.contextBatchSize === "number" && a.contextBatchSize > 0 ? a.contextBatchSize : void 0,
2276
2553
  contextConcurrency: typeof a.contextConcurrency === "number" && a.contextConcurrency > 0 ? a.contextConcurrency : void 0,
2277
2554
  vision: typeof a.vision === "boolean" ? a.vision : void 0,
2278
- promptStyle: PROMPT_STYLES.includes(a.promptStyle) ? a.promptStyle : void 0
2555
+ promptStyle: PROMPT_STYLES.includes(a.promptStyle) ? a.promptStyle : void 0,
2556
+ inputPricePerMTok: typeof a.inputPricePerMTok === "number" && a.inputPricePerMTok >= 0 ? a.inputPricePerMTok : void 0,
2557
+ outputPricePerMTok: typeof a.outputPricePerMTok === "number" && a.outputPricePerMTok >= 0 ? a.outputPricePerMTok : void 0
2279
2558
  };
2280
2559
  }
2281
2560
  function coerceProfiles(raw) {
@@ -2314,6 +2593,10 @@ function aiConfigError(ai) {
2314
2593
  if (!(a.endpoint === null || a.endpoint === void 0 || typeof a.endpoint === "string")) return "ai.endpoint must be a string or null";
2315
2594
  if (!(a.region === void 0 || a.region === null || typeof a.region === "string")) return "ai.region must be a string or null";
2316
2595
  if (typeof a.batchSize !== "number") return "ai.batchSize must be a number";
2596
+ for (const f of ["inputPricePerMTok", "outputPricePerMTok"]) {
2597
+ const v = a[f];
2598
+ if (!(v === void 0 || v === null || typeof v === "number" && v >= 0)) return `ai.${f} must be a non-negative number`;
2599
+ }
2317
2600
  return null;
2318
2601
  }
2319
2602
  var EDITOR_IDS, isEditorId, DEFAULT_AI, DEFAULT_EDITOR, settingsPath;
@@ -2349,7 +2632,7 @@ var init_glob = __esm({
2349
2632
  });
2350
2633
 
2351
2634
  // src/server/ai/run.ts
2352
- import { readFileSync as readFileSync5, existsSync as existsSync4 } from "fs";
2635
+ import { readFileSync as readFileSync5, existsSync as existsSync5 } from "fs";
2353
2636
  import { resolve as resolve4, extname } from "path";
2354
2637
  function selectRequests(state, opts) {
2355
2638
  const targets = (opts.locales ?? state.config.locales).filter((l) => l !== state.config.sourceLocale);
@@ -2432,7 +2715,7 @@ function attachScreenshots(reqs, state, projectRoot) {
2432
2715
  if (!mediaType) continue;
2433
2716
  if (!cache2.has(screenshot)) {
2434
2717
  const abs = resolve4(projectRoot, screenshot);
2435
- if (!existsSync4(abs)) {
2718
+ if (!existsSync5(abs)) {
2436
2719
  cache2.set(screenshot, null);
2437
2720
  } else {
2438
2721
  const buf = readFileSync5(abs);
@@ -2529,8 +2812,120 @@ var init_run = __esm({
2529
2812
  }
2530
2813
  });
2531
2814
 
2815
+ // src/server/ai/pricing.ts
2816
+ function bareModelId(model) {
2817
+ let id = model.trim().toLowerCase();
2818
+ const slash = id.lastIndexOf("/");
2819
+ if (slash !== -1) id = id.slice(slash + 1);
2820
+ const anth = id.lastIndexOf("anthropic.");
2821
+ if (anth !== -1) id = id.slice(anth + "anthropic.".length);
2822
+ return id;
2823
+ }
2824
+ function resolvePricing(ai) {
2825
+ if (ai.inputPricePerMTok !== void 0 && ai.outputPricePerMTok !== void 0) {
2826
+ return { source: "profile", inputPerMTok: ai.inputPricePerMTok, outputPerMTok: ai.outputPricePerMTok };
2827
+ }
2828
+ if (FREE_PROVIDERS.has(ai.provider)) return { source: "builtin", inputPerMTok: 0, outputPerMTok: 0 };
2829
+ const id = bareModelId(ai.model);
2830
+ let best;
2831
+ for (const row of PRICE_TABLE) {
2832
+ if (id.startsWith(row[0]) && (!best || row[0].length > best[0].length)) best = row;
2833
+ }
2834
+ return best ? { source: "builtin", inputPerMTok: best[1], outputPerMTok: best[2] } : null;
2835
+ }
2836
+ var PRICE_TABLE, FREE_PROVIDERS;
2837
+ var init_pricing = __esm({
2838
+ "src/server/ai/pricing.ts"() {
2839
+ "use strict";
2840
+ PRICE_TABLE = [
2841
+ ["claude-fable-5", 10, 50],
2842
+ ["claude-mythos-5", 10, 50],
2843
+ // Deprecated Opus 4.1 / 4.0 cost 3x the 4.5+ generation — they must outrank
2844
+ // the shorter "claude-opus-4" prefix (4-2025 covers the dated Opus 4 full IDs).
2845
+ ["claude-opus-4-1", 15, 75],
2846
+ ["claude-opus-4-0", 15, 75],
2847
+ ["claude-opus-4-2025", 15, 75],
2848
+ ["claude-opus-4", 5, 25],
2849
+ ["claude-sonnet-4", 3, 15],
2850
+ ["claude-haiku-4", 1, 5],
2851
+ ["claude-3-5-haiku", 0.8, 4],
2852
+ ["gpt-5.5-pro", 30, 180],
2853
+ ["gpt-5.5", 5, 30],
2854
+ ["gpt-5.4-pro", 30, 180],
2855
+ ["gpt-5.4-mini", 0.75, 4.5],
2856
+ ["gpt-5.4-nano", 0.2, 1.25],
2857
+ ["gpt-5.4", 2.5, 15],
2858
+ ["gpt-5.3-codex", 1.75, 14]
2859
+ ];
2860
+ FREE_PROVIDERS = /* @__PURE__ */ new Set(["ollama", "claude-code"]);
2861
+ }
2862
+ });
2863
+
2864
+ // src/server/ai/estimate.ts
2865
+ function estimateTokens(text) {
2866
+ const cjk = text.match(CJK_RE)?.length ?? 0;
2867
+ return Math.ceil((text.length - cjk) / 4 + cjk / 2);
2868
+ }
2869
+ function estimateOutputTokens(req) {
2870
+ const translated = Math.ceil(estimateTokens(req.source) * EXPANSION);
2871
+ if (req.plural) {
2872
+ return ITEM_REPLY_OVERHEAD + req.plural.categories.length * (translated + FORM_REPLY_OVERHEAD);
2873
+ }
2874
+ return ITEM_REPLY_OVERHEAD + translated;
2875
+ }
2876
+ function estimateTranslation(state, ai, opts) {
2877
+ const reqs = selectRequests(state, opts);
2878
+ const byLocale = /* @__PURE__ */ new Map();
2879
+ for (const r of reqs) {
2880
+ let group = byLocale.get(r.targetLocale);
2881
+ if (!group) {
2882
+ group = [];
2883
+ byLocale.set(r.targetLocale, group);
2884
+ }
2885
+ group.push(r);
2886
+ }
2887
+ const perLocale = [];
2888
+ for (const [locale, group] of byLocale) {
2889
+ let inputTokens2 = 0;
2890
+ let outputTokens2 = 0;
2891
+ const batches = chunk(group, Math.max(1, ai.batchSize));
2892
+ for (const batch of batches) {
2893
+ const system = buildSystemPrompt(batch.some((r) => r.plural !== void 0));
2894
+ inputTokens2 += estimateTokens(system) + estimateTokens(buildBatchPrompt(batch));
2895
+ for (const r of batch) outputTokens2 += estimateOutputTokens(r);
2896
+ }
2897
+ perLocale.push({ locale, requests: group.length, batches: batches.length, inputTokens: inputTokens2, outputTokens: outputTokens2 });
2898
+ }
2899
+ const inputTokens = perLocale.reduce((n, l) => n + l.inputTokens, 0);
2900
+ const outputTokens = perLocale.reduce((n, l) => n + l.outputTokens, 0);
2901
+ const pricing = resolvePricing(ai);
2902
+ return {
2903
+ requests: reqs.length,
2904
+ batches: perLocale.reduce((n, l) => n + l.batches, 0),
2905
+ perLocale,
2906
+ inputTokens,
2907
+ outputTokens,
2908
+ pricing,
2909
+ estimatedCost: pricing ? (inputTokens * pricing.inputPerMTok + outputTokens * pricing.outputPerMTok) / 1e6 : null
2910
+ };
2911
+ }
2912
+ var CJK_RE, EXPANSION, ITEM_REPLY_OVERHEAD, FORM_REPLY_OVERHEAD;
2913
+ var init_estimate = __esm({
2914
+ "src/server/ai/estimate.ts"() {
2915
+ "use strict";
2916
+ init_run();
2917
+ init_provider();
2918
+ init_batch();
2919
+ init_pricing();
2920
+ CJK_RE = /[ -鿿가-힯豈-﫿]/g;
2921
+ EXPANSION = 1.2;
2922
+ ITEM_REPLY_OVERHEAD = 16;
2923
+ FORM_REPLY_OVERHEAD = 8;
2924
+ }
2925
+ });
2926
+
2532
2927
  // src/server/log.ts
2533
- import { appendFileSync, readFileSync as readFileSync6, existsSync as existsSync5 } from "fs";
2928
+ import { appendFileSync, readFileSync as readFileSync6, existsSync as existsSync6 } from "fs";
2534
2929
  import { resolve as resolve5 } from "path";
2535
2930
  function logPath(projectRoot) {
2536
2931
  return resolve5(projectRoot, ".glotfile", "log.jsonl");
@@ -2541,7 +2936,7 @@ function appendLog(projectRoot, entry) {
2541
2936
  }
2542
2937
  function readLog(projectRoot, limit = 100) {
2543
2938
  const path = logPath(projectRoot);
2544
- if (!existsSync5(path)) return [];
2939
+ if (!existsSync6(path)) return [];
2545
2940
  const lines = readFileSync6(path, "utf8").split("\n").filter((l) => l.trim() !== "");
2546
2941
  const entries = lines.map((l) => JSON.parse(l));
2547
2942
  return entries.reverse().slice(0, limit);
@@ -2554,11 +2949,11 @@ var init_log = __esm({
2554
2949
  });
2555
2950
 
2556
2951
  // src/server/scan.ts
2557
- import { existsSync as existsSync6, readFileSync as readFileSync7 } from "fs";
2952
+ import { existsSync as existsSync7, readFileSync as readFileSync7 } from "fs";
2558
2953
  import { resolve as resolve6 } from "path";
2559
2954
  function loadUsageCache(projectRoot) {
2560
2955
  const path = resolve6(projectRoot, ".glotfile", "usage.json");
2561
- if (!existsSync6(path)) return null;
2956
+ if (!existsSync7(path)) return null;
2562
2957
  try {
2563
2958
  return JSON.parse(readFileSync7(path, "utf8"));
2564
2959
  } catch {
@@ -2574,8 +2969,10 @@ function findMissing(state) {
2574
2969
  const targets = state.config.locales.filter((l) => l !== state.config.sourceLocale).sort();
2575
2970
  const out = [];
2576
2971
  for (const key of Object.keys(state.keys).sort()) {
2972
+ const entry = state.keys[key];
2973
+ if (entry.skipTranslate) continue;
2577
2974
  for (const locale of targets) {
2578
- const v = state.keys[key].values[locale]?.value;
2975
+ const v = entry.plural ? entry.values[locale]?.forms?.other?.trim() : entry.values[locale]?.value;
2579
2976
  if (!v) out.push({ key, locale });
2580
2977
  }
2581
2978
  }
@@ -2601,7 +2998,7 @@ var init_scan = __esm({
2601
2998
  });
2602
2999
 
2603
3000
  // src/server/scanner.ts
2604
- import { readdirSync as readdirSync2, statSync, readFileSync as readFileSync8 } from "fs";
3001
+ import { readdirSync as readdirSync3, statSync as statSync2, readFileSync as readFileSync8 } from "fs";
2605
3002
  import { join as join3, extname as extname2, relative } from "path";
2606
3003
  function scannerForExt(ext) {
2607
3004
  return EXT_SCANNER[ext] ?? null;
@@ -2722,7 +3119,7 @@ function isIncluded(relPath, includePatterns) {
2722
3119
  function* walkFiles(dir, root, exclude) {
2723
3120
  let entries;
2724
3121
  try {
2725
- entries = readdirSync2(dir);
3122
+ entries = readdirSync3(dir);
2726
3123
  } catch {
2727
3124
  return;
2728
3125
  }
@@ -2732,7 +3129,7 @@ function* walkFiles(dir, root, exclude) {
2732
3129
  const rel = relative(root, abs);
2733
3130
  let st;
2734
3131
  try {
2735
- st = statSync(abs);
3132
+ st = statSync2(abs);
2736
3133
  } catch {
2737
3134
  continue;
2738
3135
  }
@@ -2761,7 +3158,7 @@ function runScan(projectRoot, opts, existing) {
2761
3158
  const abs = join3(projectRoot, relPath);
2762
3159
  let st;
2763
3160
  try {
2764
- st = statSync(abs);
3161
+ st = statSync2(abs);
2765
3162
  } catch {
2766
3163
  continue;
2767
3164
  }
@@ -2879,7 +3276,7 @@ var init_scanner = __esm({
2879
3276
  });
2880
3277
 
2881
3278
  // src/server/ai/context.ts
2882
- import { existsSync as existsSync7, readFileSync as readFileSync9 } from "fs";
3279
+ import { existsSync as existsSync8, readFileSync as readFileSync9 } from "fs";
2883
3280
  import { resolve as resolve7 } from "path";
2884
3281
  function globToRegExp2(glob) {
2885
3282
  const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
@@ -2894,7 +3291,7 @@ function extractSnippets(refs, projectRoot, fileCache) {
2894
3291
  for (const ref of selected) {
2895
3292
  const absPath = resolve7(projectRoot, ref.file);
2896
3293
  if (!fileCache.has(ref.file)) {
2897
- if (!existsSync7(absPath)) continue;
3294
+ if (!existsSync8(absPath)) continue;
2898
3295
  const content = readFileSync9(absPath, "utf8");
2899
3296
  fileCache.set(ref.file, content.split("\n"));
2900
3297
  }
@@ -3039,103 +3436,446 @@ var init_context = __esm({
3039
3436
  }
3040
3437
  });
3041
3438
 
3042
- // src/server/import/detect.ts
3043
- import { existsSync as existsSync9, readdirSync as readdirSync3, statSync as statSync2 } from "fs";
3044
- import { join as join4 } from "path";
3045
- function safeIsDir(p) {
3046
- try {
3047
- return statSync2(p).isDirectory();
3048
- } catch {
3049
- return false;
3050
- }
3051
- }
3052
- function listDirs(dir) {
3053
- return readdirSync3(dir).filter((e) => safeIsDir(join4(dir, e)));
3054
- }
3055
- function fileCount(dir) {
3056
- try {
3057
- return readdirSync3(dir).length;
3058
- } catch {
3059
- return 0;
3060
- }
3061
- }
3062
- function pickSource(locales, sizeOf) {
3063
- if (locales.includes("en")) return "en";
3064
- return [...locales].sort((a, b) => sizeOf(b) - sizeOf(a) || a.localeCompare(b))[0] ?? "en";
3065
- }
3066
- function detectLaravel(root) {
3067
- const localeRoot = [join4(root, "resources", "lang"), join4(root, "lang")].find(safeIsDir);
3068
- if (!localeRoot) return null;
3069
- const locales = listDirs(localeRoot).filter((d) => LOCALE_RE.test(d));
3070
- if (locales.length === 0) return null;
3071
- const sourceLocale = pickSource(locales, (loc) => fileCount(join4(localeRoot, loc)));
3072
- return { format: "laravel-php", localeRoot, locales, sourceLocale };
3073
- }
3074
- function detectVue(root) {
3075
- for (const rel of VUE_DIR_CANDIDATES) {
3076
- const localeRoot = join4(root, rel);
3077
- if (!safeIsDir(localeRoot)) continue;
3078
- const locales = readdirSync3(localeRoot).filter((f) => f.endsWith(".json")).map((f) => f.slice(0, -5)).filter((l) => LOCALE_RE.test(l));
3079
- if (locales.length >= 2) {
3080
- const sourceLocale = pickSource(locales, (loc) => {
3081
- try {
3082
- return statSync2(join4(localeRoot, `${loc}.json`)).size;
3083
- } catch {
3084
- return 0;
3085
- }
3086
- });
3087
- return { format: "vue-i18n-json", localeRoot, locales, sourceLocale };
3088
- }
3089
- }
3090
- return null;
3091
- }
3092
- function detectArb(root) {
3093
- for (const rel of ["lib/l10n", "l10n", "lib/src/l10n"]) {
3094
- const localeRoot = join4(root, rel);
3095
- if (!safeIsDir(localeRoot)) continue;
3096
- const locales = readdirSync3(localeRoot).map((f) => f.match(/^(?:app_)?(.+)\.arb$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l));
3097
- if (locales.length >= 1) {
3098
- return { format: "flutter-arb", localeRoot, locales, sourceLocale: pickSource(locales, () => 0) };
3099
- }
3100
- }
3101
- return null;
3439
+ // src/server/lint/spelling.ts
3440
+ function tokenize(text) {
3441
+ return text.match(new RegExp("\\p{L}[\\p{L}'']*", "gu")) ?? [];
3102
3442
  }
3103
- function detect(root, formatOverride) {
3104
- if (!existsSync9(root)) return null;
3105
- if (formatOverride) {
3106
- const fn = BY_FORMAT[formatOverride];
3107
- if (!fn) throw new Error(`Unknown format: ${formatOverride}`);
3108
- return fn(root);
3109
- }
3110
- for (const fn of DETECTORS) {
3111
- const d = fn(root);
3112
- if (d) return d;
3113
- }
3114
- return null;
3443
+ function buildAllowWords(glossary, dictionary2 = []) {
3444
+ const set = /* @__PURE__ */ new Set();
3445
+ const add = (s) => {
3446
+ for (const w of tokenize(s)) set.add(w.toLowerCase());
3447
+ };
3448
+ for (const g of glossary) add(g.term);
3449
+ for (const w of dictionary2) add(w);
3450
+ return set;
3115
3451
  }
3116
- var LOCALE_RE, VUE_DIR_CANDIDATES, DETECTORS, BY_FORMAT;
3117
- var init_detect = __esm({
3118
- "src/server/import/detect.ts"() {
3452
+ var spellingRule, defaultLoader;
3453
+ var init_spelling = __esm({
3454
+ "src/server/lint/spelling.ts"() {
3119
3455
  "use strict";
3120
- LOCALE_RE = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
3121
- VUE_DIR_CANDIDATES = ["src/locale", "src/locales", "src/i18n/locales", "locales", "lang"];
3122
- DETECTORS = [detectLaravel, detectVue, detectArb];
3123
- BY_FORMAT = {
3124
- "laravel-php": detectLaravel,
3125
- "vue-i18n-json": detectVue,
3126
- "flutter-arb": detectArb
3456
+ init_placeholders();
3457
+ spellingRule = {
3458
+ id: "spelling",
3459
+ run(state, ctx) {
3460
+ const out = [];
3461
+ for (const key of Object.keys(state.keys)) {
3462
+ const entry = state.keys[key];
3463
+ for (const locale of ctx.targetLocales) {
3464
+ const speller = ctx.spellers.get(locale);
3465
+ if (!speller) continue;
3466
+ const value = entry.values[locale]?.value;
3467
+ if (!value) continue;
3468
+ const placeholders = new Set(extractPlaceholders(value).map((p) => p.toLowerCase()));
3469
+ for (const word of tokenize(value)) {
3470
+ const lower = word.toLowerCase();
3471
+ if (placeholders.has(lower) || ctx.allowWords.has(lower)) continue;
3472
+ if (!speller.correct(word)) {
3473
+ out.push({ ruleId: "spelling", key, locale, message: `possible misspelling: "${word}"` });
3474
+ }
3475
+ }
3476
+ }
3477
+ }
3478
+ return out;
3479
+ }
3480
+ };
3481
+ defaultLoader = async (dictId) => {
3482
+ try {
3483
+ const nspellMod = await import("nspell");
3484
+ const nspell2 = nspellMod.default ?? nspellMod;
3485
+ const dictMod = await import(`dictionary-${dictId.toLowerCase()}`);
3486
+ const dictExport = dictMod.default ?? dictMod;
3487
+ const dict = typeof dictExport === "function" ? await dictExport() : dictExport;
3488
+ return nspell2(dict);
3489
+ } catch {
3490
+ return null;
3491
+ }
3127
3492
  };
3128
3493
  }
3129
3494
  });
3130
3495
 
3131
- // src/server/import/flatten.ts
3132
- function flattenObject(value, prefix, warnings) {
3133
- const out = {};
3134
- const walk = (node, path) => {
3135
- if (typeof node === "string") {
3136
- out[path] = node;
3137
- } else if (typeof node === "number" || typeof node === "boolean") {
3138
- out[path] = String(node);
3496
+ // src/server/lint/rules.ts
3497
+ var emptySourceRule, emptyTranslationRule, identicalToSourceRule, whitespaceRule, placeholderMismatchRule, icuMismatchRule, maxLengthRule, glossaryViolationRule, ALL_RULES;
3498
+ var init_rules = __esm({
3499
+ "src/server/lint/rules.ts"() {
3500
+ "use strict";
3501
+ init_scan();
3502
+ init_placeholders();
3503
+ init_run();
3504
+ init_spelling();
3505
+ emptySourceRule = {
3506
+ id: "empty-source",
3507
+ run(state, ctx) {
3508
+ const out = [];
3509
+ for (const key of Object.keys(state.keys)) {
3510
+ const entry = state.keys[key];
3511
+ const v = entry.plural ? entry.values[ctx.sourceLocale]?.forms?.other : entry.values[ctx.sourceLocale]?.value;
3512
+ if (!v || !v.trim()) out.push({ ruleId: "empty-source", key, locale: "", message: "source value is empty" });
3513
+ }
3514
+ return out;
3515
+ }
3516
+ };
3517
+ emptyTranslationRule = {
3518
+ id: "empty-translation",
3519
+ run(state, ctx) {
3520
+ const out = [];
3521
+ for (const m of findMissing(state)) {
3522
+ out.push({ ruleId: "empty-translation", key: m.key, locale: m.locale, message: "translation is empty or missing" });
3523
+ }
3524
+ for (const key of Object.keys(state.keys)) {
3525
+ for (const locale of ctx.targetLocales) {
3526
+ const v = state.keys[key].values[locale]?.value;
3527
+ if (v && !v.trim()) out.push({ ruleId: "empty-translation", key, locale, message: "translation is whitespace-only" });
3528
+ }
3529
+ }
3530
+ return out;
3531
+ }
3532
+ };
3533
+ identicalToSourceRule = {
3534
+ id: "identical-to-source",
3535
+ run(state, ctx) {
3536
+ const out = [];
3537
+ for (const key of Object.keys(state.keys)) {
3538
+ const entry = state.keys[key];
3539
+ if (entry.skipTranslate) continue;
3540
+ const src = entry.values[ctx.sourceLocale]?.value;
3541
+ if (!src) continue;
3542
+ for (const locale of ctx.targetLocales) {
3543
+ const v = entry.values[locale]?.value;
3544
+ if (v && v === src) out.push({ ruleId: "identical-to-source", key, locale, message: "translation is identical to the source" });
3545
+ }
3546
+ }
3547
+ return out;
3548
+ }
3549
+ };
3550
+ whitespaceRule = {
3551
+ id: "whitespace",
3552
+ run(state, ctx) {
3553
+ const out = [];
3554
+ for (const key of Object.keys(state.keys)) {
3555
+ const entry = state.keys[key];
3556
+ const src = entry.values[ctx.sourceLocale]?.value ?? "";
3557
+ const srcEdge = src !== src.trim();
3558
+ for (const locale of ctx.targetLocales) {
3559
+ const v = entry.values[locale]?.value;
3560
+ if (!v) continue;
3561
+ if (v !== v.trim() !== srcEdge) {
3562
+ out.push({ ruleId: "whitespace", key, locale, message: "leading/trailing whitespace differs from the source" });
3563
+ }
3564
+ }
3565
+ }
3566
+ return out;
3567
+ }
3568
+ };
3569
+ placeholderMismatchRule = {
3570
+ id: "placeholder-mismatch",
3571
+ run(state, ctx) {
3572
+ const out = [];
3573
+ for (const key of Object.keys(state.keys)) {
3574
+ const entry = state.keys[key];
3575
+ if (entry.plural) {
3576
+ const srcForm = entry.values[ctx.sourceLocale]?.forms?.other;
3577
+ if (!srcForm) continue;
3578
+ for (const locale of ctx.targetLocales) {
3579
+ const forms = entry.values[locale]?.forms;
3580
+ if (!forms) continue;
3581
+ const bad = Object.entries(forms).some(
3582
+ ([cat, form]) => form && !pluralFormPlaceholdersMatch(cat, srcForm, form)
3583
+ );
3584
+ if (bad) {
3585
+ out.push({ ruleId: "placeholder-mismatch", key, locale, message: "placeholders differ from the source" });
3586
+ }
3587
+ }
3588
+ continue;
3589
+ }
3590
+ const src = entry.values[ctx.sourceLocale]?.value;
3591
+ if (!src) continue;
3592
+ for (const locale of ctx.targetLocales) {
3593
+ const v = entry.values[locale]?.value;
3594
+ if (!v) continue;
3595
+ if (!placeholdersMatch(src, v)) {
3596
+ out.push({ ruleId: "placeholder-mismatch", key, locale, message: "placeholders differ from the source" });
3597
+ }
3598
+ }
3599
+ }
3600
+ return out;
3601
+ }
3602
+ };
3603
+ icuMismatchRule = {
3604
+ id: "icu-mismatch",
3605
+ run(state, ctx) {
3606
+ const out = [];
3607
+ for (const key of Object.keys(state.keys)) {
3608
+ const entry = state.keys[key];
3609
+ const src = entry.values[ctx.sourceLocale]?.value;
3610
+ if (!src) continue;
3611
+ const srcIcu = isIcuPluralOrSelect(src);
3612
+ for (const locale of ctx.targetLocales) {
3613
+ const v = entry.values[locale]?.value;
3614
+ if (!v) continue;
3615
+ if (isIcuPluralOrSelect(v) !== srcIcu) {
3616
+ out.push({
3617
+ ruleId: "icu-mismatch",
3618
+ key,
3619
+ locale,
3620
+ message: srcIcu ? "source is an ICU plural/select but the translation is not" : "translation is an ICU plural/select but the source is not"
3621
+ });
3622
+ }
3623
+ }
3624
+ }
3625
+ return out;
3626
+ }
3627
+ };
3628
+ maxLengthRule = {
3629
+ id: "max-length",
3630
+ run(state, ctx) {
3631
+ const out = [];
3632
+ for (const key of Object.keys(state.keys)) {
3633
+ const entry = state.keys[key];
3634
+ const max = entry.maxLength;
3635
+ if (max == null) continue;
3636
+ for (const locale of ctx.targetLocales) {
3637
+ const v = entry.values[locale]?.value;
3638
+ if (v && v.length > max) {
3639
+ out.push({ ruleId: "max-length", key, locale, message: `length ${v.length} exceeds maxLength ${max}` });
3640
+ }
3641
+ }
3642
+ }
3643
+ return out;
3644
+ }
3645
+ };
3646
+ glossaryViolationRule = {
3647
+ id: "glossary-violation",
3648
+ run(state, ctx) {
3649
+ const out = [];
3650
+ for (const key of Object.keys(state.keys)) {
3651
+ const entry = state.keys[key];
3652
+ const src = entry.values[ctx.sourceLocale]?.value;
3653
+ if (!src) continue;
3654
+ for (const locale of ctx.targetLocales) {
3655
+ const v = entry.values[locale]?.value;
3656
+ if (!v) continue;
3657
+ for (const hint of relevantGlossary(src, locale, ctx.glossary)) {
3658
+ if (hint.doNotTranslate && !v.includes(hint.term)) {
3659
+ out.push({ ruleId: "glossary-violation", key, locale, message: `do-not-translate term "${hint.term}" is missing or altered` });
3660
+ }
3661
+ if (hint.forced && !v.includes(hint.forced)) {
3662
+ out.push({ ruleId: "glossary-violation", key, locale, message: `expected glossary translation "${hint.forced}" for "${hint.term}"` });
3663
+ }
3664
+ }
3665
+ }
3666
+ }
3667
+ return out;
3668
+ }
3669
+ };
3670
+ ALL_RULES = [
3671
+ emptySourceRule,
3672
+ emptyTranslationRule,
3673
+ placeholderMismatchRule,
3674
+ icuMismatchRule,
3675
+ glossaryViolationRule,
3676
+ maxLengthRule,
3677
+ identicalToSourceRule,
3678
+ whitespaceRule,
3679
+ spellingRule
3680
+ ];
3681
+ }
3682
+ });
3683
+
3684
+ // src/server/lint/run.ts
3685
+ function resolveSeverity(id, config) {
3686
+ return config.rules?.[id] ?? DEFAULT_SEVERITY[id];
3687
+ }
3688
+ function sortFindings(findings) {
3689
+ return [...findings].sort(
3690
+ (a, b) => a.key.localeCompare(b.key) || a.locale.localeCompare(b.locale) || a.ruleId.localeCompare(b.ruleId)
3691
+ );
3692
+ }
3693
+ function countSeverities(findings) {
3694
+ let error = 0, warn = 0;
3695
+ for (const f of findings) f.severity === "error" ? error++ : warn++;
3696
+ return { error, warn };
3697
+ }
3698
+ async function loadSpellers(locales, config, load, warn) {
3699
+ const map = /* @__PURE__ */ new Map();
3700
+ for (const locale of locales) {
3701
+ const dictId = config.spelling?.locales?.[locale] ?? locale;
3702
+ const speller = await load(dictId);
3703
+ if (speller) map.set(locale, speller);
3704
+ else warn(`no dictionary for "${locale}", skipping spelling`);
3705
+ }
3706
+ return map;
3707
+ }
3708
+ async function runLint(state, options = {}) {
3709
+ const config = state.config.lint ?? {};
3710
+ const rules = options.rules ?? ALL_RULES;
3711
+ const warn = options.warn ?? ((m) => console.warn(m));
3712
+ const load = options.loadSpeller ?? defaultLoader;
3713
+ const targetLocales = state.config.locales.filter((l) => l !== state.config.sourceLocale);
3714
+ const isActive = (rule) => {
3715
+ if (options.ruleIds && !options.ruleIds.includes(rule.id)) return false;
3716
+ return resolveSeverity(rule.id, config) !== "off";
3717
+ };
3718
+ const active = rules.filter(isActive);
3719
+ const spellingOn = active.some((r) => r.id === "spelling");
3720
+ const spellers = spellingOn ? await loadSpellers(targetLocales, config, load, warn) : /* @__PURE__ */ new Map();
3721
+ const allowWords = spellingOn ? buildAllowWords(state.glossary, config.dictionary) : /* @__PURE__ */ new Set();
3722
+ const ctx = {
3723
+ config,
3724
+ sourceLocale: state.config.sourceLocale,
3725
+ targetLocales,
3726
+ glossary: state.glossary,
3727
+ spellers,
3728
+ allowWords
3729
+ };
3730
+ const ignoreRes = (config.ignore ?? []).map(globToRegExp);
3731
+ const localeFilter = options.locales ? new Set(options.locales) : null;
3732
+ const findings = [];
3733
+ for (const rule of active) {
3734
+ const severity = resolveSeverity(rule.id, config);
3735
+ for (const raw of rule.run(state, ctx)) {
3736
+ if (ignoreRes.some((re) => re.test(raw.key))) continue;
3737
+ if (localeFilter && raw.locale !== "" && !localeFilter.has(raw.locale)) continue;
3738
+ findings.push({ ...raw, severity });
3739
+ }
3740
+ }
3741
+ const sorted = sortFindings(findings);
3742
+ const counts = countSeverities(sorted);
3743
+ return { findings: sorted, counts, ok: counts.error === 0 };
3744
+ }
3745
+ var init_run2 = __esm({
3746
+ "src/server/lint/run.ts"() {
3747
+ "use strict";
3748
+ init_glob();
3749
+ init_registry();
3750
+ init_rules();
3751
+ init_spelling();
3752
+ }
3753
+ });
3754
+
3755
+ // src/server/lint/outputs.ts
3756
+ import { readFileSync as readFileSync10, existsSync as existsSync9 } from "fs";
3757
+ import { resolve as resolve8 } from "path";
3758
+ function checkOutputs(state, root) {
3759
+ const out = [];
3760
+ for (const output of state.config.outputs) {
3761
+ const result = getAdapter(output.adapter).export(state, output);
3762
+ for (const file of result.files) {
3763
+ const abs = resolve8(root, file.path);
3764
+ const current = existsSync9(abs) ? readFileSync10(abs, "utf8") : null;
3765
+ if (current === null) {
3766
+ out.push({ ruleId: "output-stale", key: file.path, locale: "", severity: "error", message: "output file is missing; run `glotfile export`" });
3767
+ } else if (current !== file.contents) {
3768
+ out.push({ ruleId: "output-stale", key: file.path, locale: "", severity: "error", message: "output file is out of date; run `glotfile export`" });
3769
+ }
3770
+ }
3771
+ }
3772
+ return out;
3773
+ }
3774
+ var init_outputs = __esm({
3775
+ "src/server/lint/outputs.ts"() {
3776
+ "use strict";
3777
+ init_adapters();
3778
+ }
3779
+ });
3780
+
3781
+ // src/server/import/detect.ts
3782
+ import { existsSync as existsSync10, readdirSync as readdirSync4, statSync as statSync3 } from "fs";
3783
+ import { join as join4 } from "path";
3784
+ function safeIsDir(p) {
3785
+ try {
3786
+ return statSync3(p).isDirectory();
3787
+ } catch {
3788
+ return false;
3789
+ }
3790
+ }
3791
+ function listDirs(dir) {
3792
+ return readdirSync4(dir).filter((e) => safeIsDir(join4(dir, e)));
3793
+ }
3794
+ function fileCount(dir) {
3795
+ try {
3796
+ return readdirSync4(dir).length;
3797
+ } catch {
3798
+ return 0;
3799
+ }
3800
+ }
3801
+ function pickSource(locales, sizeOf) {
3802
+ if (locales.includes("en")) return "en";
3803
+ return [...locales].sort((a, b) => sizeOf(b) - sizeOf(a) || a.localeCompare(b))[0] ?? "en";
3804
+ }
3805
+ function detectLaravel(root) {
3806
+ const localeRoot = [join4(root, "resources", "lang"), join4(root, "lang")].find(safeIsDir);
3807
+ if (!localeRoot) return null;
3808
+ const locales = listDirs(localeRoot).filter((d) => LOCALE_RE.test(d));
3809
+ if (locales.length === 0) return null;
3810
+ const sourceLocale = pickSource(locales, (loc) => fileCount(join4(localeRoot, loc)));
3811
+ return { format: "laravel-php", localeRoot, locales, sourceLocale };
3812
+ }
3813
+ function detectVue(root, forced = false) {
3814
+ for (const rel of VUE_DIR_CANDIDATES) {
3815
+ const localeRoot = join4(root, rel);
3816
+ if (!safeIsDir(localeRoot)) continue;
3817
+ const locales = readdirSync4(localeRoot).filter((f) => f.endsWith(".json")).map((f) => f.slice(0, -5)).filter((l) => LOCALE_RE.test(l));
3818
+ const enough = locales.length >= 2 || locales.length === 1 && (forced || locales[0] === "en" || locales[0].startsWith("en-") || locales[0].startsWith("en_"));
3819
+ if (enough) {
3820
+ const sourceLocale = pickSource(locales, (loc) => {
3821
+ try {
3822
+ return statSync3(join4(localeRoot, `${loc}.json`)).size;
3823
+ } catch {
3824
+ return 0;
3825
+ }
3826
+ });
3827
+ return { format: "vue-i18n-json", localeRoot, locales, sourceLocale };
3828
+ }
3829
+ }
3830
+ return null;
3831
+ }
3832
+ function detectArb(root) {
3833
+ for (const rel of ["lib/l10n", "l10n", "lib/src/l10n"]) {
3834
+ const localeRoot = join4(root, rel);
3835
+ if (!safeIsDir(localeRoot)) continue;
3836
+ const locales = readdirSync4(localeRoot).map((f) => f.match(/^(?:app_)?(.+)\.arb$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l));
3837
+ if (locales.length >= 1) {
3838
+ return { format: "flutter-arb", localeRoot, locales, sourceLocale: pickSource(locales, () => 0) };
3839
+ }
3840
+ }
3841
+ return null;
3842
+ }
3843
+ function detect(root, formatOverride) {
3844
+ if (!existsSync10(root)) return null;
3845
+ if (formatOverride) {
3846
+ const fn = BY_FORMAT[formatOverride];
3847
+ if (!fn) throw new Error(`Unknown format: ${formatOverride}`);
3848
+ return fn(root);
3849
+ }
3850
+ for (const fn of DETECTORS) {
3851
+ const d = fn(root);
3852
+ if (d) return d;
3853
+ }
3854
+ return null;
3855
+ }
3856
+ var LOCALE_RE, VUE_DIR_CANDIDATES, DETECTORS, BY_FORMAT;
3857
+ var init_detect = __esm({
3858
+ "src/server/import/detect.ts"() {
3859
+ "use strict";
3860
+ LOCALE_RE = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
3861
+ VUE_DIR_CANDIDATES = ["src/locale", "src/locales", "src/i18n/locales", "locales", "lang"];
3862
+ DETECTORS = [detectLaravel, detectVue, detectArb];
3863
+ BY_FORMAT = {
3864
+ "laravel-php": detectLaravel,
3865
+ "vue-i18n-json": (root) => detectVue(root, true),
3866
+ "flutter-arb": detectArb
3867
+ };
3868
+ }
3869
+ });
3870
+
3871
+ // src/server/import/flatten.ts
3872
+ function flattenObject(value, prefix, warnings) {
3873
+ const out = {};
3874
+ const walk = (node, path) => {
3875
+ if (typeof node === "string") {
3876
+ out[path] = node;
3877
+ } else if (typeof node === "number" || typeof node === "boolean") {
3878
+ out[path] = String(node);
3139
3879
  } else if (Array.isArray(node)) {
3140
3880
  node.forEach((el, i) => walk(el, path ? `${path}.${i}` : String(i)));
3141
3881
  } else if (node && typeof node === "object") {
@@ -3156,7 +3896,7 @@ var init_flatten = __esm({
3156
3896
  });
3157
3897
 
3158
3898
  // src/server/import/parsers/vue-i18n-json.ts
3159
- import { readdirSync as readdirSync4, readFileSync as readFileSync11 } from "fs";
3899
+ import { readdirSync as readdirSync5, readFileSync as readFileSync11 } from "fs";
3160
3900
  import { join as join5 } from "path";
3161
3901
  var LOCALE_RE2, vueI18nJson2;
3162
3902
  var init_vue_i18n_json2 = __esm({
@@ -3170,7 +3910,7 @@ var init_vue_i18n_json2 = __esm({
3170
3910
  const warnings = [];
3171
3911
  const keys = {};
3172
3912
  const locales = [];
3173
- for (const file of readdirSync4(localeRoot).sort()) {
3913
+ for (const file of readdirSync5(localeRoot).sort()) {
3174
3914
  if (!file.endsWith(".json")) continue;
3175
3915
  const locale = file.slice(0, -".json".length);
3176
3916
  if (!LOCALE_RE2.test(locale)) continue;
@@ -3204,18 +3944,18 @@ var init_placeholders2 = __esm({
3204
3944
  });
3205
3945
 
3206
3946
  // src/server/import/parsers/laravel-php.ts
3207
- import { readdirSync as readdirSync5, statSync as statSync3 } from "fs";
3947
+ import { readdirSync as readdirSync6, statSync as statSync4 } from "fs";
3208
3948
  import { join as join6, relative as relative2 } from "path";
3209
3949
  import { execFileSync } from "child_process";
3210
3950
  function listDirs2(dir) {
3211
- return readdirSync5(dir).filter((e) => statSync3(join6(dir, e)).isDirectory());
3951
+ return readdirSync6(dir).filter((e) => statSync4(join6(dir, e)).isDirectory());
3212
3952
  }
3213
3953
  function listPhpFiles(dir) {
3214
3954
  const out = [];
3215
3955
  const walk = (d) => {
3216
- for (const e of readdirSync5(d)) {
3956
+ for (const e of readdirSync6(d)) {
3217
3957
  const full = join6(d, e);
3218
- if (statSync3(full).isDirectory()) walk(full);
3958
+ if (statSync4(full).isDirectory()) walk(full);
3219
3959
  else if (e.endsWith(".php")) out.push(full);
3220
3960
  }
3221
3961
  };
@@ -3282,7 +4022,7 @@ var init_laravel_php2 = __esm({
3282
4022
  });
3283
4023
 
3284
4024
  // src/server/import/parsers/flutter-arb.ts
3285
- import { readdirSync as readdirSync6, readFileSync as readFileSync12 } from "fs";
4025
+ import { readdirSync as readdirSync7, readFileSync as readFileSync12 } from "fs";
3286
4026
  import { join as join7 } from "path";
3287
4027
  function localeFromArbName(file) {
3288
4028
  const m = file.match(/^(.+)\.arb$/);
@@ -3316,7 +4056,7 @@ var init_flutter_arb2 = __esm({
3316
4056
  const warnings = [];
3317
4057
  const keys = {};
3318
4058
  const locales = [];
3319
- for (const file of readdirSync6(localeRoot).sort()) {
4059
+ for (const file of readdirSync7(localeRoot).sort()) {
3320
4060
  if (!file.endsWith(".arb")) continue;
3321
4061
  const locale = localeFromArbName(file);
3322
4062
  if (!locale) continue;
@@ -3502,7 +4242,7 @@ function runImport(opts) {
3502
4242
  localeCount: state.config.locales.length
3503
4243
  };
3504
4244
  }
3505
- var init_run2 = __esm({
4245
+ var init_run3 = __esm({
3506
4246
  "src/server/import/run.ts"() {
3507
4247
  "use strict";
3508
4248
  init_detect();
@@ -3888,11 +4628,11 @@ var init_ui_prefs = __esm({
3888
4628
  // src/server/api.ts
3889
4629
  import { Hono } from "hono";
3890
4630
  import { streamSSE } from "hono/streaming";
3891
- import { readFileSync as readFileSync14, existsSync as existsSync10, readdirSync as readdirSync7, statSync as statSync4, rmSync as rmSync4 } from "fs";
3892
- import { dirname as dirname2, resolve as resolve9, basename, relative as relative3, sep } from "path";
4631
+ import { readFileSync as readFileSync14, existsSync as existsSync11, readdirSync as readdirSync8, statSync as statSync5, rmSync as rmSync4 } from "fs";
4632
+ import { dirname as dirname3, resolve as resolve9, basename, relative as relative3, sep as sep2 } from "path";
3893
4633
  function projectName(root) {
3894
4634
  const nameFile = resolve9(root, ".idea", ".name");
3895
- if (existsSync10(nameFile)) {
4635
+ if (existsSync11(nameFile)) {
3896
4636
  try {
3897
4637
  const name = readFileSync14(nameFile, "utf8").trim();
3898
4638
  if (name) return name;
@@ -3904,7 +4644,7 @@ function projectName(root) {
3904
4644
  function createApi(deps) {
3905
4645
  const app = new Hono();
3906
4646
  const load = () => loadState(deps.statePath);
3907
- const projectRoot = dirname2(resolve9(deps.statePath));
4647
+ const projectRoot = dirname3(resolve9(deps.statePath));
3908
4648
  let translateQueue = Promise.resolve();
3909
4649
  const withTranslateLock = (fn) => {
3910
4650
  const next = translateQueue.then(fn, fn);
@@ -4002,13 +4742,13 @@ function createApi(deps) {
4002
4742
  found.set(deps.statePath, {
4003
4743
  name: basename(deps.statePath),
4004
4744
  path: deps.statePath,
4005
- relDir: activeRel !== basename(activeRel) ? dirname2(activeRel) : void 0
4745
+ relDir: activeRel !== basename(activeRel) ? dirname3(activeRel) : void 0
4006
4746
  });
4007
4747
  function walk(dir, depth) {
4008
4748
  if (depth > 4) return;
4009
4749
  let entries = [];
4010
4750
  try {
4011
- entries = readdirSync7(dir);
4751
+ entries = readdirSync8(dir);
4012
4752
  } catch {
4013
4753
  return;
4014
4754
  }
@@ -4016,13 +4756,13 @@ function createApi(deps) {
4016
4756
  if (name.startsWith(".") || name === "node_modules") continue;
4017
4757
  const abs = resolve9(dir, name);
4018
4758
  let filePath = null;
4019
- if ((name === "glotfile" || name.endsWith(".glotfile")) && existsSync10(resolve9(abs, "config.json"))) {
4759
+ if ((name === "glotfile" || name.endsWith(".glotfile")) && existsSync11(resolve9(abs, "config.json"))) {
4020
4760
  filePath = resolve9(dir, `${name}.json`);
4021
4761
  } else if (name === "glotfile.json" || name.endsWith(".glotfile.json")) {
4022
4762
  filePath = abs;
4023
4763
  } else {
4024
4764
  try {
4025
- if (statSync4(abs).isDirectory()) walk(abs, depth + 1);
4765
+ if (statSync5(abs).isDirectory()) walk(abs, depth + 1);
4026
4766
  } catch {
4027
4767
  }
4028
4768
  continue;
@@ -4031,7 +4771,7 @@ function createApi(deps) {
4031
4771
  try {
4032
4772
  loadState(filePath);
4033
4773
  const rel = relative3(projectRoot, filePath);
4034
- found.set(filePath, { name: basename(filePath), path: filePath, relDir: rel !== basename(filePath) ? dirname2(rel) : void 0 });
4774
+ found.set(filePath, { name: basename(filePath), path: filePath, relDir: rel !== basename(filePath) ? dirname3(rel) : void 0 });
4035
4775
  } catch {
4036
4776
  }
4037
4777
  }
@@ -4048,9 +4788,9 @@ function createApi(deps) {
4048
4788
  const { path } = await c.req.json();
4049
4789
  if (typeof path !== "string") return c.json({ error: "path must be a string" }, 400);
4050
4790
  const resolved = resolve9(projectRoot, path);
4051
- const inside = resolved === projectRoot || resolved.startsWith(projectRoot + sep);
4791
+ const inside = resolved === projectRoot || resolved.startsWith(projectRoot + sep2);
4052
4792
  if (!inside) return c.json({ error: "file is outside the project" }, 400);
4053
- if (!existsSync10(resolved)) return c.json({ error: "file not found" }, 400);
4793
+ if (!existsSync11(resolved)) return c.json({ error: "file not found" }, 400);
4054
4794
  loadState(resolved);
4055
4795
  deps.statePath = resolved;
4056
4796
  return c.json({ ok: true, path: resolved, name: basename(resolved), dir: projectRoot, project: basename(projectRoot) });
@@ -4107,11 +4847,11 @@ function createApi(deps) {
4107
4847
  function removeOrphanScreenshot(s, screenshot) {
4108
4848
  if (!screenshot) return;
4109
4849
  for (const e of Object.values(s.keys)) if (e.screenshot === screenshot) return;
4110
- const root = dirname2(resolve9(deps.statePath));
4850
+ const root = dirname3(resolve9(deps.statePath));
4111
4851
  const abs = resolve9(root, screenshot);
4112
4852
  const rel = relative3(root, abs);
4113
- const seg0 = rel.split(sep)[0] ?? "";
4114
- if (!rel.startsWith("..") && seg0.endsWith("-screenshots") && existsSync10(abs)) {
4853
+ const seg0 = rel.split(sep2)[0] ?? "";
4854
+ if (!rel.startsWith("..") && seg0.endsWith("-screenshots") && existsSync11(abs)) {
4115
4855
  try {
4116
4856
  rmSync4(abs);
4117
4857
  } catch {
@@ -4363,7 +5103,7 @@ function createApi(deps) {
4363
5103
  const body = await c.req.parseBody();
4364
5104
  const file = body["file"];
4365
5105
  if (!file || typeof file === "string") return c.json({ error: "no file uploaded" }, 400);
4366
- const root = dirname2(resolve9(deps.statePath));
5106
+ const root = dirname3(resolve9(deps.statePath));
4367
5107
  const dirName = screenshotDirName(deps.statePath);
4368
5108
  const dir = resolve9(root, dirName);
4369
5109
  const filename = `${sanitize(key)}__${sanitize(file.name)}`;
@@ -4399,6 +5139,23 @@ function createApi(deps) {
4399
5139
  return c.json({ files, warnings });
4400
5140
  });
4401
5141
  app.get("/scan/missing", (c) => c.json(findMissing(load())));
5142
+ const spellerCache = /* @__PURE__ */ new Map();
5143
+ const cachedLoader = (dictId) => {
5144
+ let p = spellerCache.get(dictId);
5145
+ if (!p) {
5146
+ p = defaultLoader(dictId);
5147
+ spellerCache.set(dictId, p);
5148
+ }
5149
+ return p;
5150
+ };
5151
+ app.get("/lint", async (c) => {
5152
+ const state = load();
5153
+ const lint = await runLint(state, { loadSpeller: cachedLoader, warn: () => {
5154
+ } });
5155
+ const findings = sortFindings([...lint.findings, ...checkOutputs(state, projectRoot)]);
5156
+ const counts = countSeverities(findings);
5157
+ return c.json({ findings, counts, ok: counts.error === 0 });
5158
+ });
4402
5159
  app.get("/checks", (c) => {
4403
5160
  const param = c.req.query("checks");
4404
5161
  const only = param ? param.split(",").map((s) => s.trim()).filter((s) => CHECK_IDS.includes(s)) : void 0;
@@ -4434,22 +5191,12 @@ function createApi(deps) {
4434
5191
  return c.json({ keyCount: result.keyCount, localeCount: result.localeCount, warnings: result.warnings });
4435
5192
  });
4436
5193
  app.post("/export", (c) => {
4437
- const s = narrowForExport(load());
4438
- const root = dirname2(resolve9(deps.statePath));
4439
- const warnings = [];
4440
- let count = 0;
4441
- for (const output of s.config.outputs) {
4442
- const adapter = getAdapter(output.adapter);
4443
- const result = adapter.export(s, output);
4444
- warnings.push(...result.warnings);
4445
- for (const f of result.files) {
4446
- const abs = resolve9(root, f.path);
4447
- writeFileAtomic(abs, f.contents);
4448
- count++;
4449
- }
4450
- }
4451
- console.log(`[export] ${count} file(s)${warnings.length ? `, ${warnings.length} warning(s)` : ""}`);
4452
- return c.json({ files: count, warnings });
5194
+ const root = dirname3(resolve9(deps.statePath));
5195
+ const { written, skipped, deleted, warnings } = exportToDisk(load(), root);
5196
+ console.log(
5197
+ `[export] ${written + skipped} file(s)${deleted ? `, ${deleted} removed` : ""}${warnings.length ? `, ${warnings.length} warning(s)` : ""}`
5198
+ );
5199
+ return c.json({ files: written + skipped, warnings });
4453
5200
  });
4454
5201
  app.post("/translate/stream", async (c) => {
4455
5202
  const signal = c.req.raw.signal;
@@ -4471,7 +5218,7 @@ function createApi(deps) {
4471
5218
  await stream.writeSSE({ event: "error", data: JSON.stringify({ error: e.message }) });
4472
5219
  return;
4473
5220
  }
4474
- const { skipped } = attachScreenshotsForProvider(reqs, s, dirname2(resolve9(deps.statePath)), provider.supportsVision());
5221
+ const { skipped } = attachScreenshotsForProvider(reqs, s, dirname3(resolve9(deps.statePath)), provider.supportsVision());
4475
5222
  if (skipped) console.warn(`Model "${aiCfg.model}" has no vision support; ${skipped} screenshot(s) ignored.`);
4476
5223
  console.log(`[translate] ${reqs.length} string(s) \u2192 ${aiCfg.model}`);
4477
5224
  let totalWritten = 0;
@@ -4549,7 +5296,7 @@ function createApi(deps) {
4549
5296
  } catch (e) {
4550
5297
  return c.json({ error: e.message }, 400);
4551
5298
  }
4552
- const { skipped } = attachScreenshotsForProvider(toTranslate, s, dirname2(resolve9(deps.statePath)), provider.supportsVision());
5299
+ const { skipped } = attachScreenshotsForProvider(toTranslate, s, dirname3(resolve9(deps.statePath)), provider.supportsVision());
4553
5300
  if (skipped) console.warn(`Model "${aiCfg.model}" has no vision support; ${skipped} screenshot(s) ignored.`);
4554
5301
  const results = await runLocaleParallel(toTranslate, provider, {}, aiCfg.concurrency);
4555
5302
  const latest = load();
@@ -4577,6 +5324,13 @@ function createApi(deps) {
4577
5324
  }
4578
5325
  return c.json({ requested: reqs.length, written, errors });
4579
5326
  }));
5327
+ app.post("/translate/estimate", async (c) => {
5328
+ const body = await c.req.json().catch(() => ({}));
5329
+ const keys = Array.isArray(body.keys) && body.keys.length ? body.keys.filter(Boolean) : void 0;
5330
+ const locales = Array.isArray(body.locales) && body.locales.length ? body.locales.filter(Boolean) : void 0;
5331
+ const ai = loadLocalSettings(projectRoot).ai;
5332
+ return c.json(estimateTranslation(load(), ai, { onlyMissing: body.onlyMissing ?? true, keys, locales }));
5333
+ });
4580
5334
  app.get("/log", (c) => c.json(readLog(projectRoot, 100)));
4581
5335
  app.post("/scan", async (c) => {
4582
5336
  const s = load();
@@ -4736,13 +5490,17 @@ var init_api = __esm({
4736
5490
  init_context();
4737
5491
  init_stats();
4738
5492
  init_checks();
5493
+ init_run2();
5494
+ init_outputs();
5495
+ init_spelling();
4739
5496
  init_adapters();
4740
5497
  init_ai();
4741
5498
  init_run();
4742
5499
  init_provider();
5500
+ init_estimate();
4743
5501
  init_log();
4744
5502
  init_schema();
4745
- init_run2();
5503
+ init_run3();
4746
5504
  init_export_run();
4747
5505
  init_ui_prefs();
4748
5506
  init_local_settings();
@@ -4761,7 +5519,7 @@ __export(server_exports, {
4761
5519
  import { Hono as Hono2 } from "hono";
4762
5520
  import { serve } from "@hono/node-server";
4763
5521
  import { fileURLToPath } from "url";
4764
- import { dirname as dirname3, join as join9, resolve as resolve10, extname as extname3, sep as sep2 } from "path";
5522
+ import { dirname as dirname4, join as join9, resolve as resolve10, extname as extname3, sep as sep3 } from "path";
4765
5523
  import { readFile, stat } from "fs/promises";
4766
5524
  import { createServer } from "net";
4767
5525
  import open from "open";
@@ -4783,11 +5541,11 @@ function buildApp(opts) {
4783
5541
  app.get("/:dir/*", async (c, next) => {
4784
5542
  const dirSeg = c.req.param("dir");
4785
5543
  if (!dirSeg.endsWith("-screenshots")) return next();
4786
- const shotsRoot = resolve10(dirname3(resolve10(apiDeps.statePath)), dirSeg);
5544
+ const shotsRoot = resolve10(dirname4(resolve10(apiDeps.statePath)), dirSeg);
4787
5545
  const pathname = decodeURIComponent(new URL(c.req.url).pathname);
4788
5546
  const rest = pathname.slice(`/${dirSeg}`.length);
4789
5547
  const target = resolve10(shotsRoot, "." + rest);
4790
- const inside = target === shotsRoot || target.startsWith(shotsRoot + sep2);
5548
+ const inside = target === shotsRoot || target.startsWith(shotsRoot + sep3);
4791
5549
  if (inside) {
4792
5550
  const file = await readFileResponse(target);
4793
5551
  if (file) return file;
@@ -4799,7 +5557,7 @@ function buildApp(opts) {
4799
5557
  app.get("/*", async (c) => {
4800
5558
  const pathname = decodeURIComponent(new URL(c.req.url).pathname);
4801
5559
  const target = resolve10(root, "." + pathname);
4802
- const inside = target === root || target.startsWith(root + sep2);
5560
+ const inside = target === root || target.startsWith(root + sep3);
4803
5561
  if (inside && pathname !== "/") {
4804
5562
  const file = await readFileResponse(target);
4805
5563
  if (file) return file;
@@ -4841,7 +5599,7 @@ async function startServer(opts) {
4841
5599
  });
4842
5600
  }
4843
5601
  function backgroundScan(statePath) {
4844
- const projectRoot = dirname3(resolve10(statePath));
5602
+ const projectRoot = dirname4(resolve10(statePath));
4845
5603
  Promise.resolve().then(() => {
4846
5604
  const state = loadState(statePath);
4847
5605
  const existing = loadUsageCache(projectRoot);
@@ -4861,7 +5619,7 @@ var init_server = __esm({
4861
5619
  init_state();
4862
5620
  init_scan();
4863
5621
  init_scanner();
4864
- here = dirname3(fileURLToPath(import.meta.url));
5622
+ here = dirname4(fileURLToPath(import.meta.url));
4865
5623
  DEFAULT_UI_DIR = join9(here, "..", "ui");
4866
5624
  MIME = {
4867
5625
  ".html": "text/html; charset=utf-8",
@@ -4899,331 +5657,15 @@ init_ai();
4899
5657
  init_local_settings();
4900
5658
  init_run();
4901
5659
  init_provider();
5660
+ init_estimate();
4902
5661
  init_log();
4903
5662
  init_scan();
4904
5663
  init_scanner();
4905
5664
  init_context();
4906
- import { resolve as resolve11, dirname as dirname4 } from "path";
4907
- import { readFileSync as readFileSync15, existsSync as existsSync11 } from "fs";
4908
-
4909
- // src/server/lint/run.ts
4910
- init_glob();
4911
- init_registry();
4912
-
4913
- // src/server/lint/rules.ts
4914
- init_scan();
4915
- init_placeholders();
4916
- init_run();
4917
-
4918
- // src/server/lint/spelling.ts
4919
- init_placeholders();
4920
- function tokenize(text) {
4921
- return text.match(new RegExp("\\p{L}[\\p{L}'']*", "gu")) ?? [];
4922
- }
4923
- function buildAllowWords(glossary, dictionary2 = []) {
4924
- const set = /* @__PURE__ */ new Set();
4925
- const add = (s) => {
4926
- for (const w of tokenize(s)) set.add(w.toLowerCase());
4927
- };
4928
- for (const g of glossary) add(g.term);
4929
- for (const w of dictionary2) add(w);
4930
- return set;
4931
- }
4932
- var spellingRule = {
4933
- id: "spelling",
4934
- run(state, ctx) {
4935
- const out = [];
4936
- for (const key of Object.keys(state.keys)) {
4937
- const entry = state.keys[key];
4938
- for (const locale of ctx.targetLocales) {
4939
- const speller = ctx.spellers.get(locale);
4940
- if (!speller) continue;
4941
- const value = entry.values[locale]?.value;
4942
- if (!value) continue;
4943
- const placeholders = new Set(extractPlaceholders(value).map((p) => p.toLowerCase()));
4944
- for (const word of tokenize(value)) {
4945
- const lower = word.toLowerCase();
4946
- if (placeholders.has(lower) || ctx.allowWords.has(lower)) continue;
4947
- if (!speller.correct(word)) {
4948
- out.push({ ruleId: "spelling", key, locale, message: `possible misspelling: "${word}"` });
4949
- }
4950
- }
4951
- }
4952
- }
4953
- return out;
4954
- }
4955
- };
4956
- var defaultLoader = async (dictId) => {
4957
- try {
4958
- const nspellMod = await import("nspell");
4959
- const nspell2 = nspellMod.default ?? nspellMod;
4960
- const dictMod = await import(`dictionary-${dictId.toLowerCase()}`);
4961
- const dictExport = dictMod.default ?? dictMod;
4962
- const dict = typeof dictExport === "function" ? await dictExport() : dictExport;
4963
- return nspell2(dict);
4964
- } catch {
4965
- return null;
4966
- }
4967
- };
4968
-
4969
- // src/server/lint/rules.ts
4970
- var emptySourceRule = {
4971
- id: "empty-source",
4972
- run(state, ctx) {
4973
- const out = [];
4974
- for (const key of Object.keys(state.keys)) {
4975
- const v = state.keys[key].values[ctx.sourceLocale]?.value;
4976
- if (!v || !v.trim()) out.push({ ruleId: "empty-source", key, locale: "", message: "source value is empty" });
4977
- }
4978
- return out;
4979
- }
4980
- };
4981
- var emptyTranslationRule = {
4982
- id: "empty-translation",
4983
- run(state, ctx) {
4984
- const out = [];
4985
- for (const m of findMissing(state)) {
4986
- out.push({ ruleId: "empty-translation", key: m.key, locale: m.locale, message: "translation is empty or missing" });
4987
- }
4988
- for (const key of Object.keys(state.keys)) {
4989
- for (const locale of ctx.targetLocales) {
4990
- const v = state.keys[key].values[locale]?.value;
4991
- if (v && !v.trim()) out.push({ ruleId: "empty-translation", key, locale, message: "translation is whitespace-only" });
4992
- }
4993
- }
4994
- return out;
4995
- }
4996
- };
4997
- var identicalToSourceRule = {
4998
- id: "identical-to-source",
4999
- run(state, ctx) {
5000
- const out = [];
5001
- for (const key of Object.keys(state.keys)) {
5002
- const entry = state.keys[key];
5003
- const src = entry.values[ctx.sourceLocale]?.value;
5004
- if (!src) continue;
5005
- for (const locale of ctx.targetLocales) {
5006
- const v = entry.values[locale]?.value;
5007
- if (v && v === src) out.push({ ruleId: "identical-to-source", key, locale, message: "translation is identical to the source" });
5008
- }
5009
- }
5010
- return out;
5011
- }
5012
- };
5013
- var whitespaceRule = {
5014
- id: "whitespace",
5015
- run(state, ctx) {
5016
- const out = [];
5017
- for (const key of Object.keys(state.keys)) {
5018
- const entry = state.keys[key];
5019
- const src = entry.values[ctx.sourceLocale]?.value ?? "";
5020
- const srcEdge = src !== src.trim();
5021
- for (const locale of ctx.targetLocales) {
5022
- const v = entry.values[locale]?.value;
5023
- if (!v) continue;
5024
- if (v !== v.trim() !== srcEdge) {
5025
- out.push({ ruleId: "whitespace", key, locale, message: "leading/trailing whitespace differs from the source" });
5026
- }
5027
- }
5028
- }
5029
- return out;
5030
- }
5031
- };
5032
- var placeholderMismatchRule = {
5033
- id: "placeholder-mismatch",
5034
- run(state, ctx) {
5035
- const out = [];
5036
- for (const key of Object.keys(state.keys)) {
5037
- const entry = state.keys[key];
5038
- if (entry.plural) {
5039
- const srcForm = entry.values[ctx.sourceLocale]?.forms?.other;
5040
- if (!srcForm) continue;
5041
- for (const locale of ctx.targetLocales) {
5042
- const forms = entry.values[locale]?.forms;
5043
- if (!forms) continue;
5044
- const bad = Object.entries(forms).some(
5045
- ([cat, form]) => form && !pluralFormPlaceholdersMatch(cat, srcForm, form)
5046
- );
5047
- if (bad) {
5048
- out.push({ ruleId: "placeholder-mismatch", key, locale, message: "placeholders differ from the source" });
5049
- }
5050
- }
5051
- continue;
5052
- }
5053
- const src = entry.values[ctx.sourceLocale]?.value;
5054
- if (!src) continue;
5055
- for (const locale of ctx.targetLocales) {
5056
- const v = entry.values[locale]?.value;
5057
- if (!v) continue;
5058
- if (!placeholdersMatch(src, v)) {
5059
- out.push({ ruleId: "placeholder-mismatch", key, locale, message: "placeholders differ from the source" });
5060
- }
5061
- }
5062
- }
5063
- return out;
5064
- }
5065
- };
5066
- var icuMismatchRule = {
5067
- id: "icu-mismatch",
5068
- run(state, ctx) {
5069
- const out = [];
5070
- for (const key of Object.keys(state.keys)) {
5071
- const entry = state.keys[key];
5072
- const src = entry.values[ctx.sourceLocale]?.value;
5073
- if (!src) continue;
5074
- const srcIcu = isIcuPluralOrSelect(src);
5075
- for (const locale of ctx.targetLocales) {
5076
- const v = entry.values[locale]?.value;
5077
- if (!v) continue;
5078
- if (isIcuPluralOrSelect(v) !== srcIcu) {
5079
- out.push({
5080
- ruleId: "icu-mismatch",
5081
- key,
5082
- locale,
5083
- message: srcIcu ? "source is an ICU plural/select but the translation is not" : "translation is an ICU plural/select but the source is not"
5084
- });
5085
- }
5086
- }
5087
- }
5088
- return out;
5089
- }
5090
- };
5091
- var maxLengthRule = {
5092
- id: "max-length",
5093
- run(state, ctx) {
5094
- const out = [];
5095
- for (const key of Object.keys(state.keys)) {
5096
- const entry = state.keys[key];
5097
- const max = entry.maxLength;
5098
- if (max == null) continue;
5099
- for (const locale of ctx.targetLocales) {
5100
- const v = entry.values[locale]?.value;
5101
- if (v && v.length > max) {
5102
- out.push({ ruleId: "max-length", key, locale, message: `length ${v.length} exceeds maxLength ${max}` });
5103
- }
5104
- }
5105
- }
5106
- return out;
5107
- }
5108
- };
5109
- var glossaryViolationRule = {
5110
- id: "glossary-violation",
5111
- run(state, ctx) {
5112
- const out = [];
5113
- for (const key of Object.keys(state.keys)) {
5114
- const entry = state.keys[key];
5115
- const src = entry.values[ctx.sourceLocale]?.value;
5116
- if (!src) continue;
5117
- for (const locale of ctx.targetLocales) {
5118
- const v = entry.values[locale]?.value;
5119
- if (!v) continue;
5120
- for (const hint of relevantGlossary(src, locale, ctx.glossary)) {
5121
- if (hint.doNotTranslate && !v.includes(hint.term)) {
5122
- out.push({ ruleId: "glossary-violation", key, locale, message: `do-not-translate term "${hint.term}" is missing or altered` });
5123
- }
5124
- if (hint.forced && !v.includes(hint.forced)) {
5125
- out.push({ ruleId: "glossary-violation", key, locale, message: `expected glossary translation "${hint.forced}" for "${hint.term}"` });
5126
- }
5127
- }
5128
- }
5129
- }
5130
- return out;
5131
- }
5132
- };
5133
- var ALL_RULES = [
5134
- emptySourceRule,
5135
- emptyTranslationRule,
5136
- placeholderMismatchRule,
5137
- icuMismatchRule,
5138
- glossaryViolationRule,
5139
- maxLengthRule,
5140
- identicalToSourceRule,
5141
- whitespaceRule,
5142
- spellingRule
5143
- ];
5144
-
5145
- // src/server/lint/run.ts
5146
- function resolveSeverity(id, config) {
5147
- return config.rules?.[id] ?? DEFAULT_SEVERITY[id];
5148
- }
5149
- function sortFindings(findings) {
5150
- return [...findings].sort(
5151
- (a, b) => a.key.localeCompare(b.key) || a.locale.localeCompare(b.locale) || a.ruleId.localeCompare(b.ruleId)
5152
- );
5153
- }
5154
- function countSeverities(findings) {
5155
- let error = 0, warn = 0;
5156
- for (const f of findings) f.severity === "error" ? error++ : warn++;
5157
- return { error, warn };
5158
- }
5159
- async function loadSpellers(locales, config, load, warn) {
5160
- const map = /* @__PURE__ */ new Map();
5161
- for (const locale of locales) {
5162
- const dictId = config.spelling?.locales?.[locale] ?? locale;
5163
- const speller = await load(dictId);
5164
- if (speller) map.set(locale, speller);
5165
- else warn(`no dictionary for "${locale}", skipping spelling`);
5166
- }
5167
- return map;
5168
- }
5169
- async function runLint(state, options = {}) {
5170
- const config = state.config.lint ?? {};
5171
- const rules = options.rules ?? ALL_RULES;
5172
- const warn = options.warn ?? ((m) => console.warn(m));
5173
- const load = options.loadSpeller ?? defaultLoader;
5174
- const targetLocales = state.config.locales.filter((l) => l !== state.config.sourceLocale);
5175
- const isActive = (rule) => {
5176
- if (options.ruleIds && !options.ruleIds.includes(rule.id)) return false;
5177
- return resolveSeverity(rule.id, config) !== "off";
5178
- };
5179
- const active = rules.filter(isActive);
5180
- const spellingOn = active.some((r) => r.id === "spelling");
5181
- const spellers = spellingOn ? await loadSpellers(targetLocales, config, load, warn) : /* @__PURE__ */ new Map();
5182
- const allowWords = spellingOn ? buildAllowWords(state.glossary, config.dictionary) : /* @__PURE__ */ new Set();
5183
- const ctx = {
5184
- config,
5185
- sourceLocale: state.config.sourceLocale,
5186
- targetLocales,
5187
- glossary: state.glossary,
5188
- spellers,
5189
- allowWords
5190
- };
5191
- const ignoreRes = (config.ignore ?? []).map(globToRegExp);
5192
- const localeFilter = options.locales ? new Set(options.locales) : null;
5193
- const findings = [];
5194
- for (const rule of active) {
5195
- const severity = resolveSeverity(rule.id, config);
5196
- for (const raw of rule.run(state, ctx)) {
5197
- if (ignoreRes.some((re) => re.test(raw.key))) continue;
5198
- if (localeFilter && raw.locale !== "" && !localeFilter.has(raw.locale)) continue;
5199
- findings.push({ ...raw, severity });
5200
- }
5201
- }
5202
- const sorted = sortFindings(findings);
5203
- const counts = countSeverities(sorted);
5204
- return { findings: sorted, counts, ok: counts.error === 0 };
5205
- }
5206
-
5207
- // src/server/lint/outputs.ts
5208
- init_adapters();
5209
- import { readFileSync as readFileSync10, existsSync as existsSync8 } from "fs";
5210
- import { resolve as resolve8 } from "path";
5211
- function checkOutputs(state, root) {
5212
- const out = [];
5213
- for (const output of state.config.outputs) {
5214
- const result = getAdapter(output.adapter).export(state, output);
5215
- for (const file of result.files) {
5216
- const abs = resolve8(root, file.path);
5217
- const current = existsSync8(abs) ? readFileSync10(abs, "utf8") : null;
5218
- if (current === null) {
5219
- out.push({ ruleId: "output-stale", key: file.path, locale: "", severity: "error", message: "output file is missing; run `glotfile export`" });
5220
- } else if (current !== file.contents) {
5221
- out.push({ ruleId: "output-stale", key: file.path, locale: "", severity: "error", message: "output file is out of date; run `glotfile export`" });
5222
- }
5223
- }
5224
- }
5225
- return out;
5226
- }
5665
+ init_run2();
5666
+ init_outputs();
5667
+ import { resolve as resolve11, dirname as dirname5 } from "path";
5668
+ import { readFileSync as readFileSync15, existsSync as existsSync12 } from "fs";
5227
5669
 
5228
5670
  // src/server/lint/locate.ts
5229
5671
  function locate(rawText, key) {
@@ -5353,6 +5795,7 @@ function parseArgs(argv) {
5353
5795
  } else if (flag === "--empty-source") args.emptySource = true;
5354
5796
  else if (flag === "--unused") args.unused = true;
5355
5797
  else if (flag === "--write") args.write = true;
5798
+ else if (flag === "--estimate") args.estimate = true;
5356
5799
  }
5357
5800
  return args;
5358
5801
  }
@@ -5366,7 +5809,7 @@ function watchTargetFor(statePath) {
5366
5809
  return detectFormat(statePath) === "split" ? { path: splitDirFor(statePath), recursive: true } : { path: statePath, recursive: false };
5367
5810
  }
5368
5811
  async function runExport(args) {
5369
- const root = dirname4(resolve11(args.statePath));
5812
+ const root = dirname5(resolve11(args.statePath));
5370
5813
  const runOnce = () => {
5371
5814
  const state = loadState(args.statePath);
5372
5815
  const result = exportToDisk(state, root, args.adapter ? { adapter: args.adapter } : void 0);
@@ -5377,8 +5820,9 @@ async function runExport(args) {
5377
5820
  return result;
5378
5821
  };
5379
5822
  if (!args.watch) {
5380
- const { written, skipped } = runOnce();
5381
- console.log(`Exported ${written} file(s)${skipped ? ` (${skipped} unchanged)` : ""}.`);
5823
+ const { written, skipped, deleted } = runOnce();
5824
+ const extras = [skipped ? `${skipped} unchanged` : "", deleted ? `${deleted} stale removed` : ""].filter(Boolean);
5825
+ console.log(`Exported ${written} file(s)${extras.length ? ` (${extras.join(", ")})` : ""}.`);
5382
5826
  return;
5383
5827
  }
5384
5828
  const { watch } = await import("fs");
@@ -5390,8 +5834,10 @@ async function runExport(args) {
5390
5834
  clearTimeout(timer);
5391
5835
  timer = setTimeout(() => {
5392
5836
  try {
5393
- const { written } = runOnce();
5394
- if (written) console.log(`Re-exported ${written} file(s).`);
5837
+ const { written, deleted } = runOnce();
5838
+ if (written || deleted) {
5839
+ console.log(`Re-exported ${written} file(s)${deleted ? ` (${deleted} stale removed)` : ""}.`);
5840
+ }
5395
5841
  } catch (e) {
5396
5842
  console.error(e.message);
5397
5843
  }
@@ -5402,7 +5848,32 @@ async function runExport(args) {
5402
5848
  }
5403
5849
  async function runTranslate(args) {
5404
5850
  const state = loadState(args.statePath);
5405
- const projectRoot = dirname4(resolve11(args.statePath));
5851
+ const projectRoot = dirname5(resolve11(args.statePath));
5852
+ if (args.estimate) {
5853
+ const ai = loadLocalSettings(projectRoot).ai;
5854
+ const est = estimateTranslation(state, ai, {
5855
+ onlyMissing: args.all ? false : args.onlyMissing ?? true,
5856
+ locales: args.locales,
5857
+ keyGlob: args.keyGlob
5858
+ });
5859
+ if (!est.requests) {
5860
+ console.log("Nothing to translate.");
5861
+ return;
5862
+ }
5863
+ const fmt = (n) => n.toLocaleString("en-US");
5864
+ console.log(`Estimate for ${fmt(est.requests)} request(s) in ${fmt(est.batches)} batch(es) \u2014 ${ai.provider} \xB7 ${ai.model}`);
5865
+ for (const l of est.perLocale) {
5866
+ console.log(` ${l.locale.padEnd(8)} ${fmt(l.requests).padStart(7)} req ${fmt(l.batches).padStart(5)} batch(es) ~${fmt(l.inputTokens)} in / ~${fmt(l.outputTokens)} out tokens`);
5867
+ }
5868
+ console.log(`Totals: ~${fmt(est.inputTokens)} input / ~${fmt(est.outputTokens)} output tokens`);
5869
+ if (est.pricing) {
5870
+ const cost = est.estimatedCost;
5871
+ console.log(`Estimated cost: ~$${cost >= 0.1 ? cost.toFixed(2) : cost.toFixed(4)} (\xB120%, ${est.pricing.source} pricing $${est.pricing.inputPerMTok}/$${est.pricing.outputPerMTok} per MTok)`);
5872
+ } else {
5873
+ console.log("No pricing known for this model \u2014 set inputPricePerMTok/outputPricePerMTok in your AI settings for a dollar estimate.");
5874
+ }
5875
+ return;
5876
+ }
5406
5877
  const reqs = selectRequests(state, {
5407
5878
  // Default to translating only empty values; --all forces a full re-translate
5408
5879
  // (overwriting existing translations). --only missing stays as a no-op alias.
@@ -5472,7 +5943,7 @@ function printReport(report, format, rawText) {
5472
5943
  }
5473
5944
  async function runLintCmd(args) {
5474
5945
  const state = loadState(args.statePath);
5475
- const rawText = existsSync11(args.statePath) ? readFileSync15(args.statePath, "utf8") : "";
5946
+ const rawText = existsSync12(args.statePath) ? readFileSync15(args.statePath, "utf8") : "";
5476
5947
  const report = await runLint(state, { locales: args.locales, ruleIds: args.ruleIds });
5477
5948
  printReport(report, args.format, rawText);
5478
5949
  const tooManyWarnings = args.maxWarnings != null && report.counts.warn > args.maxWarnings;
@@ -5492,8 +5963,8 @@ async function runCheck(args) {
5492
5963
  process.exitCode = 1;
5493
5964
  return;
5494
5965
  }
5495
- const rawText = existsSync11(args.statePath) ? readFileSync15(args.statePath, "utf8") : "";
5496
- const root = dirname4(resolve11(args.statePath));
5966
+ const rawText = existsSync12(args.statePath) ? readFileSync15(args.statePath, "utf8") : "";
5967
+ const root = dirname5(resolve11(args.statePath));
5497
5968
  const lint = await runLint(state, {});
5498
5969
  const findings = sortFindings([...lint.findings, ...checkOutputs(state, root)]);
5499
5970
  const counts = countSeverities(findings);
@@ -5502,10 +5973,10 @@ async function runCheck(args) {
5502
5973
  if (!report.ok) process.exitCode = 1;
5503
5974
  }
5504
5975
  async function runImportCmd(args) {
5505
- const { runImport: runImport2 } = await Promise.resolve().then(() => (init_run2(), run_exports));
5506
- const projectRoot = args.importSource ? resolve11(args.importSource) : dirname4(resolve11(args.statePath));
5976
+ const { runImport: runImport2 } = await Promise.resolve().then(() => (init_run3(), run_exports));
5977
+ const projectRoot = args.importSource ? resolve11(args.importSource) : dirname5(resolve11(args.statePath));
5507
5978
  const out = resolve11(projectRoot, "glotfile.json");
5508
- if (existsSync11(out) && !args.importForce) {
5979
+ if (existsSync12(out) && !args.importForce) {
5509
5980
  console.error(`${out} already exists; pass --force to overwrite`);
5510
5981
  process.exitCode = 1;
5511
5982
  return;
@@ -5530,7 +6001,7 @@ async function runImportCmd(args) {
5530
6001
  }
5531
6002
  async function runBuildContext(args) {
5532
6003
  const state = loadState(args.statePath);
5533
- const projectRoot = dirname4(resolve11(args.statePath));
6004
+ const projectRoot = dirname5(resolve11(args.statePath));
5534
6005
  const cache2 = loadUsageCache(projectRoot);
5535
6006
  if (!cache2) {
5536
6007
  console.error("No usage index found. Run 'glotfile scan' first.");
@@ -5601,7 +6072,7 @@ async function runBuildContext(args) {
5601
6072
  }
5602
6073
  async function runScanCmd(args) {
5603
6074
  const state = loadState(args.statePath);
5604
- const projectRoot = dirname4(resolve11(args.statePath));
6075
+ const projectRoot = dirname5(resolve11(args.statePath));
5605
6076
  const existing = loadUsageCache(projectRoot);
5606
6077
  const result = runScan(projectRoot, state.config.scan ?? {}, existing);
5607
6078
  const fileCount2 = Object.keys(result.files).length;
@@ -5620,7 +6091,7 @@ async function runPrune(args) {
5620
6091
  for (const k of findEmptySourceKeys(state)) toRemove.add(k);
5621
6092
  }
5622
6093
  if (args.unused) {
5623
- const projectRoot = dirname4(resolve11(args.statePath));
6094
+ const projectRoot = dirname5(resolve11(args.statePath));
5624
6095
  const cache2 = runScan(projectRoot, state.config.scan ?? {}, loadUsageCache(projectRoot));
5625
6096
  const used = new Set(computeUsedKeys(state, cache2));
5626
6097
  for (const k of Object.keys(state.keys)) {
@@ -5673,9 +6144,10 @@ var COMMAND_HELP = {
5673
6144
  },
5674
6145
  translate: {
5675
6146
  summary: "AI-translate missing strings into your target locales (writes back to the state file).",
5676
- usage: "glotfile translate [--all] [--locale <list>] [--key <glob>]",
6147
+ usage: "glotfile translate [--all] [--estimate] [--locale <list>] [--key <glob>]",
5677
6148
  options: [
5678
6149
  ["--all", "Re-translate every string, not just empty values"],
6150
+ ["--estimate", "Print batches, tokens and estimated cost without translating"],
5679
6151
  ["--locale <list>", "Comma-separated target locales (alias: --locales)"],
5680
6152
  ["--key <glob>", "Only keys matching this glob"]
5681
6153
  ]