glotfile 0.4.2 → 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
 
@@ -2232,13 +2414,13 @@ var init_ai = __esm({
2232
2414
  });
2233
2415
 
2234
2416
  // src/server/glotfile-dir.ts
2235
- 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";
2236
2418
  import { resolve as resolve2 } from "path";
2237
2419
  function ensureGlotfileDir(projectRoot) {
2238
2420
  const dir = resolve2(projectRoot, ".glotfile");
2239
2421
  mkdirSync3(dir, { recursive: true });
2240
2422
  const ignore = resolve2(dir, ".gitignore");
2241
- if (!existsSync3(ignore)) {
2423
+ if (!existsSync4(ignore)) {
2242
2424
  try {
2243
2425
  writeFileSync2(ignore, "*\n");
2244
2426
  } catch {
@@ -2349,7 +2531,7 @@ var init_glob = __esm({
2349
2531
  });
2350
2532
 
2351
2533
  // src/server/ai/run.ts
2352
- import { readFileSync as readFileSync5, existsSync as existsSync4 } from "fs";
2534
+ import { readFileSync as readFileSync5, existsSync as existsSync5 } from "fs";
2353
2535
  import { resolve as resolve4, extname } from "path";
2354
2536
  function selectRequests(state, opts) {
2355
2537
  const targets = (opts.locales ?? state.config.locales).filter((l) => l !== state.config.sourceLocale);
@@ -2432,7 +2614,7 @@ function attachScreenshots(reqs, state, projectRoot) {
2432
2614
  if (!mediaType) continue;
2433
2615
  if (!cache2.has(screenshot)) {
2434
2616
  const abs = resolve4(projectRoot, screenshot);
2435
- if (!existsSync4(abs)) {
2617
+ if (!existsSync5(abs)) {
2436
2618
  cache2.set(screenshot, null);
2437
2619
  } else {
2438
2620
  const buf = readFileSync5(abs);
@@ -2530,7 +2712,7 @@ var init_run = __esm({
2530
2712
  });
2531
2713
 
2532
2714
  // src/server/log.ts
2533
- import { appendFileSync, readFileSync as readFileSync6, existsSync as existsSync5 } from "fs";
2715
+ import { appendFileSync, readFileSync as readFileSync6, existsSync as existsSync6 } from "fs";
2534
2716
  import { resolve as resolve5 } from "path";
2535
2717
  function logPath(projectRoot) {
2536
2718
  return resolve5(projectRoot, ".glotfile", "log.jsonl");
@@ -2541,7 +2723,7 @@ function appendLog(projectRoot, entry) {
2541
2723
  }
2542
2724
  function readLog(projectRoot, limit = 100) {
2543
2725
  const path = logPath(projectRoot);
2544
- if (!existsSync5(path)) return [];
2726
+ if (!existsSync6(path)) return [];
2545
2727
  const lines = readFileSync6(path, "utf8").split("\n").filter((l) => l.trim() !== "");
2546
2728
  const entries = lines.map((l) => JSON.parse(l));
2547
2729
  return entries.reverse().slice(0, limit);
@@ -2554,11 +2736,11 @@ var init_log = __esm({
2554
2736
  });
2555
2737
 
2556
2738
  // src/server/scan.ts
2557
- import { existsSync as existsSync6, readFileSync as readFileSync7 } from "fs";
2739
+ import { existsSync as existsSync7, readFileSync as readFileSync7 } from "fs";
2558
2740
  import { resolve as resolve6 } from "path";
2559
2741
  function loadUsageCache(projectRoot) {
2560
2742
  const path = resolve6(projectRoot, ".glotfile", "usage.json");
2561
- if (!existsSync6(path)) return null;
2743
+ if (!existsSync7(path)) return null;
2562
2744
  try {
2563
2745
  return JSON.parse(readFileSync7(path, "utf8"));
2564
2746
  } catch {
@@ -2574,8 +2756,10 @@ function findMissing(state) {
2574
2756
  const targets = state.config.locales.filter((l) => l !== state.config.sourceLocale).sort();
2575
2757
  const out = [];
2576
2758
  for (const key of Object.keys(state.keys).sort()) {
2759
+ const entry = state.keys[key];
2760
+ if (entry.skipTranslate) continue;
2577
2761
  for (const locale of targets) {
2578
- const v = state.keys[key].values[locale]?.value;
2762
+ const v = entry.plural ? entry.values[locale]?.forms?.other?.trim() : entry.values[locale]?.value;
2579
2763
  if (!v) out.push({ key, locale });
2580
2764
  }
2581
2765
  }
@@ -2601,7 +2785,7 @@ var init_scan = __esm({
2601
2785
  });
2602
2786
 
2603
2787
  // src/server/scanner.ts
2604
- import { readdirSync as readdirSync2, statSync, readFileSync as readFileSync8 } from "fs";
2788
+ import { readdirSync as readdirSync3, statSync as statSync2, readFileSync as readFileSync8 } from "fs";
2605
2789
  import { join as join3, extname as extname2, relative } from "path";
2606
2790
  function scannerForExt(ext) {
2607
2791
  return EXT_SCANNER[ext] ?? null;
@@ -2722,7 +2906,7 @@ function isIncluded(relPath, includePatterns) {
2722
2906
  function* walkFiles(dir, root, exclude) {
2723
2907
  let entries;
2724
2908
  try {
2725
- entries = readdirSync2(dir);
2909
+ entries = readdirSync3(dir);
2726
2910
  } catch {
2727
2911
  return;
2728
2912
  }
@@ -2732,7 +2916,7 @@ function* walkFiles(dir, root, exclude) {
2732
2916
  const rel = relative(root, abs);
2733
2917
  let st;
2734
2918
  try {
2735
- st = statSync(abs);
2919
+ st = statSync2(abs);
2736
2920
  } catch {
2737
2921
  continue;
2738
2922
  }
@@ -2761,7 +2945,7 @@ function runScan(projectRoot, opts, existing) {
2761
2945
  const abs = join3(projectRoot, relPath);
2762
2946
  let st;
2763
2947
  try {
2764
- st = statSync(abs);
2948
+ st = statSync2(abs);
2765
2949
  } catch {
2766
2950
  continue;
2767
2951
  }
@@ -2879,7 +3063,7 @@ var init_scanner = __esm({
2879
3063
  });
2880
3064
 
2881
3065
  // src/server/ai/context.ts
2882
- import { existsSync as existsSync7, readFileSync as readFileSync9 } from "fs";
3066
+ import { existsSync as existsSync8, readFileSync as readFileSync9 } from "fs";
2883
3067
  import { resolve as resolve7 } from "path";
2884
3068
  function globToRegExp2(glob) {
2885
3069
  const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
@@ -2894,7 +3078,7 @@ function extractSnippets(refs, projectRoot, fileCache) {
2894
3078
  for (const ref of selected) {
2895
3079
  const absPath = resolve7(projectRoot, ref.file);
2896
3080
  if (!fileCache.has(ref.file)) {
2897
- if (!existsSync7(absPath)) continue;
3081
+ if (!existsSync8(absPath)) continue;
2898
3082
  const content = readFileSync9(absPath, "utf8");
2899
3083
  fileCache.set(ref.file, content.split("\n"));
2900
3084
  }
@@ -3039,22 +3223,364 @@ var init_context = __esm({
3039
3223
  }
3040
3224
  });
3041
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
+
3042
3568
  // src/server/import/detect.ts
3043
- 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";
3044
3570
  import { join as join4 } from "path";
3045
3571
  function safeIsDir(p) {
3046
3572
  try {
3047
- return statSync2(p).isDirectory();
3573
+ return statSync3(p).isDirectory();
3048
3574
  } catch {
3049
3575
  return false;
3050
3576
  }
3051
3577
  }
3052
3578
  function listDirs(dir) {
3053
- return readdirSync3(dir).filter((e) => safeIsDir(join4(dir, e)));
3579
+ return readdirSync4(dir).filter((e) => safeIsDir(join4(dir, e)));
3054
3580
  }
3055
3581
  function fileCount(dir) {
3056
3582
  try {
3057
- return readdirSync3(dir).length;
3583
+ return readdirSync4(dir).length;
3058
3584
  } catch {
3059
3585
  return 0;
3060
3586
  }
@@ -3071,15 +3597,16 @@ function detectLaravel(root) {
3071
3597
  const sourceLocale = pickSource(locales, (loc) => fileCount(join4(localeRoot, loc)));
3072
3598
  return { format: "laravel-php", localeRoot, locales, sourceLocale };
3073
3599
  }
3074
- function detectVue(root) {
3600
+ function detectVue(root, forced = false) {
3075
3601
  for (const rel of VUE_DIR_CANDIDATES) {
3076
3602
  const localeRoot = join4(root, rel);
3077
3603
  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) {
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) {
3080
3607
  const sourceLocale = pickSource(locales, (loc) => {
3081
3608
  try {
3082
- return statSync2(join4(localeRoot, `${loc}.json`)).size;
3609
+ return statSync3(join4(localeRoot, `${loc}.json`)).size;
3083
3610
  } catch {
3084
3611
  return 0;
3085
3612
  }
@@ -3093,7 +3620,7 @@ function detectArb(root) {
3093
3620
  for (const rel of ["lib/l10n", "l10n", "lib/src/l10n"]) {
3094
3621
  const localeRoot = join4(root, rel);
3095
3622
  if (!safeIsDir(localeRoot)) continue;
3096
- 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));
3097
3624
  if (locales.length >= 1) {
3098
3625
  return { format: "flutter-arb", localeRoot, locales, sourceLocale: pickSource(locales, () => 0) };
3099
3626
  }
@@ -3101,7 +3628,7 @@ function detectArb(root) {
3101
3628
  return null;
3102
3629
  }
3103
3630
  function detect(root, formatOverride) {
3104
- if (!existsSync9(root)) return null;
3631
+ if (!existsSync10(root)) return null;
3105
3632
  if (formatOverride) {
3106
3633
  const fn = BY_FORMAT[formatOverride];
3107
3634
  if (!fn) throw new Error(`Unknown format: ${formatOverride}`);
@@ -3122,7 +3649,7 @@ var init_detect = __esm({
3122
3649
  DETECTORS = [detectLaravel, detectVue, detectArb];
3123
3650
  BY_FORMAT = {
3124
3651
  "laravel-php": detectLaravel,
3125
- "vue-i18n-json": detectVue,
3652
+ "vue-i18n-json": (root) => detectVue(root, true),
3126
3653
  "flutter-arb": detectArb
3127
3654
  };
3128
3655
  }
@@ -3156,7 +3683,7 @@ var init_flatten = __esm({
3156
3683
  });
3157
3684
 
3158
3685
  // src/server/import/parsers/vue-i18n-json.ts
3159
- import { readdirSync as readdirSync4, readFileSync as readFileSync11 } from "fs";
3686
+ import { readdirSync as readdirSync5, readFileSync as readFileSync11 } from "fs";
3160
3687
  import { join as join5 } from "path";
3161
3688
  var LOCALE_RE2, vueI18nJson2;
3162
3689
  var init_vue_i18n_json2 = __esm({
@@ -3170,7 +3697,7 @@ var init_vue_i18n_json2 = __esm({
3170
3697
  const warnings = [];
3171
3698
  const keys = {};
3172
3699
  const locales = [];
3173
- for (const file of readdirSync4(localeRoot).sort()) {
3700
+ for (const file of readdirSync5(localeRoot).sort()) {
3174
3701
  if (!file.endsWith(".json")) continue;
3175
3702
  const locale = file.slice(0, -".json".length);
3176
3703
  if (!LOCALE_RE2.test(locale)) continue;
@@ -3204,18 +3731,18 @@ var init_placeholders2 = __esm({
3204
3731
  });
3205
3732
 
3206
3733
  // src/server/import/parsers/laravel-php.ts
3207
- import { readdirSync as readdirSync5, statSync as statSync3 } from "fs";
3734
+ import { readdirSync as readdirSync6, statSync as statSync4 } from "fs";
3208
3735
  import { join as join6, relative as relative2 } from "path";
3209
3736
  import { execFileSync } from "child_process";
3210
3737
  function listDirs2(dir) {
3211
- return readdirSync5(dir).filter((e) => statSync3(join6(dir, e)).isDirectory());
3738
+ return readdirSync6(dir).filter((e) => statSync4(join6(dir, e)).isDirectory());
3212
3739
  }
3213
3740
  function listPhpFiles(dir) {
3214
3741
  const out = [];
3215
3742
  const walk = (d) => {
3216
- for (const e of readdirSync5(d)) {
3743
+ for (const e of readdirSync6(d)) {
3217
3744
  const full = join6(d, e);
3218
- if (statSync3(full).isDirectory()) walk(full);
3745
+ if (statSync4(full).isDirectory()) walk(full);
3219
3746
  else if (e.endsWith(".php")) out.push(full);
3220
3747
  }
3221
3748
  };
@@ -3282,7 +3809,7 @@ var init_laravel_php2 = __esm({
3282
3809
  });
3283
3810
 
3284
3811
  // src/server/import/parsers/flutter-arb.ts
3285
- import { readdirSync as readdirSync6, readFileSync as readFileSync12 } from "fs";
3812
+ import { readdirSync as readdirSync7, readFileSync as readFileSync12 } from "fs";
3286
3813
  import { join as join7 } from "path";
3287
3814
  function localeFromArbName(file) {
3288
3815
  const m = file.match(/^(.+)\.arb$/);
@@ -3316,7 +3843,7 @@ var init_flutter_arb2 = __esm({
3316
3843
  const warnings = [];
3317
3844
  const keys = {};
3318
3845
  const locales = [];
3319
- for (const file of readdirSync6(localeRoot).sort()) {
3846
+ for (const file of readdirSync7(localeRoot).sort()) {
3320
3847
  if (!file.endsWith(".arb")) continue;
3321
3848
  const locale = localeFromArbName(file);
3322
3849
  if (!locale) continue;
@@ -3502,7 +4029,7 @@ function runImport(opts) {
3502
4029
  localeCount: state.config.locales.length
3503
4030
  };
3504
4031
  }
3505
- var init_run2 = __esm({
4032
+ var init_run3 = __esm({
3506
4033
  "src/server/import/run.ts"() {
3507
4034
  "use strict";
3508
4035
  init_detect();
@@ -3888,11 +4415,11 @@ var init_ui_prefs = __esm({
3888
4415
  // src/server/api.ts
3889
4416
  import { Hono } from "hono";
3890
4417
  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";
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";
3893
4420
  function projectName(root) {
3894
4421
  const nameFile = resolve9(root, ".idea", ".name");
3895
- if (existsSync10(nameFile)) {
4422
+ if (existsSync11(nameFile)) {
3896
4423
  try {
3897
4424
  const name = readFileSync14(nameFile, "utf8").trim();
3898
4425
  if (name) return name;
@@ -3904,7 +4431,7 @@ function projectName(root) {
3904
4431
  function createApi(deps) {
3905
4432
  const app = new Hono();
3906
4433
  const load = () => loadState(deps.statePath);
3907
- const projectRoot = dirname2(resolve9(deps.statePath));
4434
+ const projectRoot = dirname3(resolve9(deps.statePath));
3908
4435
  let translateQueue = Promise.resolve();
3909
4436
  const withTranslateLock = (fn) => {
3910
4437
  const next = translateQueue.then(fn, fn);
@@ -4002,13 +4529,13 @@ function createApi(deps) {
4002
4529
  found.set(deps.statePath, {
4003
4530
  name: basename(deps.statePath),
4004
4531
  path: deps.statePath,
4005
- relDir: activeRel !== basename(activeRel) ? dirname2(activeRel) : void 0
4532
+ relDir: activeRel !== basename(activeRel) ? dirname3(activeRel) : void 0
4006
4533
  });
4007
4534
  function walk(dir, depth) {
4008
4535
  if (depth > 4) return;
4009
4536
  let entries = [];
4010
4537
  try {
4011
- entries = readdirSync7(dir);
4538
+ entries = readdirSync8(dir);
4012
4539
  } catch {
4013
4540
  return;
4014
4541
  }
@@ -4016,13 +4543,13 @@ function createApi(deps) {
4016
4543
  if (name.startsWith(".") || name === "node_modules") continue;
4017
4544
  const abs = resolve9(dir, name);
4018
4545
  let filePath = null;
4019
- if ((name === "glotfile" || name.endsWith(".glotfile")) && existsSync10(resolve9(abs, "config.json"))) {
4546
+ if ((name === "glotfile" || name.endsWith(".glotfile")) && existsSync11(resolve9(abs, "config.json"))) {
4020
4547
  filePath = resolve9(dir, `${name}.json`);
4021
4548
  } else if (name === "glotfile.json" || name.endsWith(".glotfile.json")) {
4022
4549
  filePath = abs;
4023
4550
  } else {
4024
4551
  try {
4025
- if (statSync4(abs).isDirectory()) walk(abs, depth + 1);
4552
+ if (statSync5(abs).isDirectory()) walk(abs, depth + 1);
4026
4553
  } catch {
4027
4554
  }
4028
4555
  continue;
@@ -4031,7 +4558,7 @@ function createApi(deps) {
4031
4558
  try {
4032
4559
  loadState(filePath);
4033
4560
  const rel = relative3(projectRoot, filePath);
4034
- 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 });
4035
4562
  } catch {
4036
4563
  }
4037
4564
  }
@@ -4048,9 +4575,9 @@ function createApi(deps) {
4048
4575
  const { path } = await c.req.json();
4049
4576
  if (typeof path !== "string") return c.json({ error: "path must be a string" }, 400);
4050
4577
  const resolved = resolve9(projectRoot, path);
4051
- const inside = resolved === projectRoot || resolved.startsWith(projectRoot + sep);
4578
+ const inside = resolved === projectRoot || resolved.startsWith(projectRoot + sep2);
4052
4579
  if (!inside) return c.json({ error: "file is outside the project" }, 400);
4053
- if (!existsSync10(resolved)) return c.json({ error: "file not found" }, 400);
4580
+ if (!existsSync11(resolved)) return c.json({ error: "file not found" }, 400);
4054
4581
  loadState(resolved);
4055
4582
  deps.statePath = resolved;
4056
4583
  return c.json({ ok: true, path: resolved, name: basename(resolved), dir: projectRoot, project: basename(projectRoot) });
@@ -4107,11 +4634,11 @@ function createApi(deps) {
4107
4634
  function removeOrphanScreenshot(s, screenshot) {
4108
4635
  if (!screenshot) return;
4109
4636
  for (const e of Object.values(s.keys)) if (e.screenshot === screenshot) return;
4110
- const root = dirname2(resolve9(deps.statePath));
4637
+ const root = dirname3(resolve9(deps.statePath));
4111
4638
  const abs = resolve9(root, screenshot);
4112
4639
  const rel = relative3(root, abs);
4113
- const seg0 = rel.split(sep)[0] ?? "";
4114
- if (!rel.startsWith("..") && seg0.endsWith("-screenshots") && existsSync10(abs)) {
4640
+ const seg0 = rel.split(sep2)[0] ?? "";
4641
+ if (!rel.startsWith("..") && seg0.endsWith("-screenshots") && existsSync11(abs)) {
4115
4642
  try {
4116
4643
  rmSync4(abs);
4117
4644
  } catch {
@@ -4363,7 +4890,7 @@ function createApi(deps) {
4363
4890
  const body = await c.req.parseBody();
4364
4891
  const file = body["file"];
4365
4892
  if (!file || typeof file === "string") return c.json({ error: "no file uploaded" }, 400);
4366
- const root = dirname2(resolve9(deps.statePath));
4893
+ const root = dirname3(resolve9(deps.statePath));
4367
4894
  const dirName = screenshotDirName(deps.statePath);
4368
4895
  const dir = resolve9(root, dirName);
4369
4896
  const filename = `${sanitize(key)}__${sanitize(file.name)}`;
@@ -4399,6 +4926,23 @@ function createApi(deps) {
4399
4926
  return c.json({ files, warnings });
4400
4927
  });
4401
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
+ });
4402
4946
  app.get("/checks", (c) => {
4403
4947
  const param = c.req.query("checks");
4404
4948
  const only = param ? param.split(",").map((s) => s.trim()).filter((s) => CHECK_IDS.includes(s)) : void 0;
@@ -4434,22 +4978,12 @@ function createApi(deps) {
4434
4978
  return c.json({ keyCount: result.keyCount, localeCount: result.localeCount, warnings: result.warnings });
4435
4979
  });
4436
4980
  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 });
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 });
4453
4987
  });
4454
4988
  app.post("/translate/stream", async (c) => {
4455
4989
  const signal = c.req.raw.signal;
@@ -4471,7 +5005,7 @@ function createApi(deps) {
4471
5005
  await stream.writeSSE({ event: "error", data: JSON.stringify({ error: e.message }) });
4472
5006
  return;
4473
5007
  }
4474
- const { skipped } = attachScreenshotsForProvider(reqs, s, dirname2(resolve9(deps.statePath)), provider.supportsVision());
5008
+ const { skipped } = attachScreenshotsForProvider(reqs, s, dirname3(resolve9(deps.statePath)), provider.supportsVision());
4475
5009
  if (skipped) console.warn(`Model "${aiCfg.model}" has no vision support; ${skipped} screenshot(s) ignored.`);
4476
5010
  console.log(`[translate] ${reqs.length} string(s) \u2192 ${aiCfg.model}`);
4477
5011
  let totalWritten = 0;
@@ -4549,7 +5083,7 @@ function createApi(deps) {
4549
5083
  } catch (e) {
4550
5084
  return c.json({ error: e.message }, 400);
4551
5085
  }
4552
- const { skipped } = attachScreenshotsForProvider(toTranslate, s, dirname2(resolve9(deps.statePath)), provider.supportsVision());
5086
+ const { skipped } = attachScreenshotsForProvider(toTranslate, s, dirname3(resolve9(deps.statePath)), provider.supportsVision());
4553
5087
  if (skipped) console.warn(`Model "${aiCfg.model}" has no vision support; ${skipped} screenshot(s) ignored.`);
4554
5088
  const results = await runLocaleParallel(toTranslate, provider, {}, aiCfg.concurrency);
4555
5089
  const latest = load();
@@ -4736,13 +5270,16 @@ var init_api = __esm({
4736
5270
  init_context();
4737
5271
  init_stats();
4738
5272
  init_checks();
5273
+ init_run2();
5274
+ init_outputs();
5275
+ init_spelling();
4739
5276
  init_adapters();
4740
5277
  init_ai();
4741
5278
  init_run();
4742
5279
  init_provider();
4743
5280
  init_log();
4744
5281
  init_schema();
4745
- init_run2();
5282
+ init_run3();
4746
5283
  init_export_run();
4747
5284
  init_ui_prefs();
4748
5285
  init_local_settings();
@@ -4761,7 +5298,7 @@ __export(server_exports, {
4761
5298
  import { Hono as Hono2 } from "hono";
4762
5299
  import { serve } from "@hono/node-server";
4763
5300
  import { fileURLToPath } from "url";
4764
- 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";
4765
5302
  import { readFile, stat } from "fs/promises";
4766
5303
  import { createServer } from "net";
4767
5304
  import open from "open";
@@ -4783,11 +5320,11 @@ function buildApp(opts) {
4783
5320
  app.get("/:dir/*", async (c, next) => {
4784
5321
  const dirSeg = c.req.param("dir");
4785
5322
  if (!dirSeg.endsWith("-screenshots")) return next();
4786
- const shotsRoot = resolve10(dirname3(resolve10(apiDeps.statePath)), dirSeg);
5323
+ const shotsRoot = resolve10(dirname4(resolve10(apiDeps.statePath)), dirSeg);
4787
5324
  const pathname = decodeURIComponent(new URL(c.req.url).pathname);
4788
5325
  const rest = pathname.slice(`/${dirSeg}`.length);
4789
5326
  const target = resolve10(shotsRoot, "." + rest);
4790
- const inside = target === shotsRoot || target.startsWith(shotsRoot + sep2);
5327
+ const inside = target === shotsRoot || target.startsWith(shotsRoot + sep3);
4791
5328
  if (inside) {
4792
5329
  const file = await readFileResponse(target);
4793
5330
  if (file) return file;
@@ -4799,7 +5336,7 @@ function buildApp(opts) {
4799
5336
  app.get("/*", async (c) => {
4800
5337
  const pathname = decodeURIComponent(new URL(c.req.url).pathname);
4801
5338
  const target = resolve10(root, "." + pathname);
4802
- const inside = target === root || target.startsWith(root + sep2);
5339
+ const inside = target === root || target.startsWith(root + sep3);
4803
5340
  if (inside && pathname !== "/") {
4804
5341
  const file = await readFileResponse(target);
4805
5342
  if (file) return file;
@@ -4841,7 +5378,7 @@ async function startServer(opts) {
4841
5378
  });
4842
5379
  }
4843
5380
  function backgroundScan(statePath) {
4844
- const projectRoot = dirname3(resolve10(statePath));
5381
+ const projectRoot = dirname4(resolve10(statePath));
4845
5382
  Promise.resolve().then(() => {
4846
5383
  const state = loadState(statePath);
4847
5384
  const existing = loadUsageCache(projectRoot);
@@ -4861,7 +5398,7 @@ var init_server = __esm({
4861
5398
  init_state();
4862
5399
  init_scan();
4863
5400
  init_scanner();
4864
- here = dirname3(fileURLToPath(import.meta.url));
5401
+ here = dirname4(fileURLToPath(import.meta.url));
4865
5402
  DEFAULT_UI_DIR = join9(here, "..", "ui");
4866
5403
  MIME = {
4867
5404
  ".html": "text/html; charset=utf-8",
@@ -4903,327 +5440,10 @@ init_log();
4903
5440
  init_scan();
4904
5441
  init_scanner();
4905
5442
  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
- }
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";
5227
5447
 
5228
5448
  // src/server/lint/locate.ts
5229
5449
  function locate(rawText, key) {
@@ -5366,7 +5586,7 @@ function watchTargetFor(statePath) {
5366
5586
  return detectFormat(statePath) === "split" ? { path: splitDirFor(statePath), recursive: true } : { path: statePath, recursive: false };
5367
5587
  }
5368
5588
  async function runExport(args) {
5369
- const root = dirname4(resolve11(args.statePath));
5589
+ const root = dirname5(resolve11(args.statePath));
5370
5590
  const runOnce = () => {
5371
5591
  const state = loadState(args.statePath);
5372
5592
  const result = exportToDisk(state, root, args.adapter ? { adapter: args.adapter } : void 0);
@@ -5377,8 +5597,9 @@ async function runExport(args) {
5377
5597
  return result;
5378
5598
  };
5379
5599
  if (!args.watch) {
5380
- const { written, skipped } = runOnce();
5381
- 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(", ")})` : ""}.`);
5382
5603
  return;
5383
5604
  }
5384
5605
  const { watch } = await import("fs");
@@ -5390,8 +5611,10 @@ async function runExport(args) {
5390
5611
  clearTimeout(timer);
5391
5612
  timer = setTimeout(() => {
5392
5613
  try {
5393
- const { written } = runOnce();
5394
- 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
+ }
5395
5618
  } catch (e) {
5396
5619
  console.error(e.message);
5397
5620
  }
@@ -5402,7 +5625,7 @@ async function runExport(args) {
5402
5625
  }
5403
5626
  async function runTranslate(args) {
5404
5627
  const state = loadState(args.statePath);
5405
- const projectRoot = dirname4(resolve11(args.statePath));
5628
+ const projectRoot = dirname5(resolve11(args.statePath));
5406
5629
  const reqs = selectRequests(state, {
5407
5630
  // Default to translating only empty values; --all forces a full re-translate
5408
5631
  // (overwriting existing translations). --only missing stays as a no-op alias.
@@ -5472,7 +5695,7 @@ function printReport(report, format, rawText) {
5472
5695
  }
5473
5696
  async function runLintCmd(args) {
5474
5697
  const state = loadState(args.statePath);
5475
- const rawText = existsSync11(args.statePath) ? readFileSync15(args.statePath, "utf8") : "";
5698
+ const rawText = existsSync12(args.statePath) ? readFileSync15(args.statePath, "utf8") : "";
5476
5699
  const report = await runLint(state, { locales: args.locales, ruleIds: args.ruleIds });
5477
5700
  printReport(report, args.format, rawText);
5478
5701
  const tooManyWarnings = args.maxWarnings != null && report.counts.warn > args.maxWarnings;
@@ -5492,8 +5715,8 @@ async function runCheck(args) {
5492
5715
  process.exitCode = 1;
5493
5716
  return;
5494
5717
  }
5495
- const rawText = existsSync11(args.statePath) ? readFileSync15(args.statePath, "utf8") : "";
5496
- const root = dirname4(resolve11(args.statePath));
5718
+ const rawText = existsSync12(args.statePath) ? readFileSync15(args.statePath, "utf8") : "";
5719
+ const root = dirname5(resolve11(args.statePath));
5497
5720
  const lint = await runLint(state, {});
5498
5721
  const findings = sortFindings([...lint.findings, ...checkOutputs(state, root)]);
5499
5722
  const counts = countSeverities(findings);
@@ -5502,10 +5725,10 @@ async function runCheck(args) {
5502
5725
  if (!report.ok) process.exitCode = 1;
5503
5726
  }
5504
5727
  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));
5728
+ const { runImport: runImport2 } = await Promise.resolve().then(() => (init_run3(), run_exports));
5729
+ const projectRoot = args.importSource ? resolve11(args.importSource) : dirname5(resolve11(args.statePath));
5507
5730
  const out = resolve11(projectRoot, "glotfile.json");
5508
- if (existsSync11(out) && !args.importForce) {
5731
+ if (existsSync12(out) && !args.importForce) {
5509
5732
  console.error(`${out} already exists; pass --force to overwrite`);
5510
5733
  process.exitCode = 1;
5511
5734
  return;
@@ -5530,7 +5753,7 @@ async function runImportCmd(args) {
5530
5753
  }
5531
5754
  async function runBuildContext(args) {
5532
5755
  const state = loadState(args.statePath);
5533
- const projectRoot = dirname4(resolve11(args.statePath));
5756
+ const projectRoot = dirname5(resolve11(args.statePath));
5534
5757
  const cache2 = loadUsageCache(projectRoot);
5535
5758
  if (!cache2) {
5536
5759
  console.error("No usage index found. Run 'glotfile scan' first.");
@@ -5601,7 +5824,7 @@ async function runBuildContext(args) {
5601
5824
  }
5602
5825
  async function runScanCmd(args) {
5603
5826
  const state = loadState(args.statePath);
5604
- const projectRoot = dirname4(resolve11(args.statePath));
5827
+ const projectRoot = dirname5(resolve11(args.statePath));
5605
5828
  const existing = loadUsageCache(projectRoot);
5606
5829
  const result = runScan(projectRoot, state.config.scan ?? {}, existing);
5607
5830
  const fileCount2 = Object.keys(result.files).length;
@@ -5620,7 +5843,7 @@ async function runPrune(args) {
5620
5843
  for (const k of findEmptySourceKeys(state)) toRemove.add(k);
5621
5844
  }
5622
5845
  if (args.unused) {
5623
- const projectRoot = dirname4(resolve11(args.statePath));
5846
+ const projectRoot = dirname5(resolve11(args.statePath));
5624
5847
  const cache2 = runScan(projectRoot, state.config.scan ?? {}, loadUsageCache(projectRoot));
5625
5848
  const used = new Set(computeUsedKeys(state, cache2));
5626
5849
  for (const k of Object.keys(state.keys)) {