glotfile 0.4.1 → 0.4.3

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];
@@ -1457,6 +1457,114 @@ var init_vue_i18n_json = __esm({
1457
1457
  }
1458
1458
  });
1459
1459
 
1460
+ // src/server/adapters/angular-xliff.ts
1461
+ function xmlEscape2(s) {
1462
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1463
+ }
1464
+ function renderInterpolations(text, ids) {
1465
+ let out = "";
1466
+ let last = 0;
1467
+ for (const m of text.matchAll(/\{(\w+)\}/g)) {
1468
+ const name = m[1];
1469
+ let id = ids.get(name);
1470
+ if (id === void 0) {
1471
+ id = ids.size === 0 ? "INTERPOLATION" : `INTERPOLATION_${ids.size}`;
1472
+ ids.set(name, id);
1473
+ }
1474
+ out += xmlEscape2(text.slice(last, m.index)) + `<x id="${id}" equiv-text="{{${name}}}"/>`;
1475
+ last = m.index + m[0].length;
1476
+ }
1477
+ return out + xmlEscape2(text.slice(last));
1478
+ }
1479
+ function renderPluralIcu(forms, ids) {
1480
+ const cats = [
1481
+ ...Object.keys(forms).filter((c) => c.startsWith("=")),
1482
+ ...PLURAL_CATEGORIES.filter((c) => forms[c] !== void 0)
1483
+ ];
1484
+ const branches = cats.map((cat) => `${cat} {${renderInterpolations(forms[cat] ?? "", ids)}}`);
1485
+ return `{VAR_PLURAL, plural, ${branches.join(" ")}}`;
1486
+ }
1487
+ function renderEmbeddedIcu(value) {
1488
+ const renamed = value.replace(
1489
+ /\{\s*\w+\s*,\s*(plural|select|selectordinal)\s*,/g,
1490
+ (_, type) => `{${type === "plural" ? "VAR_PLURAL" : "VAR_SELECT"}, ${type},`
1491
+ );
1492
+ return xmlEscape2(renamed);
1493
+ }
1494
+ function renderScalar(value, ids) {
1495
+ return isIcuPluralOrSelect(value) ? renderEmbeddedIcu(value) : renderInterpolations(value, ids);
1496
+ }
1497
+ var DEFAULT_LOCALE_CASE7, angularXliff;
1498
+ var init_angular_xliff = __esm({
1499
+ "src/server/adapters/angular-xliff.ts"() {
1500
+ "use strict";
1501
+ init_adapters();
1502
+ init_options();
1503
+ init_placeholders();
1504
+ init_schema();
1505
+ DEFAULT_LOCALE_CASE7 = "bcp47-hyphen";
1506
+ angularXliff = {
1507
+ name: "angular-xliff",
1508
+ capabilities: {
1509
+ plural: "native",
1510
+ select: "native",
1511
+ nesting: "flat",
1512
+ metadata: true,
1513
+ placeholderStyle: "icu",
1514
+ fileGrouping: "per-locale"
1515
+ },
1516
+ defaultLocaleCase: DEFAULT_LOCALE_CASE7,
1517
+ export(state, output) {
1518
+ const files = [];
1519
+ const warnings = [];
1520
+ warnings.push(...localeCollisionWarnings(output, state.config.locales, DEFAULT_LOCALE_CASE7));
1521
+ const sourceLocale = state.config.sourceLocale;
1522
+ const sourceToken = resolveLocaleToken(output, sourceLocale, DEFAULT_LOCALE_CASE7);
1523
+ const emptyAs = resolveEmptyAs(output, "source");
1524
+ const keys = Object.keys(state.keys).sort();
1525
+ for (const locale of state.config.locales) {
1526
+ const token = resolveLocaleToken(output, locale, DEFAULT_LOCALE_CASE7);
1527
+ const units = [];
1528
+ for (const key of keys) {
1529
+ const entry = state.keys[key];
1530
+ let source;
1531
+ let target;
1532
+ const ids = /* @__PURE__ */ new Map();
1533
+ if (entry.plural) {
1534
+ const targetForms = resolveForms(entry, locale, sourceLocale, emptyAs);
1535
+ if (targetForms === null) continue;
1536
+ source = renderPluralIcu(entry.values[sourceLocale]?.forms ?? {}, ids);
1537
+ target = renderPluralIcu(targetForms, ids);
1538
+ } else {
1539
+ const targetValue = resolveScalar(entry, locale, sourceLocale, emptyAs);
1540
+ if (targetValue === null) continue;
1541
+ source = renderScalar(entry.values[sourceLocale]?.value ?? "", ids);
1542
+ target = renderScalar(targetValue, ids);
1543
+ }
1544
+ units.push(` <trans-unit id="${xmlEscape2(key)}" datatype="html">`);
1545
+ units.push(` <source>${source}</source>`);
1546
+ units.push(` <target>${target}</target>`);
1547
+ if (entry.description) {
1548
+ units.push(` <note priority="1" from="description">${xmlEscape2(entry.description)}</note>`);
1549
+ }
1550
+ units.push(` </trans-unit>`);
1551
+ }
1552
+ const contents = `<?xml version="1.0" encoding="UTF-8" ?>
1553
+ <xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
1554
+ <file source-language="${xmlEscape2(sourceToken)}" target-language="${xmlEscape2(token)}" datatype="plaintext" original="ng2.template">
1555
+ <body>
1556
+ ` + (units.length ? units.join("\n") + "\n" : "") + ` </body>
1557
+ </file>
1558
+ </xliff>
1559
+ `;
1560
+ files.push({ path: resolvePath(output.path, token), contents });
1561
+ }
1562
+ return { files, warnings };
1563
+ }
1564
+ };
1565
+ }
1566
+ });
1567
+
1460
1568
  // src/server/adapters/index.ts
1461
1569
  function resolvePath(template, locale, namespace = "") {
1462
1570
  return template.replaceAll("{locale}", locale).replaceAll("{namespace}", namespace);
@@ -1488,7 +1596,8 @@ function getRegistry() {
1488
1596
  [i18nextJson.name]: i18nextJson,
1489
1597
  [gettextPo.name]: gettextPo,
1490
1598
  [appleStringsdict.name]: appleStringsdict,
1491
- [vueI18nJson.name]: vueI18nJson
1599
+ [vueI18nJson.name]: vueI18nJson,
1600
+ [angularXliff.name]: angularXliff
1492
1601
  };
1493
1602
  }
1494
1603
  function getAdapter(name) {
@@ -1507,12 +1616,13 @@ var init_adapters = __esm({
1507
1616
  init_gettext_po();
1508
1617
  init_apple_stringsdict();
1509
1618
  init_vue_i18n_json();
1619
+ init_angular_xliff();
1510
1620
  }
1511
1621
  });
1512
1622
 
1513
1623
  // src/server/export-run.ts
1514
- import { readFileSync as readFileSync3 } from "fs";
1515
- import { resolve } from "path";
1624
+ import { existsSync as existsSync3, readFileSync as readFileSync3, readdirSync as readdirSync2, rmdirSync, statSync, unlinkSync } from "fs";
1625
+ import { dirname as dirname2, resolve, sep } from "path";
1516
1626
  function effectiveLocales(config) {
1517
1627
  const limit = config.exportLocales;
1518
1628
  if (!limit || limit.length === 0) return config.locales;
@@ -1523,14 +1633,81 @@ function narrowForExport(state) {
1523
1633
  if (locales.length === state.config.locales.length) return state;
1524
1634
  return { ...state, config: { ...state.config, locales } };
1525
1635
  }
1636
+ function escapeRegExp(s) {
1637
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1638
+ }
1639
+ function segmentRegExp(segment) {
1640
+ const pattern = escapeRegExp(segment).replaceAll("\\{locale\\}", "(?<locale>[A-Za-z0-9_-]+)").replaceAll("\\{namespace\\}", "[^/]*");
1641
+ return new RegExp(`^${pattern}$`);
1642
+ }
1643
+ function removeEmptyDirs(dir, stopAt) {
1644
+ let current = dir;
1645
+ while (current !== stopAt && current.startsWith(stopAt + sep)) {
1646
+ try {
1647
+ rmdirSync(current);
1648
+ } catch {
1649
+ return;
1650
+ }
1651
+ current = dirname2(current);
1652
+ }
1653
+ }
1654
+ function pruneStaleLocaleFiles(output, validTokens, projectRoot) {
1655
+ const segments = output.path.split("/").filter(Boolean);
1656
+ if (!segments.some((s) => s.includes("{locale}"))) return 0;
1657
+ const root = resolve(projectRoot);
1658
+ let deleted = 0;
1659
+ const stale = (token) => token !== void 0 && !validTokens.has(token) && LOCALE_TOKEN.test(token);
1660
+ const visit = (dir, index, locale) => {
1661
+ const segment = segments[index];
1662
+ const isLast = index === segments.length - 1;
1663
+ if (!segment.includes("{locale}") && !segment.includes("{namespace}")) {
1664
+ const next = resolve(dir, segment);
1665
+ if (isLast) {
1666
+ if (stale(locale) && existsSync3(next) && statSync(next).isFile()) {
1667
+ unlinkSync(next);
1668
+ deleted++;
1669
+ removeEmptyDirs(dir, root);
1670
+ }
1671
+ return;
1672
+ }
1673
+ visit(next, index + 1, locale);
1674
+ return;
1675
+ }
1676
+ const re = segmentRegExp(segment);
1677
+ let entries;
1678
+ try {
1679
+ entries = readdirSync2(dir, { withFileTypes: true });
1680
+ } catch {
1681
+ return;
1682
+ }
1683
+ for (const entry of entries) {
1684
+ const m = entry.name.match(re);
1685
+ if (!m) continue;
1686
+ const token = m.groups?.locale ?? locale;
1687
+ if (isLast) {
1688
+ if (!entry.isFile() || !stale(token)) continue;
1689
+ unlinkSync(resolve(dir, entry.name));
1690
+ deleted++;
1691
+ removeEmptyDirs(dir, root);
1692
+ } else if (entry.isDirectory()) {
1693
+ visit(resolve(dir, entry.name), index + 1, token);
1694
+ }
1695
+ }
1696
+ };
1697
+ visit(root, 0, void 0);
1698
+ return deleted;
1699
+ }
1526
1700
  function exportToDisk(state, projectRoot, opts) {
1701
+ const allLocales = state.config.locales;
1527
1702
  state = narrowForExport(state);
1528
1703
  const outputs = opts?.adapter ? state.config.outputs.filter((o) => o.adapter === opts.adapter) : state.config.outputs;
1529
1704
  const warnings = [];
1530
1705
  let written = 0;
1531
1706
  let skipped = 0;
1707
+ let deleted = 0;
1532
1708
  for (const output of outputs) {
1533
- const result = getAdapter(output.adapter).export(state, output);
1709
+ const adapter = getAdapter(output.adapter);
1710
+ const result = adapter.export(state, output);
1534
1711
  warnings.push(...result.warnings);
1535
1712
  const writtenPaths = /* @__PURE__ */ new Set();
1536
1713
  for (const f of result.files) {
@@ -1552,14 +1729,19 @@ function exportToDisk(state, projectRoot, opts) {
1552
1729
  writeFileAtomic(abs, f.contents);
1553
1730
  written++;
1554
1731
  }
1732
+ const validTokens = new Set(allLocales.map((l) => resolveLocaleToken(output, l, adapter.defaultLocaleCase)));
1733
+ deleted += pruneStaleLocaleFiles(output, validTokens, projectRoot);
1555
1734
  }
1556
- return { written, skipped, warnings };
1735
+ return { written, skipped, deleted, warnings };
1557
1736
  }
1737
+ var LOCALE_TOKEN;
1558
1738
  var init_export_run = __esm({
1559
1739
  "src/server/export-run.ts"() {
1560
1740
  "use strict";
1561
1741
  init_adapters();
1742
+ init_options();
1562
1743
  init_atomic_write();
1744
+ LOCALE_TOKEN = /^[a-z]{2,3}([_-][a-z0-9]+)*$/i;
1563
1745
  }
1564
1746
  });
1565
1747
 
@@ -1590,6 +1772,7 @@ function buildSystemPrompt(hasPluralItems) {
1590
1772
  function buildBatchPrompt(reqs) {
1591
1773
  const targetLocale = reqs[0]?.targetLocale ?? "";
1592
1774
  const hasPluralItems = reqs.some((r) => r.plural !== void 0);
1775
+ const hasGlossaryItems = reqs.some((r) => r.glossary !== void 0 && r.glossary.length > 0);
1593
1776
  const items = reqs.map((r) => {
1594
1777
  const base = {
1595
1778
  id: r.id,
@@ -1599,7 +1782,7 @@ function buildBatchPrompt(reqs) {
1599
1782
  // Wrap in braces so the model sees "{site}" not "site" — makes the visual
1600
1783
  // connection to the source string obvious and reduces rename errors.
1601
1784
  placeholders: r.placeholders.map((p) => `{${p}}`),
1602
- glossary: r.glossary ?? [],
1785
+ ...r.glossary?.length ? { glossary: r.glossary } : {},
1603
1786
  hasScreenshot: r.image !== void 0
1604
1787
  };
1605
1788
  if (r.plural) {
@@ -1609,7 +1792,7 @@ function buildBatchPrompt(reqs) {
1609
1792
  });
1610
1793
  const returnFormat = hasPluralItems ? 'For a scalar item (has `source`) return {"id","translation"}; for a plural item (has `plural`) return {"id","forms"} with one string per required category.' : 'Return {"id","translation"} for each item.';
1611
1794
  return `Translate every item below into the target locale: ${targetLocale}. All items share this one target language.
1612
- Glossary entries are constraints you MUST apply. Items with hasScreenshot:true have a screenshot supplied as a separate image block above; use it for context. ${returnFormat} Return JSON {"items":[\u2026]}.
1795
+ ` + (hasGlossaryItems ? "Glossary entries are constraints you MUST apply. " : "") + `Items with hasScreenshot:true have a screenshot supplied as a separate image block above; use it for context. ${returnFormat} Return JSON {"items":[\u2026]}.
1613
1796
  ` + JSON.stringify(items, null, 2);
1614
1797
  }
1615
1798
  function buildTranslateGemmaSystemPrompt(sourceLocale, targetLocale) {
@@ -2231,13 +2414,13 @@ var init_ai = __esm({
2231
2414
  });
2232
2415
 
2233
2416
  // src/server/glotfile-dir.ts
2234
- import { existsSync as existsSync3, mkdirSync as mkdirSync3, writeFileSync as writeFileSync2 } from "fs";
2417
+ import { existsSync as existsSync4, mkdirSync as mkdirSync3, writeFileSync as writeFileSync2 } from "fs";
2235
2418
  import { resolve as resolve2 } from "path";
2236
2419
  function ensureGlotfileDir(projectRoot) {
2237
2420
  const dir = resolve2(projectRoot, ".glotfile");
2238
2421
  mkdirSync3(dir, { recursive: true });
2239
2422
  const ignore = resolve2(dir, ".gitignore");
2240
- if (!existsSync3(ignore)) {
2423
+ if (!existsSync4(ignore)) {
2241
2424
  try {
2242
2425
  writeFileSync2(ignore, "*\n");
2243
2426
  } catch {
@@ -2348,7 +2531,7 @@ var init_glob = __esm({
2348
2531
  });
2349
2532
 
2350
2533
  // src/server/ai/run.ts
2351
- import { readFileSync as readFileSync5, existsSync as existsSync4 } from "fs";
2534
+ import { readFileSync as readFileSync5, existsSync as existsSync5 } from "fs";
2352
2535
  import { resolve as resolve4, extname } from "path";
2353
2536
  function selectRequests(state, opts) {
2354
2537
  const targets = (opts.locales ?? state.config.locales).filter((l) => l !== state.config.sourceLocale);
@@ -2431,7 +2614,7 @@ function attachScreenshots(reqs, state, projectRoot) {
2431
2614
  if (!mediaType) continue;
2432
2615
  if (!cache2.has(screenshot)) {
2433
2616
  const abs = resolve4(projectRoot, screenshot);
2434
- if (!existsSync4(abs)) {
2617
+ if (!existsSync5(abs)) {
2435
2618
  cache2.set(screenshot, null);
2436
2619
  } else {
2437
2620
  const buf = readFileSync5(abs);
@@ -2529,7 +2712,7 @@ var init_run = __esm({
2529
2712
  });
2530
2713
 
2531
2714
  // src/server/log.ts
2532
- import { appendFileSync, readFileSync as readFileSync6, existsSync as existsSync5 } from "fs";
2715
+ import { appendFileSync, readFileSync as readFileSync6, existsSync as existsSync6 } from "fs";
2533
2716
  import { resolve as resolve5 } from "path";
2534
2717
  function logPath(projectRoot) {
2535
2718
  return resolve5(projectRoot, ".glotfile", "log.jsonl");
@@ -2540,7 +2723,7 @@ function appendLog(projectRoot, entry) {
2540
2723
  }
2541
2724
  function readLog(projectRoot, limit = 100) {
2542
2725
  const path = logPath(projectRoot);
2543
- if (!existsSync5(path)) return [];
2726
+ if (!existsSync6(path)) return [];
2544
2727
  const lines = readFileSync6(path, "utf8").split("\n").filter((l) => l.trim() !== "");
2545
2728
  const entries = lines.map((l) => JSON.parse(l));
2546
2729
  return entries.reverse().slice(0, limit);
@@ -2553,11 +2736,11 @@ var init_log = __esm({
2553
2736
  });
2554
2737
 
2555
2738
  // src/server/scan.ts
2556
- import { existsSync as existsSync6, readFileSync as readFileSync7 } from "fs";
2739
+ import { existsSync as existsSync7, readFileSync as readFileSync7 } from "fs";
2557
2740
  import { resolve as resolve6 } from "path";
2558
2741
  function loadUsageCache(projectRoot) {
2559
2742
  const path = resolve6(projectRoot, ".glotfile", "usage.json");
2560
- if (!existsSync6(path)) return null;
2743
+ if (!existsSync7(path)) return null;
2561
2744
  try {
2562
2745
  return JSON.parse(readFileSync7(path, "utf8"));
2563
2746
  } catch {
@@ -2573,8 +2756,10 @@ function findMissing(state) {
2573
2756
  const targets = state.config.locales.filter((l) => l !== state.config.sourceLocale).sort();
2574
2757
  const out = [];
2575
2758
  for (const key of Object.keys(state.keys).sort()) {
2759
+ const entry = state.keys[key];
2760
+ if (entry.skipTranslate) continue;
2576
2761
  for (const locale of targets) {
2577
- const v = state.keys[key].values[locale]?.value;
2762
+ const v = entry.plural ? entry.values[locale]?.forms?.other?.trim() : entry.values[locale]?.value;
2578
2763
  if (!v) out.push({ key, locale });
2579
2764
  }
2580
2765
  }
@@ -2600,7 +2785,7 @@ var init_scan = __esm({
2600
2785
  });
2601
2786
 
2602
2787
  // src/server/scanner.ts
2603
- import { readdirSync as readdirSync2, statSync, readFileSync as readFileSync8 } from "fs";
2788
+ import { readdirSync as readdirSync3, statSync as statSync2, readFileSync as readFileSync8 } from "fs";
2604
2789
  import { join as join3, extname as extname2, relative } from "path";
2605
2790
  function scannerForExt(ext) {
2606
2791
  return EXT_SCANNER[ext] ?? null;
@@ -2721,7 +2906,7 @@ function isIncluded(relPath, includePatterns) {
2721
2906
  function* walkFiles(dir, root, exclude) {
2722
2907
  let entries;
2723
2908
  try {
2724
- entries = readdirSync2(dir);
2909
+ entries = readdirSync3(dir);
2725
2910
  } catch {
2726
2911
  return;
2727
2912
  }
@@ -2731,7 +2916,7 @@ function* walkFiles(dir, root, exclude) {
2731
2916
  const rel = relative(root, abs);
2732
2917
  let st;
2733
2918
  try {
2734
- st = statSync(abs);
2919
+ st = statSync2(abs);
2735
2920
  } catch {
2736
2921
  continue;
2737
2922
  }
@@ -2760,7 +2945,7 @@ function runScan(projectRoot, opts, existing) {
2760
2945
  const abs = join3(projectRoot, relPath);
2761
2946
  let st;
2762
2947
  try {
2763
- st = statSync(abs);
2948
+ st = statSync2(abs);
2764
2949
  } catch {
2765
2950
  continue;
2766
2951
  }
@@ -2878,7 +3063,7 @@ var init_scanner = __esm({
2878
3063
  });
2879
3064
 
2880
3065
  // src/server/ai/context.ts
2881
- import { existsSync as existsSync7, readFileSync as readFileSync9 } from "fs";
3066
+ import { existsSync as existsSync8, readFileSync as readFileSync9 } from "fs";
2882
3067
  import { resolve as resolve7 } from "path";
2883
3068
  function globToRegExp2(glob) {
2884
3069
  const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
@@ -2893,7 +3078,7 @@ function extractSnippets(refs, projectRoot, fileCache) {
2893
3078
  for (const ref of selected) {
2894
3079
  const absPath = resolve7(projectRoot, ref.file);
2895
3080
  if (!fileCache.has(ref.file)) {
2896
- if (!existsSync7(absPath)) continue;
3081
+ if (!existsSync8(absPath)) continue;
2897
3082
  const content = readFileSync9(absPath, "utf8");
2898
3083
  fileCache.set(ref.file, content.split("\n"));
2899
3084
  }
@@ -3038,22 +3223,364 @@ var init_context = __esm({
3038
3223
  }
3039
3224
  });
3040
3225
 
3226
+ // src/server/lint/spelling.ts
3227
+ function tokenize(text) {
3228
+ return text.match(new RegExp("\\p{L}[\\p{L}'']*", "gu")) ?? [];
3229
+ }
3230
+ function buildAllowWords(glossary, dictionary2 = []) {
3231
+ const set = /* @__PURE__ */ new Set();
3232
+ const add = (s) => {
3233
+ for (const w of tokenize(s)) set.add(w.toLowerCase());
3234
+ };
3235
+ for (const g of glossary) add(g.term);
3236
+ for (const w of dictionary2) add(w);
3237
+ return set;
3238
+ }
3239
+ var spellingRule, defaultLoader;
3240
+ var init_spelling = __esm({
3241
+ "src/server/lint/spelling.ts"() {
3242
+ "use strict";
3243
+ init_placeholders();
3244
+ spellingRule = {
3245
+ id: "spelling",
3246
+ run(state, ctx) {
3247
+ const out = [];
3248
+ for (const key of Object.keys(state.keys)) {
3249
+ const entry = state.keys[key];
3250
+ for (const locale of ctx.targetLocales) {
3251
+ const speller = ctx.spellers.get(locale);
3252
+ if (!speller) continue;
3253
+ const value = entry.values[locale]?.value;
3254
+ if (!value) continue;
3255
+ const placeholders = new Set(extractPlaceholders(value).map((p) => p.toLowerCase()));
3256
+ for (const word of tokenize(value)) {
3257
+ const lower = word.toLowerCase();
3258
+ if (placeholders.has(lower) || ctx.allowWords.has(lower)) continue;
3259
+ if (!speller.correct(word)) {
3260
+ out.push({ ruleId: "spelling", key, locale, message: `possible misspelling: "${word}"` });
3261
+ }
3262
+ }
3263
+ }
3264
+ }
3265
+ return out;
3266
+ }
3267
+ };
3268
+ defaultLoader = async (dictId) => {
3269
+ try {
3270
+ const nspellMod = await import("nspell");
3271
+ const nspell2 = nspellMod.default ?? nspellMod;
3272
+ const dictMod = await import(`dictionary-${dictId.toLowerCase()}`);
3273
+ const dictExport = dictMod.default ?? dictMod;
3274
+ const dict = typeof dictExport === "function" ? await dictExport() : dictExport;
3275
+ return nspell2(dict);
3276
+ } catch {
3277
+ return null;
3278
+ }
3279
+ };
3280
+ }
3281
+ });
3282
+
3283
+ // src/server/lint/rules.ts
3284
+ var emptySourceRule, emptyTranslationRule, identicalToSourceRule, whitespaceRule, placeholderMismatchRule, icuMismatchRule, maxLengthRule, glossaryViolationRule, ALL_RULES;
3285
+ var init_rules = __esm({
3286
+ "src/server/lint/rules.ts"() {
3287
+ "use strict";
3288
+ init_scan();
3289
+ init_placeholders();
3290
+ init_run();
3291
+ init_spelling();
3292
+ emptySourceRule = {
3293
+ id: "empty-source",
3294
+ run(state, ctx) {
3295
+ const out = [];
3296
+ for (const key of Object.keys(state.keys)) {
3297
+ const entry = state.keys[key];
3298
+ const v = entry.plural ? entry.values[ctx.sourceLocale]?.forms?.other : entry.values[ctx.sourceLocale]?.value;
3299
+ if (!v || !v.trim()) out.push({ ruleId: "empty-source", key, locale: "", message: "source value is empty" });
3300
+ }
3301
+ return out;
3302
+ }
3303
+ };
3304
+ emptyTranslationRule = {
3305
+ id: "empty-translation",
3306
+ run(state, ctx) {
3307
+ const out = [];
3308
+ for (const m of findMissing(state)) {
3309
+ out.push({ ruleId: "empty-translation", key: m.key, locale: m.locale, message: "translation is empty or missing" });
3310
+ }
3311
+ for (const key of Object.keys(state.keys)) {
3312
+ for (const locale of ctx.targetLocales) {
3313
+ const v = state.keys[key].values[locale]?.value;
3314
+ if (v && !v.trim()) out.push({ ruleId: "empty-translation", key, locale, message: "translation is whitespace-only" });
3315
+ }
3316
+ }
3317
+ return out;
3318
+ }
3319
+ };
3320
+ identicalToSourceRule = {
3321
+ id: "identical-to-source",
3322
+ run(state, ctx) {
3323
+ const out = [];
3324
+ for (const key of Object.keys(state.keys)) {
3325
+ const entry = state.keys[key];
3326
+ if (entry.skipTranslate) continue;
3327
+ const src = entry.values[ctx.sourceLocale]?.value;
3328
+ if (!src) continue;
3329
+ for (const locale of ctx.targetLocales) {
3330
+ const v = entry.values[locale]?.value;
3331
+ if (v && v === src) out.push({ ruleId: "identical-to-source", key, locale, message: "translation is identical to the source" });
3332
+ }
3333
+ }
3334
+ return out;
3335
+ }
3336
+ };
3337
+ whitespaceRule = {
3338
+ id: "whitespace",
3339
+ run(state, ctx) {
3340
+ const out = [];
3341
+ for (const key of Object.keys(state.keys)) {
3342
+ const entry = state.keys[key];
3343
+ const src = entry.values[ctx.sourceLocale]?.value ?? "";
3344
+ const srcEdge = src !== src.trim();
3345
+ for (const locale of ctx.targetLocales) {
3346
+ const v = entry.values[locale]?.value;
3347
+ if (!v) continue;
3348
+ if (v !== v.trim() !== srcEdge) {
3349
+ out.push({ ruleId: "whitespace", key, locale, message: "leading/trailing whitespace differs from the source" });
3350
+ }
3351
+ }
3352
+ }
3353
+ return out;
3354
+ }
3355
+ };
3356
+ placeholderMismatchRule = {
3357
+ id: "placeholder-mismatch",
3358
+ run(state, ctx) {
3359
+ const out = [];
3360
+ for (const key of Object.keys(state.keys)) {
3361
+ const entry = state.keys[key];
3362
+ if (entry.plural) {
3363
+ const srcForm = entry.values[ctx.sourceLocale]?.forms?.other;
3364
+ if (!srcForm) continue;
3365
+ for (const locale of ctx.targetLocales) {
3366
+ const forms = entry.values[locale]?.forms;
3367
+ if (!forms) continue;
3368
+ const bad = Object.entries(forms).some(
3369
+ ([cat, form]) => form && !pluralFormPlaceholdersMatch(cat, srcForm, form)
3370
+ );
3371
+ if (bad) {
3372
+ out.push({ ruleId: "placeholder-mismatch", key, locale, message: "placeholders differ from the source" });
3373
+ }
3374
+ }
3375
+ continue;
3376
+ }
3377
+ const src = entry.values[ctx.sourceLocale]?.value;
3378
+ if (!src) continue;
3379
+ for (const locale of ctx.targetLocales) {
3380
+ const v = entry.values[locale]?.value;
3381
+ if (!v) continue;
3382
+ if (!placeholdersMatch(src, v)) {
3383
+ out.push({ ruleId: "placeholder-mismatch", key, locale, message: "placeholders differ from the source" });
3384
+ }
3385
+ }
3386
+ }
3387
+ return out;
3388
+ }
3389
+ };
3390
+ icuMismatchRule = {
3391
+ id: "icu-mismatch",
3392
+ run(state, ctx) {
3393
+ const out = [];
3394
+ for (const key of Object.keys(state.keys)) {
3395
+ const entry = state.keys[key];
3396
+ const src = entry.values[ctx.sourceLocale]?.value;
3397
+ if (!src) continue;
3398
+ const srcIcu = isIcuPluralOrSelect(src);
3399
+ for (const locale of ctx.targetLocales) {
3400
+ const v = entry.values[locale]?.value;
3401
+ if (!v) continue;
3402
+ if (isIcuPluralOrSelect(v) !== srcIcu) {
3403
+ out.push({
3404
+ ruleId: "icu-mismatch",
3405
+ key,
3406
+ locale,
3407
+ message: srcIcu ? "source is an ICU plural/select but the translation is not" : "translation is an ICU plural/select but the source is not"
3408
+ });
3409
+ }
3410
+ }
3411
+ }
3412
+ return out;
3413
+ }
3414
+ };
3415
+ maxLengthRule = {
3416
+ id: "max-length",
3417
+ run(state, ctx) {
3418
+ const out = [];
3419
+ for (const key of Object.keys(state.keys)) {
3420
+ const entry = state.keys[key];
3421
+ const max = entry.maxLength;
3422
+ if (max == null) continue;
3423
+ for (const locale of ctx.targetLocales) {
3424
+ const v = entry.values[locale]?.value;
3425
+ if (v && v.length > max) {
3426
+ out.push({ ruleId: "max-length", key, locale, message: `length ${v.length} exceeds maxLength ${max}` });
3427
+ }
3428
+ }
3429
+ }
3430
+ return out;
3431
+ }
3432
+ };
3433
+ glossaryViolationRule = {
3434
+ id: "glossary-violation",
3435
+ run(state, ctx) {
3436
+ const out = [];
3437
+ for (const key of Object.keys(state.keys)) {
3438
+ const entry = state.keys[key];
3439
+ const src = entry.values[ctx.sourceLocale]?.value;
3440
+ if (!src) continue;
3441
+ for (const locale of ctx.targetLocales) {
3442
+ const v = entry.values[locale]?.value;
3443
+ if (!v) continue;
3444
+ for (const hint of relevantGlossary(src, locale, ctx.glossary)) {
3445
+ if (hint.doNotTranslate && !v.includes(hint.term)) {
3446
+ out.push({ ruleId: "glossary-violation", key, locale, message: `do-not-translate term "${hint.term}" is missing or altered` });
3447
+ }
3448
+ if (hint.forced && !v.includes(hint.forced)) {
3449
+ out.push({ ruleId: "glossary-violation", key, locale, message: `expected glossary translation "${hint.forced}" for "${hint.term}"` });
3450
+ }
3451
+ }
3452
+ }
3453
+ }
3454
+ return out;
3455
+ }
3456
+ };
3457
+ ALL_RULES = [
3458
+ emptySourceRule,
3459
+ emptyTranslationRule,
3460
+ placeholderMismatchRule,
3461
+ icuMismatchRule,
3462
+ glossaryViolationRule,
3463
+ maxLengthRule,
3464
+ identicalToSourceRule,
3465
+ whitespaceRule,
3466
+ spellingRule
3467
+ ];
3468
+ }
3469
+ });
3470
+
3471
+ // src/server/lint/run.ts
3472
+ function resolveSeverity(id, config) {
3473
+ return config.rules?.[id] ?? DEFAULT_SEVERITY[id];
3474
+ }
3475
+ function sortFindings(findings) {
3476
+ return [...findings].sort(
3477
+ (a, b) => a.key.localeCompare(b.key) || a.locale.localeCompare(b.locale) || a.ruleId.localeCompare(b.ruleId)
3478
+ );
3479
+ }
3480
+ function countSeverities(findings) {
3481
+ let error = 0, warn = 0;
3482
+ for (const f of findings) f.severity === "error" ? error++ : warn++;
3483
+ return { error, warn };
3484
+ }
3485
+ async function loadSpellers(locales, config, load, warn) {
3486
+ const map = /* @__PURE__ */ new Map();
3487
+ for (const locale of locales) {
3488
+ const dictId = config.spelling?.locales?.[locale] ?? locale;
3489
+ const speller = await load(dictId);
3490
+ if (speller) map.set(locale, speller);
3491
+ else warn(`no dictionary for "${locale}", skipping spelling`);
3492
+ }
3493
+ return map;
3494
+ }
3495
+ async function runLint(state, options = {}) {
3496
+ const config = state.config.lint ?? {};
3497
+ const rules = options.rules ?? ALL_RULES;
3498
+ const warn = options.warn ?? ((m) => console.warn(m));
3499
+ const load = options.loadSpeller ?? defaultLoader;
3500
+ const targetLocales = state.config.locales.filter((l) => l !== state.config.sourceLocale);
3501
+ const isActive = (rule) => {
3502
+ if (options.ruleIds && !options.ruleIds.includes(rule.id)) return false;
3503
+ return resolveSeverity(rule.id, config) !== "off";
3504
+ };
3505
+ const active = rules.filter(isActive);
3506
+ const spellingOn = active.some((r) => r.id === "spelling");
3507
+ const spellers = spellingOn ? await loadSpellers(targetLocales, config, load, warn) : /* @__PURE__ */ new Map();
3508
+ const allowWords = spellingOn ? buildAllowWords(state.glossary, config.dictionary) : /* @__PURE__ */ new Set();
3509
+ const ctx = {
3510
+ config,
3511
+ sourceLocale: state.config.sourceLocale,
3512
+ targetLocales,
3513
+ glossary: state.glossary,
3514
+ spellers,
3515
+ allowWords
3516
+ };
3517
+ const ignoreRes = (config.ignore ?? []).map(globToRegExp);
3518
+ const localeFilter = options.locales ? new Set(options.locales) : null;
3519
+ const findings = [];
3520
+ for (const rule of active) {
3521
+ const severity = resolveSeverity(rule.id, config);
3522
+ for (const raw of rule.run(state, ctx)) {
3523
+ if (ignoreRes.some((re) => re.test(raw.key))) continue;
3524
+ if (localeFilter && raw.locale !== "" && !localeFilter.has(raw.locale)) continue;
3525
+ findings.push({ ...raw, severity });
3526
+ }
3527
+ }
3528
+ const sorted = sortFindings(findings);
3529
+ const counts = countSeverities(sorted);
3530
+ return { findings: sorted, counts, ok: counts.error === 0 };
3531
+ }
3532
+ var init_run2 = __esm({
3533
+ "src/server/lint/run.ts"() {
3534
+ "use strict";
3535
+ init_glob();
3536
+ init_registry();
3537
+ init_rules();
3538
+ init_spelling();
3539
+ }
3540
+ });
3541
+
3542
+ // src/server/lint/outputs.ts
3543
+ import { readFileSync as readFileSync10, existsSync as existsSync9 } from "fs";
3544
+ import { resolve as resolve8 } from "path";
3545
+ function checkOutputs(state, root) {
3546
+ const out = [];
3547
+ for (const output of state.config.outputs) {
3548
+ const result = getAdapter(output.adapter).export(state, output);
3549
+ for (const file of result.files) {
3550
+ const abs = resolve8(root, file.path);
3551
+ const current = existsSync9(abs) ? readFileSync10(abs, "utf8") : null;
3552
+ if (current === null) {
3553
+ out.push({ ruleId: "output-stale", key: file.path, locale: "", severity: "error", message: "output file is missing; run `glotfile export`" });
3554
+ } else if (current !== file.contents) {
3555
+ out.push({ ruleId: "output-stale", key: file.path, locale: "", severity: "error", message: "output file is out of date; run `glotfile export`" });
3556
+ }
3557
+ }
3558
+ }
3559
+ return out;
3560
+ }
3561
+ var init_outputs = __esm({
3562
+ "src/server/lint/outputs.ts"() {
3563
+ "use strict";
3564
+ init_adapters();
3565
+ }
3566
+ });
3567
+
3041
3568
  // src/server/import/detect.ts
3042
- import { existsSync as existsSync9, readdirSync as readdirSync3, statSync as statSync2 } from "fs";
3569
+ import { existsSync as existsSync10, readdirSync as readdirSync4, statSync as statSync3 } from "fs";
3043
3570
  import { join as join4 } from "path";
3044
3571
  function safeIsDir(p) {
3045
3572
  try {
3046
- return statSync2(p).isDirectory();
3573
+ return statSync3(p).isDirectory();
3047
3574
  } catch {
3048
3575
  return false;
3049
3576
  }
3050
3577
  }
3051
3578
  function listDirs(dir) {
3052
- return readdirSync3(dir).filter((e) => safeIsDir(join4(dir, e)));
3579
+ return readdirSync4(dir).filter((e) => safeIsDir(join4(dir, e)));
3053
3580
  }
3054
3581
  function fileCount(dir) {
3055
3582
  try {
3056
- return readdirSync3(dir).length;
3583
+ return readdirSync4(dir).length;
3057
3584
  } catch {
3058
3585
  return 0;
3059
3586
  }
@@ -3070,15 +3597,16 @@ function detectLaravel(root) {
3070
3597
  const sourceLocale = pickSource(locales, (loc) => fileCount(join4(localeRoot, loc)));
3071
3598
  return { format: "laravel-php", localeRoot, locales, sourceLocale };
3072
3599
  }
3073
- function detectVue(root) {
3600
+ function detectVue(root, forced = false) {
3074
3601
  for (const rel of VUE_DIR_CANDIDATES) {
3075
3602
  const localeRoot = join4(root, rel);
3076
3603
  if (!safeIsDir(localeRoot)) continue;
3077
- const locales = readdirSync3(localeRoot).filter((f) => f.endsWith(".json")).map((f) => f.slice(0, -5)).filter((l) => LOCALE_RE.test(l));
3078
- if (locales.length >= 2) {
3604
+ const locales = readdirSync4(localeRoot).filter((f) => f.endsWith(".json")).map((f) => f.slice(0, -5)).filter((l) => LOCALE_RE.test(l));
3605
+ const enough = locales.length >= 2 || locales.length === 1 && (forced || locales[0] === "en" || locales[0].startsWith("en-") || locales[0].startsWith("en_"));
3606
+ if (enough) {
3079
3607
  const sourceLocale = pickSource(locales, (loc) => {
3080
3608
  try {
3081
- return statSync2(join4(localeRoot, `${loc}.json`)).size;
3609
+ return statSync3(join4(localeRoot, `${loc}.json`)).size;
3082
3610
  } catch {
3083
3611
  return 0;
3084
3612
  }
@@ -3092,7 +3620,7 @@ function detectArb(root) {
3092
3620
  for (const rel of ["lib/l10n", "l10n", "lib/src/l10n"]) {
3093
3621
  const localeRoot = join4(root, rel);
3094
3622
  if (!safeIsDir(localeRoot)) continue;
3095
- const locales = readdirSync3(localeRoot).map((f) => f.match(/^(?:app_)?(.+)\.arb$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l));
3623
+ const locales = readdirSync4(localeRoot).map((f) => f.match(/^(?:app_)?(.+)\.arb$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l));
3096
3624
  if (locales.length >= 1) {
3097
3625
  return { format: "flutter-arb", localeRoot, locales, sourceLocale: pickSource(locales, () => 0) };
3098
3626
  }
@@ -3100,7 +3628,7 @@ function detectArb(root) {
3100
3628
  return null;
3101
3629
  }
3102
3630
  function detect(root, formatOverride) {
3103
- if (!existsSync9(root)) return null;
3631
+ if (!existsSync10(root)) return null;
3104
3632
  if (formatOverride) {
3105
3633
  const fn = BY_FORMAT[formatOverride];
3106
3634
  if (!fn) throw new Error(`Unknown format: ${formatOverride}`);
@@ -3121,7 +3649,7 @@ var init_detect = __esm({
3121
3649
  DETECTORS = [detectLaravel, detectVue, detectArb];
3122
3650
  BY_FORMAT = {
3123
3651
  "laravel-php": detectLaravel,
3124
- "vue-i18n-json": detectVue,
3652
+ "vue-i18n-json": (root) => detectVue(root, true),
3125
3653
  "flutter-arb": detectArb
3126
3654
  };
3127
3655
  }
@@ -3155,7 +3683,7 @@ var init_flatten = __esm({
3155
3683
  });
3156
3684
 
3157
3685
  // src/server/import/parsers/vue-i18n-json.ts
3158
- import { readdirSync as readdirSync4, readFileSync as readFileSync11 } from "fs";
3686
+ import { readdirSync as readdirSync5, readFileSync as readFileSync11 } from "fs";
3159
3687
  import { join as join5 } from "path";
3160
3688
  var LOCALE_RE2, vueI18nJson2;
3161
3689
  var init_vue_i18n_json2 = __esm({
@@ -3169,7 +3697,7 @@ var init_vue_i18n_json2 = __esm({
3169
3697
  const warnings = [];
3170
3698
  const keys = {};
3171
3699
  const locales = [];
3172
- for (const file of readdirSync4(localeRoot).sort()) {
3700
+ for (const file of readdirSync5(localeRoot).sort()) {
3173
3701
  if (!file.endsWith(".json")) continue;
3174
3702
  const locale = file.slice(0, -".json".length);
3175
3703
  if (!LOCALE_RE2.test(locale)) continue;
@@ -3203,18 +3731,18 @@ var init_placeholders2 = __esm({
3203
3731
  });
3204
3732
 
3205
3733
  // src/server/import/parsers/laravel-php.ts
3206
- import { readdirSync as readdirSync5, statSync as statSync3 } from "fs";
3734
+ import { readdirSync as readdirSync6, statSync as statSync4 } from "fs";
3207
3735
  import { join as join6, relative as relative2 } from "path";
3208
3736
  import { execFileSync } from "child_process";
3209
3737
  function listDirs2(dir) {
3210
- return readdirSync5(dir).filter((e) => statSync3(join6(dir, e)).isDirectory());
3738
+ return readdirSync6(dir).filter((e) => statSync4(join6(dir, e)).isDirectory());
3211
3739
  }
3212
3740
  function listPhpFiles(dir) {
3213
3741
  const out = [];
3214
3742
  const walk = (d) => {
3215
- for (const e of readdirSync5(d)) {
3743
+ for (const e of readdirSync6(d)) {
3216
3744
  const full = join6(d, e);
3217
- if (statSync3(full).isDirectory()) walk(full);
3745
+ if (statSync4(full).isDirectory()) walk(full);
3218
3746
  else if (e.endsWith(".php")) out.push(full);
3219
3747
  }
3220
3748
  };
@@ -3281,7 +3809,7 @@ var init_laravel_php2 = __esm({
3281
3809
  });
3282
3810
 
3283
3811
  // src/server/import/parsers/flutter-arb.ts
3284
- import { readdirSync as readdirSync6, readFileSync as readFileSync12 } from "fs";
3812
+ import { readdirSync as readdirSync7, readFileSync as readFileSync12 } from "fs";
3285
3813
  import { join as join7 } from "path";
3286
3814
  function localeFromArbName(file) {
3287
3815
  const m = file.match(/^(.+)\.arb$/);
@@ -3315,7 +3843,7 @@ var init_flutter_arb2 = __esm({
3315
3843
  const warnings = [];
3316
3844
  const keys = {};
3317
3845
  const locales = [];
3318
- for (const file of readdirSync6(localeRoot).sort()) {
3846
+ for (const file of readdirSync7(localeRoot).sort()) {
3319
3847
  if (!file.endsWith(".arb")) continue;
3320
3848
  const locale = localeFromArbName(file);
3321
3849
  if (!locale) continue;
@@ -3501,7 +4029,7 @@ function runImport(opts) {
3501
4029
  localeCount: state.config.locales.length
3502
4030
  };
3503
4031
  }
3504
- var init_run2 = __esm({
4032
+ var init_run3 = __esm({
3505
4033
  "src/server/import/run.ts"() {
3506
4034
  "use strict";
3507
4035
  init_detect();
@@ -3887,11 +4415,11 @@ var init_ui_prefs = __esm({
3887
4415
  // src/server/api.ts
3888
4416
  import { Hono } from "hono";
3889
4417
  import { streamSSE } from "hono/streaming";
3890
- import { readFileSync as readFileSync14, existsSync as existsSync10, readdirSync as readdirSync7, statSync as statSync4, rmSync as rmSync4 } from "fs";
3891
- import { dirname as dirname2, resolve as resolve9, basename, relative as relative3, sep } from "path";
4418
+ import { readFileSync as readFileSync14, existsSync as existsSync11, readdirSync as readdirSync8, statSync as statSync5, rmSync as rmSync4 } from "fs";
4419
+ import { dirname as dirname3, resolve as resolve9, basename, relative as relative3, sep as sep2 } from "path";
3892
4420
  function projectName(root) {
3893
4421
  const nameFile = resolve9(root, ".idea", ".name");
3894
- if (existsSync10(nameFile)) {
4422
+ if (existsSync11(nameFile)) {
3895
4423
  try {
3896
4424
  const name = readFileSync14(nameFile, "utf8").trim();
3897
4425
  if (name) return name;
@@ -3903,7 +4431,7 @@ function projectName(root) {
3903
4431
  function createApi(deps) {
3904
4432
  const app = new Hono();
3905
4433
  const load = () => loadState(deps.statePath);
3906
- const projectRoot = dirname2(resolve9(deps.statePath));
4434
+ const projectRoot = dirname3(resolve9(deps.statePath));
3907
4435
  let translateQueue = Promise.resolve();
3908
4436
  const withTranslateLock = (fn) => {
3909
4437
  const next = translateQueue.then(fn, fn);
@@ -4001,13 +4529,13 @@ function createApi(deps) {
4001
4529
  found.set(deps.statePath, {
4002
4530
  name: basename(deps.statePath),
4003
4531
  path: deps.statePath,
4004
- relDir: activeRel !== basename(activeRel) ? dirname2(activeRel) : void 0
4532
+ relDir: activeRel !== basename(activeRel) ? dirname3(activeRel) : void 0
4005
4533
  });
4006
4534
  function walk(dir, depth) {
4007
4535
  if (depth > 4) return;
4008
4536
  let entries = [];
4009
4537
  try {
4010
- entries = readdirSync7(dir);
4538
+ entries = readdirSync8(dir);
4011
4539
  } catch {
4012
4540
  return;
4013
4541
  }
@@ -4015,13 +4543,13 @@ function createApi(deps) {
4015
4543
  if (name.startsWith(".") || name === "node_modules") continue;
4016
4544
  const abs = resolve9(dir, name);
4017
4545
  let filePath = null;
4018
- if ((name === "glotfile" || name.endsWith(".glotfile")) && existsSync10(resolve9(abs, "config.json"))) {
4546
+ if ((name === "glotfile" || name.endsWith(".glotfile")) && existsSync11(resolve9(abs, "config.json"))) {
4019
4547
  filePath = resolve9(dir, `${name}.json`);
4020
4548
  } else if (name === "glotfile.json" || name.endsWith(".glotfile.json")) {
4021
4549
  filePath = abs;
4022
4550
  } else {
4023
4551
  try {
4024
- if (statSync4(abs).isDirectory()) walk(abs, depth + 1);
4552
+ if (statSync5(abs).isDirectory()) walk(abs, depth + 1);
4025
4553
  } catch {
4026
4554
  }
4027
4555
  continue;
@@ -4030,7 +4558,7 @@ function createApi(deps) {
4030
4558
  try {
4031
4559
  loadState(filePath);
4032
4560
  const rel = relative3(projectRoot, filePath);
4033
- found.set(filePath, { name: basename(filePath), path: filePath, relDir: rel !== basename(filePath) ? dirname2(rel) : void 0 });
4561
+ found.set(filePath, { name: basename(filePath), path: filePath, relDir: rel !== basename(filePath) ? dirname3(rel) : void 0 });
4034
4562
  } catch {
4035
4563
  }
4036
4564
  }
@@ -4047,9 +4575,9 @@ function createApi(deps) {
4047
4575
  const { path } = await c.req.json();
4048
4576
  if (typeof path !== "string") return c.json({ error: "path must be a string" }, 400);
4049
4577
  const resolved = resolve9(projectRoot, path);
4050
- const inside = resolved === projectRoot || resolved.startsWith(projectRoot + sep);
4578
+ const inside = resolved === projectRoot || resolved.startsWith(projectRoot + sep2);
4051
4579
  if (!inside) return c.json({ error: "file is outside the project" }, 400);
4052
- if (!existsSync10(resolved)) return c.json({ error: "file not found" }, 400);
4580
+ if (!existsSync11(resolved)) return c.json({ error: "file not found" }, 400);
4053
4581
  loadState(resolved);
4054
4582
  deps.statePath = resolved;
4055
4583
  return c.json({ ok: true, path: resolved, name: basename(resolved), dir: projectRoot, project: basename(projectRoot) });
@@ -4106,11 +4634,11 @@ function createApi(deps) {
4106
4634
  function removeOrphanScreenshot(s, screenshot) {
4107
4635
  if (!screenshot) return;
4108
4636
  for (const e of Object.values(s.keys)) if (e.screenshot === screenshot) return;
4109
- const root = dirname2(resolve9(deps.statePath));
4637
+ const root = dirname3(resolve9(deps.statePath));
4110
4638
  const abs = resolve9(root, screenshot);
4111
4639
  const rel = relative3(root, abs);
4112
- const seg0 = rel.split(sep)[0] ?? "";
4113
- if (!rel.startsWith("..") && seg0.endsWith("-screenshots") && existsSync10(abs)) {
4640
+ const seg0 = rel.split(sep2)[0] ?? "";
4641
+ if (!rel.startsWith("..") && seg0.endsWith("-screenshots") && existsSync11(abs)) {
4114
4642
  try {
4115
4643
  rmSync4(abs);
4116
4644
  } catch {
@@ -4362,7 +4890,7 @@ function createApi(deps) {
4362
4890
  const body = await c.req.parseBody();
4363
4891
  const file = body["file"];
4364
4892
  if (!file || typeof file === "string") return c.json({ error: "no file uploaded" }, 400);
4365
- const root = dirname2(resolve9(deps.statePath));
4893
+ const root = dirname3(resolve9(deps.statePath));
4366
4894
  const dirName = screenshotDirName(deps.statePath);
4367
4895
  const dir = resolve9(root, dirName);
4368
4896
  const filename = `${sanitize(key)}__${sanitize(file.name)}`;
@@ -4398,6 +4926,23 @@ function createApi(deps) {
4398
4926
  return c.json({ files, warnings });
4399
4927
  });
4400
4928
  app.get("/scan/missing", (c) => c.json(findMissing(load())));
4929
+ const spellerCache = /* @__PURE__ */ new Map();
4930
+ const cachedLoader = (dictId) => {
4931
+ let p = spellerCache.get(dictId);
4932
+ if (!p) {
4933
+ p = defaultLoader(dictId);
4934
+ spellerCache.set(dictId, p);
4935
+ }
4936
+ return p;
4937
+ };
4938
+ app.get("/lint", async (c) => {
4939
+ const state = load();
4940
+ const lint = await runLint(state, { loadSpeller: cachedLoader, warn: () => {
4941
+ } });
4942
+ const findings = sortFindings([...lint.findings, ...checkOutputs(state, projectRoot)]);
4943
+ const counts = countSeverities(findings);
4944
+ return c.json({ findings, counts, ok: counts.error === 0 });
4945
+ });
4401
4946
  app.get("/checks", (c) => {
4402
4947
  const param = c.req.query("checks");
4403
4948
  const only = param ? param.split(",").map((s) => s.trim()).filter((s) => CHECK_IDS.includes(s)) : void 0;
@@ -4433,22 +4978,12 @@ function createApi(deps) {
4433
4978
  return c.json({ keyCount: result.keyCount, localeCount: result.localeCount, warnings: result.warnings });
4434
4979
  });
4435
4980
  app.post("/export", (c) => {
4436
- const s = narrowForExport(load());
4437
- const root = dirname2(resolve9(deps.statePath));
4438
- const warnings = [];
4439
- let count = 0;
4440
- for (const output of s.config.outputs) {
4441
- const adapter = getAdapter(output.adapter);
4442
- const result = adapter.export(s, output);
4443
- warnings.push(...result.warnings);
4444
- for (const f of result.files) {
4445
- const abs = resolve9(root, f.path);
4446
- writeFileAtomic(abs, f.contents);
4447
- count++;
4448
- }
4449
- }
4450
- console.log(`[export] ${count} file(s)${warnings.length ? `, ${warnings.length} warning(s)` : ""}`);
4451
- return c.json({ files: count, warnings });
4981
+ const root = dirname3(resolve9(deps.statePath));
4982
+ const { written, skipped, deleted, warnings } = exportToDisk(load(), root);
4983
+ console.log(
4984
+ `[export] ${written + skipped} file(s)${deleted ? `, ${deleted} removed` : ""}${warnings.length ? `, ${warnings.length} warning(s)` : ""}`
4985
+ );
4986
+ return c.json({ files: written + skipped, warnings });
4452
4987
  });
4453
4988
  app.post("/translate/stream", async (c) => {
4454
4989
  const signal = c.req.raw.signal;
@@ -4470,7 +5005,7 @@ function createApi(deps) {
4470
5005
  await stream.writeSSE({ event: "error", data: JSON.stringify({ error: e.message }) });
4471
5006
  return;
4472
5007
  }
4473
- const { skipped } = attachScreenshotsForProvider(reqs, s, projectRoot, provider.supportsVision());
5008
+ const { skipped } = attachScreenshotsForProvider(reqs, s, dirname3(resolve9(deps.statePath)), provider.supportsVision());
4474
5009
  if (skipped) console.warn(`Model "${aiCfg.model}" has no vision support; ${skipped} screenshot(s) ignored.`);
4475
5010
  console.log(`[translate] ${reqs.length} string(s) \u2192 ${aiCfg.model}`);
4476
5011
  let totalWritten = 0;
@@ -4548,7 +5083,7 @@ function createApi(deps) {
4548
5083
  } catch (e) {
4549
5084
  return c.json({ error: e.message }, 400);
4550
5085
  }
4551
- const { skipped } = attachScreenshotsForProvider(toTranslate, s, projectRoot, provider.supportsVision());
5086
+ const { skipped } = attachScreenshotsForProvider(toTranslate, s, dirname3(resolve9(deps.statePath)), provider.supportsVision());
4552
5087
  if (skipped) console.warn(`Model "${aiCfg.model}" has no vision support; ${skipped} screenshot(s) ignored.`);
4553
5088
  const results = await runLocaleParallel(toTranslate, provider, {}, aiCfg.concurrency);
4554
5089
  const latest = load();
@@ -4735,13 +5270,16 @@ var init_api = __esm({
4735
5270
  init_context();
4736
5271
  init_stats();
4737
5272
  init_checks();
5273
+ init_run2();
5274
+ init_outputs();
5275
+ init_spelling();
4738
5276
  init_adapters();
4739
5277
  init_ai();
4740
5278
  init_run();
4741
5279
  init_provider();
4742
5280
  init_log();
4743
5281
  init_schema();
4744
- init_run2();
5282
+ init_run3();
4745
5283
  init_export_run();
4746
5284
  init_ui_prefs();
4747
5285
  init_local_settings();
@@ -4760,7 +5298,7 @@ __export(server_exports, {
4760
5298
  import { Hono as Hono2 } from "hono";
4761
5299
  import { serve } from "@hono/node-server";
4762
5300
  import { fileURLToPath } from "url";
4763
- import { dirname as dirname3, join as join9, resolve as resolve10, extname as extname3, sep as sep2 } from "path";
5301
+ import { dirname as dirname4, join as join9, resolve as resolve10, extname as extname3, sep as sep3 } from "path";
4764
5302
  import { readFile, stat } from "fs/promises";
4765
5303
  import { createServer } from "net";
4766
5304
  import open from "open";
@@ -4777,16 +5315,16 @@ async function readFileResponse(absPath) {
4777
5315
  }
4778
5316
  function buildApp(opts) {
4779
5317
  const app = new Hono2();
4780
- app.route("/api", createApi({ statePath: opts.statePath, autoExport: true }));
4781
- const projectRoot = dirname3(resolve10(opts.statePath));
5318
+ const apiDeps = { statePath: opts.statePath, autoExport: true };
5319
+ app.route("/api", createApi(apiDeps));
4782
5320
  app.get("/:dir/*", async (c, next) => {
4783
5321
  const dirSeg = c.req.param("dir");
4784
5322
  if (!dirSeg.endsWith("-screenshots")) return next();
4785
- const shotsRoot = resolve10(projectRoot, dirSeg);
5323
+ const shotsRoot = resolve10(dirname4(resolve10(apiDeps.statePath)), dirSeg);
4786
5324
  const pathname = decodeURIComponent(new URL(c.req.url).pathname);
4787
5325
  const rest = pathname.slice(`/${dirSeg}`.length);
4788
5326
  const target = resolve10(shotsRoot, "." + rest);
4789
- const inside = target === shotsRoot || target.startsWith(shotsRoot + sep2);
5327
+ const inside = target === shotsRoot || target.startsWith(shotsRoot + sep3);
4790
5328
  if (inside) {
4791
5329
  const file = await readFileResponse(target);
4792
5330
  if (file) return file;
@@ -4798,7 +5336,7 @@ function buildApp(opts) {
4798
5336
  app.get("/*", async (c) => {
4799
5337
  const pathname = decodeURIComponent(new URL(c.req.url).pathname);
4800
5338
  const target = resolve10(root, "." + pathname);
4801
- const inside = target === root || target.startsWith(root + sep2);
5339
+ const inside = target === root || target.startsWith(root + sep3);
4802
5340
  if (inside && pathname !== "/") {
4803
5341
  const file = await readFileResponse(target);
4804
5342
  if (file) return file;
@@ -4840,7 +5378,7 @@ async function startServer(opts) {
4840
5378
  });
4841
5379
  }
4842
5380
  function backgroundScan(statePath) {
4843
- const projectRoot = dirname3(resolve10(statePath));
5381
+ const projectRoot = dirname4(resolve10(statePath));
4844
5382
  Promise.resolve().then(() => {
4845
5383
  const state = loadState(statePath);
4846
5384
  const existing = loadUsageCache(projectRoot);
@@ -4860,7 +5398,7 @@ var init_server = __esm({
4860
5398
  init_state();
4861
5399
  init_scan();
4862
5400
  init_scanner();
4863
- here = dirname3(fileURLToPath(import.meta.url));
5401
+ here = dirname4(fileURLToPath(import.meta.url));
4864
5402
  DEFAULT_UI_DIR = join9(here, "..", "ui");
4865
5403
  MIME = {
4866
5404
  ".html": "text/html; charset=utf-8",
@@ -4902,327 +5440,10 @@ init_log();
4902
5440
  init_scan();
4903
5441
  init_scanner();
4904
5442
  init_context();
4905
- import { resolve as resolve11, dirname as dirname4 } from "path";
4906
- import { readFileSync as readFileSync15, existsSync as existsSync11 } from "fs";
4907
-
4908
- // src/server/lint/run.ts
4909
- init_glob();
4910
- init_registry();
4911
-
4912
- // src/server/lint/rules.ts
4913
- init_scan();
4914
- init_placeholders();
4915
- init_run();
4916
-
4917
- // src/server/lint/spelling.ts
4918
- init_placeholders();
4919
- function tokenize(text) {
4920
- return text.match(new RegExp("\\p{L}[\\p{L}'']*", "gu")) ?? [];
4921
- }
4922
- function buildAllowWords(glossary, dictionary2 = []) {
4923
- const set = /* @__PURE__ */ new Set();
4924
- const add = (s) => {
4925
- for (const w of tokenize(s)) set.add(w.toLowerCase());
4926
- };
4927
- for (const g of glossary) add(g.term);
4928
- for (const w of dictionary2) add(w);
4929
- return set;
4930
- }
4931
- var spellingRule = {
4932
- id: "spelling",
4933
- run(state, ctx) {
4934
- const out = [];
4935
- for (const key of Object.keys(state.keys)) {
4936
- const entry = state.keys[key];
4937
- for (const locale of ctx.targetLocales) {
4938
- const speller = ctx.spellers.get(locale);
4939
- if (!speller) continue;
4940
- const value = entry.values[locale]?.value;
4941
- if (!value) continue;
4942
- const placeholders = new Set(extractPlaceholders(value).map((p) => p.toLowerCase()));
4943
- for (const word of tokenize(value)) {
4944
- const lower = word.toLowerCase();
4945
- if (placeholders.has(lower) || ctx.allowWords.has(lower)) continue;
4946
- if (!speller.correct(word)) {
4947
- out.push({ ruleId: "spelling", key, locale, message: `possible misspelling: "${word}"` });
4948
- }
4949
- }
4950
- }
4951
- }
4952
- return out;
4953
- }
4954
- };
4955
- var defaultLoader = async (dictId) => {
4956
- try {
4957
- const nspellMod = await import("nspell");
4958
- const nspell2 = nspellMod.default ?? nspellMod;
4959
- const dictMod = await import(`dictionary-${dictId.toLowerCase()}`);
4960
- const dictExport = dictMod.default ?? dictMod;
4961
- const dict = typeof dictExport === "function" ? await dictExport() : dictExport;
4962
- return nspell2(dict);
4963
- } catch {
4964
- return null;
4965
- }
4966
- };
4967
-
4968
- // src/server/lint/rules.ts
4969
- var emptySourceRule = {
4970
- id: "empty-source",
4971
- run(state, ctx) {
4972
- const out = [];
4973
- for (const key of Object.keys(state.keys)) {
4974
- const v = state.keys[key].values[ctx.sourceLocale]?.value;
4975
- if (!v || !v.trim()) out.push({ ruleId: "empty-source", key, locale: "", message: "source value is empty" });
4976
- }
4977
- return out;
4978
- }
4979
- };
4980
- var emptyTranslationRule = {
4981
- id: "empty-translation",
4982
- run(state, ctx) {
4983
- const out = [];
4984
- for (const m of findMissing(state)) {
4985
- out.push({ ruleId: "empty-translation", key: m.key, locale: m.locale, message: "translation is empty or missing" });
4986
- }
4987
- for (const key of Object.keys(state.keys)) {
4988
- for (const locale of ctx.targetLocales) {
4989
- const v = state.keys[key].values[locale]?.value;
4990
- if (v && !v.trim()) out.push({ ruleId: "empty-translation", key, locale, message: "translation is whitespace-only" });
4991
- }
4992
- }
4993
- return out;
4994
- }
4995
- };
4996
- var identicalToSourceRule = {
4997
- id: "identical-to-source",
4998
- run(state, ctx) {
4999
- const out = [];
5000
- for (const key of Object.keys(state.keys)) {
5001
- const entry = state.keys[key];
5002
- const src = entry.values[ctx.sourceLocale]?.value;
5003
- if (!src) continue;
5004
- for (const locale of ctx.targetLocales) {
5005
- const v = entry.values[locale]?.value;
5006
- if (v && v === src) out.push({ ruleId: "identical-to-source", key, locale, message: "translation is identical to the source" });
5007
- }
5008
- }
5009
- return out;
5010
- }
5011
- };
5012
- var whitespaceRule = {
5013
- id: "whitespace",
5014
- run(state, ctx) {
5015
- const out = [];
5016
- for (const key of Object.keys(state.keys)) {
5017
- const entry = state.keys[key];
5018
- const src = entry.values[ctx.sourceLocale]?.value ?? "";
5019
- const srcEdge = src !== src.trim();
5020
- for (const locale of ctx.targetLocales) {
5021
- const v = entry.values[locale]?.value;
5022
- if (!v) continue;
5023
- if (v !== v.trim() !== srcEdge) {
5024
- out.push({ ruleId: "whitespace", key, locale, message: "leading/trailing whitespace differs from the source" });
5025
- }
5026
- }
5027
- }
5028
- return out;
5029
- }
5030
- };
5031
- var placeholderMismatchRule = {
5032
- id: "placeholder-mismatch",
5033
- run(state, ctx) {
5034
- const out = [];
5035
- for (const key of Object.keys(state.keys)) {
5036
- const entry = state.keys[key];
5037
- if (entry.plural) {
5038
- const srcForm = entry.values[ctx.sourceLocale]?.forms?.other;
5039
- if (!srcForm) continue;
5040
- for (const locale of ctx.targetLocales) {
5041
- const forms = entry.values[locale]?.forms;
5042
- if (!forms) continue;
5043
- const bad = Object.entries(forms).some(
5044
- ([cat, form]) => form && !pluralFormPlaceholdersMatch(cat, srcForm, form)
5045
- );
5046
- if (bad) {
5047
- out.push({ ruleId: "placeholder-mismatch", key, locale, message: "placeholders differ from the source" });
5048
- }
5049
- }
5050
- continue;
5051
- }
5052
- const src = entry.values[ctx.sourceLocale]?.value;
5053
- if (!src) continue;
5054
- for (const locale of ctx.targetLocales) {
5055
- const v = entry.values[locale]?.value;
5056
- if (!v) continue;
5057
- if (!placeholdersMatch(src, v)) {
5058
- out.push({ ruleId: "placeholder-mismatch", key, locale, message: "placeholders differ from the source" });
5059
- }
5060
- }
5061
- }
5062
- return out;
5063
- }
5064
- };
5065
- var icuMismatchRule = {
5066
- id: "icu-mismatch",
5067
- run(state, ctx) {
5068
- const out = [];
5069
- for (const key of Object.keys(state.keys)) {
5070
- const entry = state.keys[key];
5071
- const src = entry.values[ctx.sourceLocale]?.value;
5072
- if (!src) continue;
5073
- const srcIcu = isIcuPluralOrSelect(src);
5074
- for (const locale of ctx.targetLocales) {
5075
- const v = entry.values[locale]?.value;
5076
- if (!v) continue;
5077
- if (isIcuPluralOrSelect(v) !== srcIcu) {
5078
- out.push({
5079
- ruleId: "icu-mismatch",
5080
- key,
5081
- locale,
5082
- message: srcIcu ? "source is an ICU plural/select but the translation is not" : "translation is an ICU plural/select but the source is not"
5083
- });
5084
- }
5085
- }
5086
- }
5087
- return out;
5088
- }
5089
- };
5090
- var maxLengthRule = {
5091
- id: "max-length",
5092
- run(state, ctx) {
5093
- const out = [];
5094
- for (const key of Object.keys(state.keys)) {
5095
- const entry = state.keys[key];
5096
- const max = entry.maxLength;
5097
- if (max == null) continue;
5098
- for (const locale of ctx.targetLocales) {
5099
- const v = entry.values[locale]?.value;
5100
- if (v && v.length > max) {
5101
- out.push({ ruleId: "max-length", key, locale, message: `length ${v.length} exceeds maxLength ${max}` });
5102
- }
5103
- }
5104
- }
5105
- return out;
5106
- }
5107
- };
5108
- var glossaryViolationRule = {
5109
- id: "glossary-violation",
5110
- run(state, ctx) {
5111
- const out = [];
5112
- for (const key of Object.keys(state.keys)) {
5113
- const entry = state.keys[key];
5114
- const src = entry.values[ctx.sourceLocale]?.value;
5115
- if (!src) continue;
5116
- for (const locale of ctx.targetLocales) {
5117
- const v = entry.values[locale]?.value;
5118
- if (!v) continue;
5119
- for (const hint of relevantGlossary(src, locale, ctx.glossary)) {
5120
- if (hint.doNotTranslate && !v.includes(hint.term)) {
5121
- out.push({ ruleId: "glossary-violation", key, locale, message: `do-not-translate term "${hint.term}" is missing or altered` });
5122
- }
5123
- if (hint.forced && !v.includes(hint.forced)) {
5124
- out.push({ ruleId: "glossary-violation", key, locale, message: `expected glossary translation "${hint.forced}" for "${hint.term}"` });
5125
- }
5126
- }
5127
- }
5128
- }
5129
- return out;
5130
- }
5131
- };
5132
- var ALL_RULES = [
5133
- emptySourceRule,
5134
- emptyTranslationRule,
5135
- placeholderMismatchRule,
5136
- icuMismatchRule,
5137
- glossaryViolationRule,
5138
- maxLengthRule,
5139
- identicalToSourceRule,
5140
- whitespaceRule,
5141
- spellingRule
5142
- ];
5143
-
5144
- // src/server/lint/run.ts
5145
- function resolveSeverity(id, config) {
5146
- return config.rules?.[id] ?? DEFAULT_SEVERITY[id];
5147
- }
5148
- function sortFindings(findings) {
5149
- return [...findings].sort(
5150
- (a, b) => a.key.localeCompare(b.key) || a.locale.localeCompare(b.locale) || a.ruleId.localeCompare(b.ruleId)
5151
- );
5152
- }
5153
- function countSeverities(findings) {
5154
- let error = 0, warn = 0;
5155
- for (const f of findings) f.severity === "error" ? error++ : warn++;
5156
- return { error, warn };
5157
- }
5158
- async function loadSpellers(locales, config, load, warn) {
5159
- const map = /* @__PURE__ */ new Map();
5160
- for (const locale of locales) {
5161
- const dictId = config.spelling?.locales?.[locale] ?? locale;
5162
- const speller = await load(dictId);
5163
- if (speller) map.set(locale, speller);
5164
- else warn(`no dictionary for "${locale}", skipping spelling`);
5165
- }
5166
- return map;
5167
- }
5168
- async function runLint(state, options = {}) {
5169
- const config = state.config.lint ?? {};
5170
- const rules = options.rules ?? ALL_RULES;
5171
- const warn = options.warn ?? ((m) => console.warn(m));
5172
- const load = options.loadSpeller ?? defaultLoader;
5173
- const targetLocales = state.config.locales.filter((l) => l !== state.config.sourceLocale);
5174
- const isActive = (rule) => {
5175
- if (options.ruleIds && !options.ruleIds.includes(rule.id)) return false;
5176
- return resolveSeverity(rule.id, config) !== "off";
5177
- };
5178
- const active = rules.filter(isActive);
5179
- const spellingOn = active.some((r) => r.id === "spelling");
5180
- const spellers = spellingOn ? await loadSpellers(targetLocales, config, load, warn) : /* @__PURE__ */ new Map();
5181
- const allowWords = spellingOn ? buildAllowWords(state.glossary, config.dictionary) : /* @__PURE__ */ new Set();
5182
- const ctx = {
5183
- config,
5184
- sourceLocale: state.config.sourceLocale,
5185
- targetLocales,
5186
- glossary: state.glossary,
5187
- spellers,
5188
- allowWords
5189
- };
5190
- const ignoreRes = (config.ignore ?? []).map(globToRegExp);
5191
- const localeFilter = options.locales ? new Set(options.locales) : null;
5192
- const findings = [];
5193
- for (const rule of active) {
5194
- const severity = resolveSeverity(rule.id, config);
5195
- for (const raw of rule.run(state, ctx)) {
5196
- if (ignoreRes.some((re) => re.test(raw.key))) continue;
5197
- if (localeFilter && raw.locale !== "" && !localeFilter.has(raw.locale)) continue;
5198
- findings.push({ ...raw, severity });
5199
- }
5200
- }
5201
- const sorted = sortFindings(findings);
5202
- const counts = countSeverities(sorted);
5203
- return { findings: sorted, counts, ok: counts.error === 0 };
5204
- }
5205
-
5206
- // src/server/lint/outputs.ts
5207
- init_adapters();
5208
- import { readFileSync as readFileSync10, existsSync as existsSync8 } from "fs";
5209
- import { resolve as resolve8 } from "path";
5210
- function checkOutputs(state, root) {
5211
- const out = [];
5212
- for (const output of state.config.outputs) {
5213
- const result = getAdapter(output.adapter).export(state, output);
5214
- for (const file of result.files) {
5215
- const abs = resolve8(root, file.path);
5216
- const current = existsSync8(abs) ? readFileSync10(abs, "utf8") : null;
5217
- if (current === null) {
5218
- out.push({ ruleId: "output-stale", key: file.path, locale: "", severity: "error", message: "output file is missing; run `glotfile export`" });
5219
- } else if (current !== file.contents) {
5220
- out.push({ ruleId: "output-stale", key: file.path, locale: "", severity: "error", message: "output file is out of date; run `glotfile export`" });
5221
- }
5222
- }
5223
- }
5224
- return out;
5225
- }
5443
+ init_run2();
5444
+ init_outputs();
5445
+ import { resolve as resolve11, dirname as dirname5 } from "path";
5446
+ import { readFileSync as readFileSync15, existsSync as existsSync12 } from "fs";
5226
5447
 
5227
5448
  // src/server/lint/locate.ts
5228
5449
  function locate(rawText, key) {
@@ -5365,7 +5586,7 @@ function watchTargetFor(statePath) {
5365
5586
  return detectFormat(statePath) === "split" ? { path: splitDirFor(statePath), recursive: true } : { path: statePath, recursive: false };
5366
5587
  }
5367
5588
  async function runExport(args) {
5368
- const root = dirname4(resolve11(args.statePath));
5589
+ const root = dirname5(resolve11(args.statePath));
5369
5590
  const runOnce = () => {
5370
5591
  const state = loadState(args.statePath);
5371
5592
  const result = exportToDisk(state, root, args.adapter ? { adapter: args.adapter } : void 0);
@@ -5376,8 +5597,9 @@ async function runExport(args) {
5376
5597
  return result;
5377
5598
  };
5378
5599
  if (!args.watch) {
5379
- const { written, skipped } = runOnce();
5380
- console.log(`Exported ${written} file(s)${skipped ? ` (${skipped} unchanged)` : ""}.`);
5600
+ const { written, skipped, deleted } = runOnce();
5601
+ const extras = [skipped ? `${skipped} unchanged` : "", deleted ? `${deleted} stale removed` : ""].filter(Boolean);
5602
+ console.log(`Exported ${written} file(s)${extras.length ? ` (${extras.join(", ")})` : ""}.`);
5381
5603
  return;
5382
5604
  }
5383
5605
  const { watch } = await import("fs");
@@ -5389,8 +5611,10 @@ async function runExport(args) {
5389
5611
  clearTimeout(timer);
5390
5612
  timer = setTimeout(() => {
5391
5613
  try {
5392
- const { written } = runOnce();
5393
- if (written) console.log(`Re-exported ${written} file(s).`);
5614
+ const { written, deleted } = runOnce();
5615
+ if (written || deleted) {
5616
+ console.log(`Re-exported ${written} file(s)${deleted ? ` (${deleted} stale removed)` : ""}.`);
5617
+ }
5394
5618
  } catch (e) {
5395
5619
  console.error(e.message);
5396
5620
  }
@@ -5401,7 +5625,7 @@ async function runExport(args) {
5401
5625
  }
5402
5626
  async function runTranslate(args) {
5403
5627
  const state = loadState(args.statePath);
5404
- const projectRoot = dirname4(resolve11(args.statePath));
5628
+ const projectRoot = dirname5(resolve11(args.statePath));
5405
5629
  const reqs = selectRequests(state, {
5406
5630
  // Default to translating only empty values; --all forces a full re-translate
5407
5631
  // (overwriting existing translations). --only missing stays as a no-op alias.
@@ -5471,7 +5695,7 @@ function printReport(report, format, rawText) {
5471
5695
  }
5472
5696
  async function runLintCmd(args) {
5473
5697
  const state = loadState(args.statePath);
5474
- const rawText = existsSync11(args.statePath) ? readFileSync15(args.statePath, "utf8") : "";
5698
+ const rawText = existsSync12(args.statePath) ? readFileSync15(args.statePath, "utf8") : "";
5475
5699
  const report = await runLint(state, { locales: args.locales, ruleIds: args.ruleIds });
5476
5700
  printReport(report, args.format, rawText);
5477
5701
  const tooManyWarnings = args.maxWarnings != null && report.counts.warn > args.maxWarnings;
@@ -5491,8 +5715,8 @@ async function runCheck(args) {
5491
5715
  process.exitCode = 1;
5492
5716
  return;
5493
5717
  }
5494
- const rawText = existsSync11(args.statePath) ? readFileSync15(args.statePath, "utf8") : "";
5495
- const root = dirname4(resolve11(args.statePath));
5718
+ const rawText = existsSync12(args.statePath) ? readFileSync15(args.statePath, "utf8") : "";
5719
+ const root = dirname5(resolve11(args.statePath));
5496
5720
  const lint = await runLint(state, {});
5497
5721
  const findings = sortFindings([...lint.findings, ...checkOutputs(state, root)]);
5498
5722
  const counts = countSeverities(findings);
@@ -5501,10 +5725,10 @@ async function runCheck(args) {
5501
5725
  if (!report.ok) process.exitCode = 1;
5502
5726
  }
5503
5727
  async function runImportCmd(args) {
5504
- const { runImport: runImport2 } = await Promise.resolve().then(() => (init_run2(), run_exports));
5505
- const projectRoot = args.importSource ? resolve11(args.importSource) : dirname4(resolve11(args.statePath));
5728
+ const { runImport: runImport2 } = await Promise.resolve().then(() => (init_run3(), run_exports));
5729
+ const projectRoot = args.importSource ? resolve11(args.importSource) : dirname5(resolve11(args.statePath));
5506
5730
  const out = resolve11(projectRoot, "glotfile.json");
5507
- if (existsSync11(out) && !args.importForce) {
5731
+ if (existsSync12(out) && !args.importForce) {
5508
5732
  console.error(`${out} already exists; pass --force to overwrite`);
5509
5733
  process.exitCode = 1;
5510
5734
  return;
@@ -5529,7 +5753,7 @@ async function runImportCmd(args) {
5529
5753
  }
5530
5754
  async function runBuildContext(args) {
5531
5755
  const state = loadState(args.statePath);
5532
- const projectRoot = dirname4(resolve11(args.statePath));
5756
+ const projectRoot = dirname5(resolve11(args.statePath));
5533
5757
  const cache2 = loadUsageCache(projectRoot);
5534
5758
  if (!cache2) {
5535
5759
  console.error("No usage index found. Run 'glotfile scan' first.");
@@ -5600,7 +5824,7 @@ async function runBuildContext(args) {
5600
5824
  }
5601
5825
  async function runScanCmd(args) {
5602
5826
  const state = loadState(args.statePath);
5603
- const projectRoot = dirname4(resolve11(args.statePath));
5827
+ const projectRoot = dirname5(resolve11(args.statePath));
5604
5828
  const existing = loadUsageCache(projectRoot);
5605
5829
  const result = runScan(projectRoot, state.config.scan ?? {}, existing);
5606
5830
  const fileCount2 = Object.keys(result.files).length;
@@ -5619,7 +5843,7 @@ async function runPrune(args) {
5619
5843
  for (const k of findEmptySourceKeys(state)) toRemove.add(k);
5620
5844
  }
5621
5845
  if (args.unused) {
5622
- const projectRoot = dirname4(resolve11(args.statePath));
5846
+ const projectRoot = dirname5(resolve11(args.statePath));
5623
5847
  const cache2 = runScan(projectRoot, state.config.scan ?? {}, loadUsageCache(projectRoot));
5624
5848
  const used = new Set(computeUsedKeys(state, cache2));
5625
5849
  for (const k of Object.keys(state.keys)) {