diffwiki-core 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -47,7 +47,7 @@ import {
47
47
  upsertPlugin,
48
48
  writePluginRegistry,
49
49
  writeRegistry
50
- } from "./chunk-MLUJY3HI.js";
50
+ } from "./chunk-7V2DIQNL.js";
51
51
 
52
52
  // src/config.ts
53
53
  import fs from "fs/promises";
@@ -91,12 +91,20 @@ async function getConfigValue(key) {
91
91
  const value = config[key];
92
92
  return typeof value === "string" ? value : void 0;
93
93
  }
94
+ async function showSystemEnabled() {
95
+ const config = await readConfig();
96
+ return config.showSystem === true || config.showSystem === "true";
97
+ }
94
98
 
95
99
  // src/slugify.ts
96
100
  function slugify(input) {
97
101
  const slug = input.toLowerCase().replace(/^#+\s*/, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80).replace(/-+$/g, "");
98
102
  return slug.length > 0 ? slug : "untitled";
99
103
  }
104
+ function slugifyPath(input) {
105
+ const segs = input.split("/").map((s) => s.trim()).filter((s) => s.length > 0).map((s) => slugify(s));
106
+ return segs.length > 0 ? segs.join("/") : "untitled";
107
+ }
100
108
 
101
109
  // src/frontmatter.ts
102
110
  import matter from "gray-matter";
@@ -110,10 +118,10 @@ var YAML_ENGINE = {
110
118
  };
111
119
  var MATTER_OPTS = { engines: { yaml: YAML_ENGINE } };
112
120
  function coerceAudience(value) {
113
- return value === "private" ? "private" : "public";
121
+ return value === "private" || value === "system" ? value : "public";
114
122
  }
115
123
  function coerceStatus(value) {
116
- return value === "draft" ? "draft" : "published";
124
+ return value === "draft" || value === "deprecated" ? value : "published";
117
125
  }
118
126
  function parseFallback(raw) {
119
127
  const fenceRe = /^---[ \t]*\n([\s\S]*?)\n---[ \t]*\n/;
@@ -158,8 +166,8 @@ function parseArticle(raw) {
158
166
  }
159
167
  function serializeArticle(article) {
160
168
  const front = { title: article.title, tags: article.tags };
161
- if (article.audience === "private") front.audience = "private";
162
- if (article.status === "draft") front.status = "draft";
169
+ if (article.audience === "private" || article.audience === "system") front.audience = article.audience;
170
+ if (article.status === "draft" || article.status === "deprecated") front.status = article.status;
163
171
  return matter.stringify(article.body, front, MATTER_OPTS);
164
172
  }
165
173
 
@@ -168,7 +176,7 @@ import fs2 from "fs/promises";
168
176
  import path from "path";
169
177
  async function fireHook(event, collection, relPath) {
170
178
  try {
171
- const { emitPluginEvent: emitPluginEvent2 } = await import("./events-UCJWB4WP.js");
179
+ const { emitPluginEvent: emitPluginEvent2 } = await import("./events-AVD3G5AX.js");
172
180
  await emitPluginEvent2(event, collection, relPath);
173
181
  } catch {
174
182
  }
@@ -205,18 +213,15 @@ async function resolveCollectionDir(name) {
205
213
  }
206
214
  function ensureMdPath(file) {
207
215
  if (/\.mdx?$/.test(file)) return file;
208
- const dir = path.dirname(file);
209
- const base = slugify(path.basename(file));
210
- const rel = dir === "." ? `${base}.md` : path.join(dir, `${base}.md`);
211
- return rel;
216
+ return `${slugifyPath(file)}.md`;
212
217
  }
213
218
  async function createArticle(opts) {
214
219
  const { collection, title } = parseAddTarget(opts.target);
215
220
  const { name, dir } = await resolveCollectionDir(collection);
216
- const filePath = path.join(dir, `${slugify(title)}.md`);
221
+ const filePath = path.join(dir, `${slugifyPath(title)}.md`);
217
222
  const body = opts.body ?? `# ${title}
218
223
  `;
219
- await fs2.mkdir(dir, { recursive: true });
224
+ await fs2.mkdir(path.dirname(filePath), { recursive: true });
220
225
  await fs2.writeFile(
221
226
  filePath,
222
227
  serializeArticle({ title, tags: opts.tags, body, audience: opts.audience, status: opts.status }),
@@ -562,6 +567,13 @@ function normaliseSite(raw) {
562
567
  ...home !== void 0 ? { home } : {}
563
568
  };
564
569
  }
570
+ function normaliseContext(raw) {
571
+ if (!raw || typeof raw !== "object") return void 0;
572
+ const r = raw;
573
+ if (!Array.isArray(r.collections)) return void 0;
574
+ const collections = r.collections.filter((c) => typeof c === "string");
575
+ return collections.length > 0 ? { collections } : void 0;
576
+ }
565
577
  async function readProjectConfig(dir) {
566
578
  let raw;
567
579
  try {
@@ -590,7 +602,11 @@ async function readProjectConfig(dir) {
590
602
  collections.push({ name, path: parsed.path });
591
603
  }
592
604
  const site = normaliseSite(parsed.site);
593
- return { ...site ? { site } : {}, collections };
605
+ const context = normaliseContext(parsed.context);
606
+ return { ...site ? { site } : {}, ...context ? { context } : {}, collections };
607
+ }
608
+ async function readContextCollections(dir) {
609
+ return (await readProjectConfig(dir))?.context?.collections ?? [];
594
610
  }
595
611
  async function resolveProjectCollections(dir) {
596
612
  const config = await readProjectConfig(dir);
@@ -1080,8 +1096,12 @@ function localGraph(graph, focusId, include, siblingCap = 12) {
1080
1096
 
1081
1097
  // src/site.ts
1082
1098
  var isStatic = () => process.env.DIFFWIKI_STATIC === "1";
1083
- function toTreeNodes(coll, nodes) {
1084
- const visible = isStatic() ? nodes.filter((n) => n.audience !== "private" && n.status !== "draft") : nodes;
1099
+ function toTreeNodes(coll, nodes, showSystem = false) {
1100
+ const visible = nodes.filter((n) => {
1101
+ if (!showSystem && n.audience === "system") return false;
1102
+ if (isStatic() && (n.audience === "private" || n.status === "draft")) return false;
1103
+ return true;
1104
+ });
1085
1105
  return visible.map(
1086
1106
  (n) => n.children ? {
1087
1107
  name: n.name,
@@ -1089,7 +1109,7 @@ function toTreeNodes(coll, nodes) {
1089
1109
  path: n.slug ? `/${coll}/${n.slug}` : void 0,
1090
1110
  ...n.audience ? { audience: n.audience } : {},
1091
1111
  ...n.status ? { status: n.status } : {},
1092
- children: toTreeNodes(coll, n.children)
1112
+ children: toTreeNodes(coll, n.children, showSystem)
1093
1113
  } : {
1094
1114
  name: n.name,
1095
1115
  title: n.title ?? n.name,
@@ -1107,44 +1127,50 @@ async function gitInfo(e) {
1107
1127
  const s = await collectionGitStatus(e);
1108
1128
  return s ? { branch: s.branch, behind: s.hasUpstream ? s.behind : void 0 } : {};
1109
1129
  }
1110
- async function buildCollections() {
1130
+ async function buildCollections(opts) {
1131
+ const showSystem = opts?.showSystem ?? false;
1111
1132
  const entries = await listCollections();
1112
1133
  const infos = await Promise.all(
1113
1134
  entries.map(async (e) => {
1114
- const { title, audience, status } = await collectionMeta(e.path);
1135
+ const meta = await collectionMeta(e.path);
1136
+ const audience = meta.audience ?? e.audience;
1115
1137
  return {
1116
1138
  name: e.name,
1117
1139
  type: e.type,
1118
1140
  path: e.path,
1119
- ...title ? { title } : {},
1141
+ ...meta.title ? { title: meta.title } : {},
1120
1142
  ...await gitInfo(e),
1121
1143
  ...audience ? { audience } : {},
1122
- ...status ? { status } : {}
1144
+ ...meta.status ? { status: meta.status } : {}
1123
1145
  };
1124
1146
  })
1125
1147
  );
1126
- return isStatic() ? infos.filter((c) => c.audience !== "private" && c.status !== "draft") : infos;
1148
+ const visible = showSystem ? infos : infos.filter((c) => c.audience !== "system");
1149
+ return isStatic() ? visible.filter((c) => c.audience !== "private" && c.status !== "draft") : visible;
1127
1150
  }
1128
- async function buildNavTree() {
1151
+ async function buildNavTree(opts) {
1152
+ const showSystem = opts?.showSystem ?? false;
1129
1153
  const entries = await listCollections();
1130
1154
  const trees = await Promise.all(
1131
1155
  entries.map(async (e) => {
1132
- const { title, audience, status } = await collectionMeta(e.path);
1156
+ const meta = await collectionMeta(e.path);
1157
+ const audience = meta.audience ?? e.audience;
1133
1158
  return {
1134
1159
  collection: {
1135
1160
  name: e.name,
1136
1161
  type: e.type,
1137
1162
  path: e.path,
1138
- ...title ? { title } : {},
1163
+ ...meta.title ? { title: meta.title } : {},
1139
1164
  ...await gitInfo(e),
1140
1165
  ...audience ? { audience } : {},
1141
- ...status ? { status } : {}
1166
+ ...meta.status ? { status: meta.status } : {}
1142
1167
  },
1143
- nodes: toTreeNodes(e.name, await listArticleTree(e.name))
1168
+ nodes: toTreeNodes(e.name, await listArticleTree(e.name), showSystem)
1144
1169
  };
1145
1170
  })
1146
1171
  );
1147
- return isStatic() ? trees.filter((t) => t.collection.audience !== "private" && t.collection.status !== "draft") : trees;
1172
+ const visible = showSystem ? trees : trees.filter((t) => t.collection.audience !== "system");
1173
+ return isStatic() ? visible.filter((t) => t.collection.audience !== "private" && t.collection.status !== "draft") : visible;
1148
1174
  }
1149
1175
  function findNode(nodes, slug) {
1150
1176
  for (const n of nodes) {
@@ -1267,12 +1293,20 @@ async function collectMarkdownFiles(dir) {
1267
1293
  }
1268
1294
  return files;
1269
1295
  }
1270
- async function nativeSearch(term, collections, collection) {
1296
+ function normalizeCollections(collection) {
1297
+ if (collection === void 0) return [];
1298
+ const names = Array.isArray(collection) ? collection : [collection];
1299
+ return names.map((n) => n.trim()).filter(Boolean);
1300
+ }
1301
+ async function nativeSearch(term, collections, collection, hideSystem = false) {
1271
1302
  let targets = collections;
1272
- if (collection) {
1273
- const one = collections.find((c) => c.name === collection);
1274
- if (!one) throw new CollectionNotFoundError(collection);
1275
- targets = [one];
1303
+ const names = normalizeCollections(collection);
1304
+ if (names.length > 0) {
1305
+ for (const n of names) {
1306
+ if (!collections.some((c) => c.name === n)) throw new CollectionNotFoundError(n);
1307
+ }
1308
+ const wanted = new Set(names);
1309
+ targets = collections.filter((c) => wanted.has(c.name));
1276
1310
  }
1277
1311
  const docs = [];
1278
1312
  for (const entry of targets) {
@@ -1283,6 +1317,7 @@ async function nativeSearch(term, collections, collection) {
1283
1317
  let body = "";
1284
1318
  try {
1285
1319
  const parsed = parseArticle(await fs7.readFile(full, "utf8"));
1320
+ if (hideSystem && parsed.audience === "system") continue;
1286
1321
  if (parsed.title) title = parsed.title;
1287
1322
  tags = parsed.tags;
1288
1323
  body = parsed.body;
@@ -1335,28 +1370,49 @@ async function resolveDefaultSearchMode() {
1335
1370
  }
1336
1371
  return { engine, type: searchTypes[0] };
1337
1372
  }
1373
+ async function dropSystemHits(hits) {
1374
+ const kept = [];
1375
+ for (const hit of hits) {
1376
+ try {
1377
+ if (parseArticle(await fs7.readFile(hit.path, "utf8")).audience === "system") continue;
1378
+ } catch {
1379
+ }
1380
+ kept.push(hit);
1381
+ }
1382
+ return kept;
1383
+ }
1338
1384
  async function search(term, opts) {
1339
1385
  const collections = await listCollections();
1386
+ const wanted = normalizeCollections(opts?.collection);
1387
+ for (const n of wanted) {
1388
+ if (!collections.some((c) => c.name === n)) throw new CollectionNotFoundError(n);
1389
+ }
1390
+ const showSystem = opts?.showSystem ?? await showSystemEnabled();
1391
+ const hideSystem = !showSystem && wanted.length === 0;
1392
+ const searchable = hideSystem ? collections.filter((c) => c.audience !== "system") : collections;
1393
+ const filterHits = (hits) => wanted.length > 0 ? hits.filter((h) => wanted.includes(h.collection)) : hits;
1340
1394
  const engine = opts?.engine ?? await resolveDefaultEngine();
1341
1395
  log2.debug(`resolved search engine "${engine}"`);
1342
1396
  if (engine === NATIVE_ENGINE) {
1343
- const hits = await nativeSearch(term, collections, opts?.collection);
1397
+ const hits = await nativeSearch(term, searchable, opts?.collection, hideSystem);
1344
1398
  return { hits, engine, fellBackToNative: false };
1345
1399
  }
1346
1400
  const provider = await loadSearchProvider(engine);
1347
1401
  if (!provider) {
1348
1402
  const error = `no provider found for engine "${engine}"`;
1349
1403
  log2.warn(`engine "${engine}" unavailable, falling back to native: ${error}`);
1350
- const hits = await nativeSearch(term, collections, opts?.collection);
1404
+ const hits = await nativeSearch(term, searchable, opts?.collection, hideSystem);
1351
1405
  return { hits, engine, fellBackToNative: true, error };
1352
1406
  }
1353
1407
  try {
1354
- const hits = await provider.handleSearch(term, collections, opts);
1408
+ const providerOpts = { ...opts, collection: wanted.length === 1 ? wanted[0] : void 0 };
1409
+ const raw = filterHits(await provider.handleSearch(term, searchable, providerOpts));
1410
+ const hits = hideSystem ? await dropSystemHits(raw) : raw;
1355
1411
  return { hits, engine, fellBackToNative: false };
1356
1412
  } catch (err) {
1357
1413
  const message = err instanceof Error ? err.message : String(err);
1358
1414
  log2.warn(`engine "${engine}" failed, falling back to native: ${message}`);
1359
- const hits = await nativeSearch(term, collections, opts?.collection);
1415
+ const hits = await nativeSearch(term, searchable, opts?.collection, hideSystem);
1360
1416
  return { hits, engine, fellBackToNative: true, error: message };
1361
1417
  }
1362
1418
  }
@@ -1380,6 +1436,7 @@ async function readArticleParts(collDir, slug) {
1380
1436
  }
1381
1437
  async function flatten(coll, collDir, nodes, out) {
1382
1438
  for (const n of nodes) {
1439
+ if (n.audience === "system") continue;
1383
1440
  if (isStatic2() && (n.audience === "private" || n.status === "draft")) continue;
1384
1441
  if (n.slug && !n.children) {
1385
1442
  const title = n.title ?? n.name;
@@ -1407,6 +1464,7 @@ async function buildSearchIndex(collection) {
1407
1464
  try {
1408
1465
  const collEntry = await findCollection(e.name);
1409
1466
  const collDir = path8.resolve(collEntry?.path ?? e.path);
1467
+ if (collEntry?.audience === "system") continue;
1410
1468
  if (isStatic2()) {
1411
1469
  const cm = await collectionMeta(collDir);
1412
1470
  if (cm.audience === "private" || cm.status === "draft") continue;
@@ -1739,6 +1797,151 @@ async function appendPluginDiagnostics(out, config) {
1739
1797
  }
1740
1798
  }
1741
1799
 
1800
+ // src/lint.ts
1801
+ import fs11 from "fs/promises";
1802
+ import path10 from "path";
1803
+ var BUDGETS = {
1804
+ recipe: { min: 300, max: 1e3 },
1805
+ "recipe-steps": { min: 500, max: 1500 },
1806
+ guide: { min: 1500, max: 5e3 },
1807
+ "system-design": { min: 2e3, max: 6e3 },
1808
+ "component-design": { min: 1e3, max: 3e3 },
1809
+ // Fallback for a bare `architecture` tag when system vs component isn't declared.
1810
+ architecture: { min: 1e3, max: 6e3 }
1811
+ };
1812
+ function estimateTokens(body) {
1813
+ return Math.ceil(body.trim().length / 4);
1814
+ }
1815
+ function inferShape(tags, body) {
1816
+ const t = new Set(tags.map((x) => x.toLowerCase()));
1817
+ if (t.has("guide")) return "guide";
1818
+ if (t.has("component-design") || t.has("component")) return "component-design";
1819
+ if (t.has("system-design") || t.has("system")) return "system-design";
1820
+ if (t.has("architecture")) return "architecture";
1821
+ if (/^###\s+Step\b/im.test(body)) return "recipe-steps";
1822
+ return "recipe";
1823
+ }
1824
+ function lintArticle(article) {
1825
+ const findings = [];
1826
+ const { title, tags, status, body } = article;
1827
+ const shape = inferShape(tags, body);
1828
+ const tokens = estimateTokens(body);
1829
+ const budget = BUDGETS[shape];
1830
+ if (!title || !title.trim()) {
1831
+ findings.push({ level: "error", rule: "frontmatter-title", message: "missing frontmatter title" });
1832
+ }
1833
+ if (tags.map((x) => x.toLowerCase()).includes("diffwiki-template")) {
1834
+ findings.push({
1835
+ level: "error",
1836
+ rule: "template-tag-leak",
1837
+ message: "instance still tagged diffwiki-template"
1838
+ });
1839
+ }
1840
+ if (tags.length === 0) {
1841
+ findings.push({ level: "warn", rule: "no-tags", message: "no tags -- add topic tags for recall" });
1842
+ }
1843
+ const hasRefsSection = /^##\s+References\b/im.test(body);
1844
+ const hasInlineLink = /\]\([^)]+\)/.test(body);
1845
+ if (!hasRefsSection && !hasInlineLink) {
1846
+ findings.push({
1847
+ level: "error",
1848
+ rule: "no-references",
1849
+ message: "has no ## References section and no inline links"
1850
+ });
1851
+ }
1852
+ if ((shape === "guide" || shape === "architecture") && !hasRefsSection) {
1853
+ findings.push({
1854
+ level: "warn",
1855
+ rule: "missing-references-section",
1856
+ message: `${shape} should end with a ## References section`
1857
+ });
1858
+ }
1859
+ if (tokens > budget.max) {
1860
+ findings.push({
1861
+ level: "error",
1862
+ rule: "token-budget",
1863
+ message: `body ~${tokens} tokens exceeds ${shape} budget (${budget.max})`
1864
+ });
1865
+ } else if (tokens < budget.min) {
1866
+ findings.push({
1867
+ level: "warn",
1868
+ rule: "token-thin",
1869
+ message: `body ~${tokens} tokens is thin for ${shape} (min ${budget.min})`
1870
+ });
1871
+ }
1872
+ if (status === "draft") {
1873
+ findings.push({ level: "warn", rule: "status-draft", message: "status is still draft" });
1874
+ }
1875
+ if (/\*\*Load when:\*\*/i.test(body)) {
1876
+ findings.push({
1877
+ level: "warn",
1878
+ rule: "load-when-prefix",
1879
+ message: "remove the **Load when:** prefix (implicit trigger)"
1880
+ });
1881
+ }
1882
+ if (/^[A-Za-z0-9][\w .+-]*:\s/.test(title)) {
1883
+ findings.push({ level: "warn", rule: "title-prefix", message: `title "${title}" repeats a path-like prefix` });
1884
+ }
1885
+ return findings;
1886
+ }
1887
+ async function walkMarkdown(dir) {
1888
+ let entries;
1889
+ try {
1890
+ entries = await fs11.readdir(dir, { withFileTypes: true });
1891
+ } catch {
1892
+ return [];
1893
+ }
1894
+ const out = [];
1895
+ for (const e of entries) {
1896
+ const full = path10.join(dir, e.name);
1897
+ if (e.isDirectory()) out.push(...await walkMarkdown(full));
1898
+ else if (e.isFile() && /\.mdx?$/.test(e.name) && !/-template\.mdx?$/.test(e.name)) out.push(full);
1899
+ }
1900
+ return out;
1901
+ }
1902
+ async function resolveScope(scope) {
1903
+ const all = await listCollections();
1904
+ if (scope === "all") return all.filter((c) => c.audience !== "system");
1905
+ if (scope === "current") {
1906
+ const cur = await resolveCwdCollection(process.cwd());
1907
+ if (!cur) throw new Error("No collection for the current directory -- pass -c <name> or -c all.");
1908
+ return [cur];
1909
+ }
1910
+ const names = scope.split(",").map((s) => s.trim()).filter(Boolean);
1911
+ const chosen = [];
1912
+ for (const n of names) {
1913
+ const hit = all.find((c) => c.name === n);
1914
+ if (!hit) throw new CollectionNotFoundError(n);
1915
+ chosen.push(hit);
1916
+ }
1917
+ return chosen;
1918
+ }
1919
+ async function lintCollections(scope) {
1920
+ const collections = await resolveScope(scope);
1921
+ const files = [];
1922
+ let errorCount = 0;
1923
+ let warnCount = 0;
1924
+ for (const coll of collections) {
1925
+ for (const full of await walkMarkdown(coll.path)) {
1926
+ const rel = path10.relative(coll.path, full);
1927
+ const label = `${coll.name}/${rel}`;
1928
+ let findings = [];
1929
+ try {
1930
+ const parsed = parseArticle(await fs11.readFile(full, "utf8"));
1931
+ findings = lintArticle(parsed);
1932
+ } catch (err) {
1933
+ findings = [{ level: "error", rule: "parse", message: `could not parse: ${err.message}` }];
1934
+ }
1935
+ for (const f of findings) {
1936
+ if (f.level === "error") errorCount++;
1937
+ else warnCount++;
1938
+ }
1939
+ files.push({ path: label, findings });
1940
+ }
1941
+ }
1942
+ return { files, fileCount: files.length, errorCount, warnCount };
1943
+ }
1944
+
1742
1945
  // src/edit.ts
1743
1946
  async function resolveEditTarget(target, cwd) {
1744
1947
  if (target && target.trim()) {
@@ -1829,6 +2032,8 @@ export {
1829
2032
  installPlugin,
1830
2033
  invalidateLinkGraph,
1831
2034
  isLinkedWorktree,
2035
+ lintArticle,
2036
+ lintCollections,
1832
2037
  listArticleTree,
1833
2038
  listAvailablePlugins,
1834
2039
  listCollections,
@@ -1847,6 +2052,7 @@ export {
1847
2052
  rankDocs,
1848
2053
  readArticle,
1849
2054
  readConfig,
2055
+ readContextCollections,
1850
2056
  readPluginRegistry,
1851
2057
  readProjectConfig,
1852
2058
  readRegistry,
@@ -1877,7 +2083,9 @@ export {
1877
2083
  setArticleMeta,
1878
2084
  setConfigValue,
1879
2085
  setTags,
2086
+ showSystemEnabled,
1880
2087
  slugify,
2088
+ slugifyPath,
1881
2089
  toTreeNodes,
1882
2090
  tokenize,
1883
2091
  unregisterCollection,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "diffwiki-core",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Core configuration, registry, and article management for diffwiki",
5
5
  "license": "MIT",
6
6
  "type": "module",