glotfile 0.4.6 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -202,9 +202,6 @@ function validate(raw) {
202
202
  if (lint.ignore !== void 0 && (!Array.isArray(lint.ignore) || !lint.ignore.every((g) => typeof g === "string"))) {
203
203
  fail("config.lint.ignore must be an array of strings");
204
204
  }
205
- if (lint.dictionary !== void 0 && (!Array.isArray(lint.dictionary) || !lint.dictionary.every((w) => typeof w === "string"))) {
206
- fail("config.lint.dictionary must be an array of strings");
207
- }
208
205
  if (lint.spelling !== void 0) {
209
206
  if (!isObject(lint.spelling)) fail("config.lint.spelling must be an object");
210
207
  if (lint.spelling.locales !== void 0 && (!isObject(lint.spelling.locales) || !Object.values(lint.spelling.locales).every((v) => typeof v === "string"))) {
@@ -269,6 +266,20 @@ function validate(raw) {
269
266
  lv.value = lv.value.trim();
270
267
  }
271
268
  }
269
+ if (entry.suppressions !== void 0) {
270
+ if (!Array.isArray(entry.suppressions)) fail(`key "${key}" suppressions must be an array`);
271
+ for (const s of entry.suppressions) {
272
+ if (!isObject(s) || typeof s.locale !== "string" || typeof s.source !== "string") {
273
+ fail(`key "${key}" has an invalid suppression (needs string rule, locale, source)`);
274
+ }
275
+ if (!RULE_IDS.includes(s.rule)) {
276
+ fail(`key "${key}" suppression has unknown rule id "${String(s.rule)}"`);
277
+ }
278
+ if (s.at !== void 0 && typeof s.at !== "string") {
279
+ fail(`key "${key}" suppression "at" must be a string`);
280
+ }
281
+ }
282
+ }
272
283
  if (entry.notes !== void 0) {
273
284
  if (!Array.isArray(entry.notes)) fail(`key "${key}" notes must be an array`);
274
285
  for (const n of entry.notes) {
@@ -512,6 +523,30 @@ function normalizeSource(value) {
512
523
  return value.trim().replace(/\s+/g, " ").toLowerCase();
513
524
  }
514
525
 
526
+ // src/server/lint/suppress.ts
527
+ import { createHash } from "crypto";
528
+ function sourceSignature(entry, sourceLocale) {
529
+ const lv = entry.values[sourceLocale];
530
+ if (entry.plural) {
531
+ return Object.entries(lv?.forms ?? {}).sort(([a], [b]) => a.localeCompare(b)).map(([cat, body]) => `${cat}:${normalizeSource(body ?? "")}`).join("|");
532
+ }
533
+ return normalizeSource(lv?.value ?? "");
534
+ }
535
+ function sourceHash(entry, sourceLocale) {
536
+ return createHash("sha256").update(sourceSignature(entry, sourceLocale)).digest("hex").slice(0, 12);
537
+ }
538
+ function findSuppression(entry, sourceLocale, ruleId, locale) {
539
+ if (!entry.suppressions?.length) return void 0;
540
+ const current = sourceHash(entry, sourceLocale);
541
+ return entry.suppressions.find((s) => s.rule === ruleId && s.locale === locale && s.source === current);
542
+ }
543
+ function pruneStaleSuppressions(entry, sourceLocale) {
544
+ if (!entry.suppressions?.length) return;
545
+ const current = sourceHash(entry, sourceLocale);
546
+ entry.suppressions = entry.suppressions.filter((s) => s.source === current);
547
+ if (!entry.suppressions.length) delete entry.suppressions;
548
+ }
549
+
515
550
  // src/server/state.ts
516
551
  var systemClock = () => (/* @__PURE__ */ new Date()).toISOString();
517
552
  function canonLocale(locale) {
@@ -532,6 +567,7 @@ function normalizeState(state) {
532
567
  const remapped = {};
533
568
  for (const [loc, lv] of Object.entries(entry.values)) remapped[canonLocale(loc)] = lv;
534
569
  entry.values = remapped;
570
+ for (const s of entry.suppressions ?? []) s.locale = canonLocale(s.locale);
535
571
  }
536
572
  for (const output of state.config.outputs) {
537
573
  if (!output.localeMap) continue;
@@ -623,6 +659,7 @@ function setSourceValue(state, key, value) {
623
659
  lv.state = "needs-review";
624
660
  }
625
661
  }
662
+ pruneStaleSuppressions(entry, state.config.sourceLocale);
626
663
  }
627
664
  }
628
665
  function setTargetValue(state, key, locale, value, clock = systemClock) {
@@ -646,6 +683,7 @@ function setSourcePluralForms(state, key, forms) {
646
683
  lv.state = "needs-review";
647
684
  }
648
685
  }
686
+ pruneStaleSuppressions(entry, state.config.sourceLocale);
649
687
  }
650
688
  }
651
689
  function setPluralForms(state, key, locale, forms, clock = systemClock) {
@@ -719,6 +757,19 @@ function deleteNote(state, key, id) {
719
757
  if (!entry.notes) return;
720
758
  entry.notes = entry.notes.filter((n) => n.id !== id);
721
759
  }
760
+ function addSuppression(state, key, rule, locale, clock = systemClock) {
761
+ const entry = requireKey(state, key);
762
+ if (!RULE_IDS.includes(rule)) throw new GlotfileError(`Unknown lint rule: ${rule}`);
763
+ const list = (entry.suppressions ?? []).filter((s) => !(s.rule === rule && s.locale === locale));
764
+ list.push({ rule, locale, source: sourceHash(entry, state.config.sourceLocale), at: clock() });
765
+ entry.suppressions = list;
766
+ }
767
+ function removeSuppression(state, key, rule, locale) {
768
+ const entry = requireKey(state, key);
769
+ if (!entry.suppressions) return;
770
+ entry.suppressions = entry.suppressions.filter((s) => !(s.rule === rule && s.locale === locale));
771
+ if (!entry.suppressions.length) delete entry.suppressions;
772
+ }
722
773
  function upsertGlossaryEntry(state, entry) {
723
774
  const i = state.glossary.findIndex((e) => e.term === entry.term);
724
775
  if (i === -1) state.glossary.push(entry);
@@ -755,6 +806,23 @@ function applyMachineTranslationForms(state, key, locale, forms, clock = systemC
755
806
  return true;
756
807
  }
757
808
 
809
+ // src/server/lint/accept.ts
810
+ function acceptFindings(state, findings, opts = {}, clock = systemClock) {
811
+ const byRule = {};
812
+ let accepted = 0;
813
+ for (const f of findings) {
814
+ if (f.locale === "" || f.suppressed) continue;
815
+ if (f.severity === "error" && !opts.includeErrors) continue;
816
+ if (opts.rules && !opts.rules.includes(f.ruleId)) continue;
817
+ if (opts.locales && !opts.locales.includes(f.locale)) continue;
818
+ if (!state.keys[f.key]) continue;
819
+ addSuppression(state, f.key, f.ruleId, f.locale, clock);
820
+ byRule[f.ruleId] = (byRule[f.ruleId] ?? 0) + 1;
821
+ accepted++;
822
+ }
823
+ return { accepted, byRule };
824
+ }
825
+
758
826
  // src/server/scan.ts
759
827
  import { existsSync as existsSync4, readFileSync as readFileSync3 } from "fs";
760
828
  import { resolve as resolve2 } from "path";
@@ -1653,11 +1721,19 @@ function spellValue(locale, value, ignore) {
1653
1721
 
1654
1722
  // src/server/checks.ts
1655
1723
  var CHECK_IDS = ["untranslated", "placeholder", "spelling", "length", "glossary"];
1724
+ var CHECK_RULE = {
1725
+ untranslated: "empty-translation",
1726
+ placeholder: "placeholder-mismatch",
1727
+ spelling: "spelling",
1728
+ length: "max-length",
1729
+ glossary: "glossary-violation"
1730
+ };
1656
1731
  function contains(haystack, needle, caseSensitive) {
1657
1732
  return caseSensitive ? haystack.includes(needle) : haystack.toLowerCase().includes(needle.toLowerCase());
1658
1733
  }
1659
1734
  function runChecks(state, opts = {}) {
1660
- const on = (id) => !opts.only || opts.only.includes(id);
1735
+ const ruleOff = (id) => state.config.lint?.rules?.[CHECK_RULE[id]] === "off";
1736
+ const on = (id) => (!opts.only || opts.only.includes(id)) && !ruleOff(id);
1661
1737
  const issues = [];
1662
1738
  let spellPending = false;
1663
1739
  const { sourceLocale } = state.config;
@@ -1785,7 +1861,11 @@ function runChecks(state, opts = {}) {
1785
1861
  }
1786
1862
  }
1787
1863
  }
1788
- return { issues, spellPending };
1864
+ const visible = issues.filter((i) => {
1865
+ const entry = state.keys[i.key];
1866
+ return !entry || !findSuppression(entry, sourceLocale, CHECK_RULE[i.check], i.locale);
1867
+ });
1868
+ return { issues: visible, spellPending };
1789
1869
  }
1790
1870
 
1791
1871
  // src/server/lint/spelling.ts
@@ -2027,7 +2107,10 @@ function sortFindings(findings) {
2027
2107
  }
2028
2108
  function countSeverities(findings) {
2029
2109
  let error = 0, warn = 0;
2030
- for (const f of findings) f.severity === "error" ? error++ : warn++;
2110
+ for (const f of findings) {
2111
+ if (f.suppressed) continue;
2112
+ f.severity === "error" ? error++ : warn++;
2113
+ }
2031
2114
  return { error, warn };
2032
2115
  }
2033
2116
  async function loadSpellers(locales, config, load, warn) {
@@ -2053,7 +2136,7 @@ async function runLint(state, options = {}) {
2053
2136
  const active = rules.filter(isActive);
2054
2137
  const spellingOn = active.some((r) => r.id === "spelling");
2055
2138
  const spellers = spellingOn ? await loadSpellers(targetLocales, config, load, warn) : /* @__PURE__ */ new Map();
2056
- const allowWords = spellingOn ? buildAllowWords(state.glossary, config.dictionary) : /* @__PURE__ */ new Set();
2139
+ const allowWords = spellingOn ? buildAllowWords(state.glossary, state.config.spelling?.customWords) : /* @__PURE__ */ new Set();
2057
2140
  const ctx = {
2058
2141
  config,
2059
2142
  sourceLocale: state.config.sourceLocale,
@@ -2065,16 +2148,23 @@ async function runLint(state, options = {}) {
2065
2148
  const ignoreRes = (config.ignore ?? []).map(globToRegExp2);
2066
2149
  const localeFilter = options.locales ? new Set(options.locales) : null;
2067
2150
  const findings = [];
2151
+ let suppressed = 0;
2068
2152
  for (const rule of active) {
2069
2153
  const severity = resolveSeverity(rule.id, config);
2070
2154
  for (const raw of rule.run(state, ctx)) {
2071
2155
  if (ignoreRes.some((re) => re.test(raw.key))) continue;
2072
2156
  if (localeFilter && raw.locale !== "" && !localeFilter.has(raw.locale)) continue;
2157
+ const entry = state.keys[raw.key];
2158
+ if (raw.locale !== "" && entry && findSuppression(entry, state.config.sourceLocale, rule.id, raw.locale)) {
2159
+ suppressed++;
2160
+ if (options.includeSuppressed) findings.push({ ...raw, severity, suppressed: true });
2161
+ continue;
2162
+ }
2073
2163
  findings.push({ ...raw, severity });
2074
2164
  }
2075
2165
  }
2076
2166
  const sorted = sortFindings(findings);
2077
- const counts = countSeverities(sorted);
2167
+ const counts = { ...countSeverities(sorted), suppressed };
2078
2168
  return { findings: sorted, counts, ok: counts.error === 0 };
2079
2169
  }
2080
2170
 
@@ -4108,6 +4198,7 @@ import { homedir } from "os";
4108
4198
  import { join as join8 } from "path";
4109
4199
  var THEMES = ["system", "light", "dark"];
4110
4200
  var isThemeMode = (v) => THEMES.includes(v);
4201
+ var isPanelWidth = (v) => typeof v === "number" && Number.isFinite(v) && v >= 120 && v <= 1200;
4111
4202
  var defaultUiPrefsPath = () => join8(homedir(), ".glotfile", "ui.json");
4112
4203
  var DEFAULTS = { theme: "system" };
4113
4204
  function readJson(path) {
@@ -4120,7 +4211,10 @@ function readJson(path) {
4120
4211
  }
4121
4212
  function loadUiPrefs(path) {
4122
4213
  const raw = readJson(path);
4123
- return { theme: isThemeMode(raw.theme) ? raw.theme : DEFAULTS.theme };
4214
+ const prefs = { theme: isThemeMode(raw.theme) ? raw.theme : DEFAULTS.theme };
4215
+ if (isPanelWidth(raw.keyColumnWidth)) prefs.keyColumnWidth = Math.round(raw.keyColumnWidth);
4216
+ if (isPanelWidth(raw.detailPanelWidth)) prefs.detailPanelWidth = Math.round(raw.detailPanelWidth);
4217
+ return prefs;
4124
4218
  }
4125
4219
  function saveUiPrefs(path, prefs) {
4126
4220
  const merged = { ...readJson(path), ...prefs };
@@ -4256,9 +4350,20 @@ function createApi(deps) {
4256
4350
  app.get("/state", (c) => c.json(load()));
4257
4351
  app.get("/ui-prefs", (c) => c.json(loadUiPrefs(uiPrefsPath)));
4258
4352
  app.put("/ui-prefs", async (c) => {
4259
- const { theme } = await c.req.json();
4260
- if (!isThemeMode(theme)) return c.json({ error: "theme must be system, light, or dark" }, 400);
4261
- saveUiPrefs(uiPrefsPath, { theme });
4353
+ const body = await c.req.json();
4354
+ const patch = {};
4355
+ if ("theme" in body) {
4356
+ if (!isThemeMode(body.theme)) return c.json({ error: "theme must be system, light, or dark" }, 400);
4357
+ patch.theme = body.theme;
4358
+ }
4359
+ for (const field of ["keyColumnWidth", "detailPanelWidth"]) {
4360
+ if (field in body) {
4361
+ if (!isPanelWidth(body[field])) return c.json({ error: `${field} must be a number between 120 and 1200` }, 400);
4362
+ patch[field] = Math.round(body[field]);
4363
+ }
4364
+ }
4365
+ if (Object.keys(patch).length === 0) return c.json({ error: "no recognized preferences in body" }, 400);
4366
+ saveUiPrefs(uiPrefsPath, patch);
4262
4367
  return c.json({ ok: true });
4263
4368
  });
4264
4369
  app.get("/local-settings", (c) => c.json(loadLocalSettings(projectRoot)));
@@ -4732,12 +4837,47 @@ function createApi(deps) {
4732
4837
  };
4733
4838
  app.get("/lint", async (c) => {
4734
4839
  const state = load();
4840
+ const includeSuppressed = c.req.query("includeSuppressed") === "1";
4735
4841
  const lint = await runLint(state, { loadSpeller: cachedLoader, warn: () => {
4736
- } });
4842
+ }, includeSuppressed });
4737
4843
  const findings = sortFindings([...lint.findings, ...checkOutputs(state, projectRoot)]);
4738
- const counts = countSeverities(findings);
4844
+ const counts = { ...countSeverities(findings), suppressed: lint.counts.suppressed };
4739
4845
  return c.json({ findings, counts, ok: counts.error === 0 });
4740
4846
  });
4847
+ app.post("/keys/:key/suppressions", async (c) => {
4848
+ const key = c.req.param("key");
4849
+ const { rule, locale } = await c.req.json();
4850
+ if (typeof rule !== "string" || !rule) return c.json({ error: "rule is required" }, 400);
4851
+ if (typeof locale !== "string" || !locale) return c.json({ error: "locale is required" }, 400);
4852
+ const s = load();
4853
+ addSuppression(s, key, rule, locale);
4854
+ persist(s);
4855
+ logChange({ kind: "suppression", summary: `Suppressed ${rule} for ${key} [${locale}]`, key, locale, after: rule });
4856
+ return c.json({ ok: true });
4857
+ });
4858
+ app.delete("/keys/:key/suppressions", (c) => {
4859
+ const key = c.req.param("key");
4860
+ const rule = c.req.query("rule") ?? "";
4861
+ const locale = c.req.query("locale") ?? "";
4862
+ if (!rule || !locale) return c.json({ error: "rule and locale are required" }, 400);
4863
+ const s = load();
4864
+ removeSuppression(s, key, rule, locale);
4865
+ persist(s);
4866
+ logChange({ kind: "suppression", summary: `Unsuppressed ${rule} for ${key} [${locale}]`, key, locale, before: rule });
4867
+ return c.json({ ok: true });
4868
+ });
4869
+ app.post("/lint/accept", async (c) => {
4870
+ const body = await c.req.json().catch(() => ({}));
4871
+ const s = load();
4872
+ const lint = await runLint(s, { loadSpeller: cachedLoader, warn: () => {
4873
+ } });
4874
+ const result = acceptFindings(s, lint.findings, { rules: body.rules, locales: body.locales });
4875
+ if (result.accepted > 0) {
4876
+ persist(s);
4877
+ logChange({ kind: "suppression", summary: `Suppressed ${result.accepted} finding(s)`, after: result.byRule });
4878
+ }
4879
+ return c.json({ ok: true, ...result });
4880
+ });
4741
4881
  app.get("/checks", (c) => {
4742
4882
  const param = c.req.query("checks");
4743
4883
  const only = param ? param.split(",").map((s) => s.trim()).filter((s) => CHECK_IDS.includes(s)) : void 0;