diffwiki-core 0.6.0 → 0.6.1-rc.202607250144.696649b

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.cjs CHANGED
@@ -577,6 +577,7 @@ __export(index_exports, {
577
577
  addTags: () => addTags,
578
578
  buildArticleTree: () => buildArticleTree,
579
579
  buildCollections: () => buildCollections,
580
+ buildLinkGraph: () => buildLinkGraph,
580
581
  buildNavTree: () => buildNavTree,
581
582
  buildSearchIndex: () => buildSearchIndex,
582
583
  collectionDir: () => collectionDir,
@@ -591,6 +592,7 @@ __export(index_exports, {
591
592
  emitPluginEvent: () => emitPluginEvent,
592
593
  ensureGitRepo: () => ensureGitRepo,
593
594
  ensurePluginRoot: () => ensurePluginRoot,
595
+ fileTimestamps: () => fileTimestamps,
594
596
  findCollection: () => findCollection,
595
597
  findCollectionById: () => findCollectionById,
596
598
  findEnabledPlugin: () => findEnabledPlugin,
@@ -600,6 +602,7 @@ __export(index_exports, {
600
602
  gitToplevel: () => gitToplevel,
601
603
  initRepoWiki: () => initRepoWiki,
602
604
  installPlugin: () => installPlugin,
605
+ invalidateLinkGraph: () => invalidateLinkGraph,
603
606
  isLinkedWorktree: () => isLinkedWorktree,
604
607
  listArticleTree: () => listArticleTree,
605
608
  listAvailablePlugins: () => listAvailablePlugins,
@@ -608,6 +611,7 @@ __export(index_exports, {
608
611
  listPlugins: () => listPlugins,
609
612
  listSearchModes: () => listSearchModes,
610
613
  loadSearchProvider: () => loadSearchProvider,
614
+ localGraph: () => localGraph,
611
615
  nativeSearch: () => nativeSearch,
612
616
  parseAddTarget: () => parseAddTarget,
613
617
  parseArticle: () => parseArticle,
@@ -755,8 +759,6 @@ function parseArticle(raw) {
755
759
  }
756
760
  function serializeArticle(article) {
757
761
  const front = { title: article.title, tags: article.tags };
758
- if (article.created) front.created = article.created;
759
- if (article.updated) front.updated = article.updated;
760
762
  return import_gray_matter.default.stringify(article.body, front, MATTER_OPTS);
761
763
  }
762
764
 
@@ -816,11 +818,10 @@ async function createArticle(opts) {
816
818
  const { collection, title } = parseAddTarget(opts.target);
817
819
  const { name, dir } = await resolveCollectionDir(collection);
818
820
  const filePath = import_node_path3.default.join(dir, `${slugify(title)}.md`);
819
- const now = (/* @__PURE__ */ new Date()).toISOString();
820
821
  const body = opts.body ?? `# ${title}
821
822
  `;
822
823
  await import_promises5.default.mkdir(dir, { recursive: true });
823
- await import_promises5.default.writeFile(filePath, serializeArticle({ title, tags: opts.tags, created: now, updated: now, body }), "utf8");
824
+ await import_promises5.default.writeFile(filePath, serializeArticle({ title, tags: opts.tags, body }), "utf8");
824
825
  void fireHook2("article-created", name, import_node_path3.default.relative(dir, filePath));
825
826
  return { title, tags: opts.tags, path: filePath, collection: name };
826
827
  }
@@ -838,17 +839,7 @@ async function loadArticle(target) {
838
839
  async function updateArticleBody(target, body) {
839
840
  const { filePath, dir, raw, collection } = await loadArticle(target);
840
841
  const parsed = parseArticle(raw);
841
- await import_promises5.default.writeFile(
842
- filePath,
843
- serializeArticle({
844
- title: parsed.title,
845
- tags: parsed.tags,
846
- created: parsed.created,
847
- updated: (/* @__PURE__ */ new Date()).toISOString(),
848
- body
849
- }),
850
- "utf8"
851
- );
842
+ await import_promises5.default.writeFile(filePath, serializeArticle({ title: parsed.title, tags: parsed.tags, body }), "utf8");
852
843
  void fireHook2("article-updated", collection, import_node_path3.default.relative(dir, filePath));
853
844
  return { title: parsed.title, tags: parsed.tags, path: filePath, collection };
854
845
  }
@@ -856,17 +847,7 @@ async function mutateTags(target, fn) {
856
847
  const { filePath, dir, raw, collection } = await loadArticle(target);
857
848
  const parsed = parseArticle(raw);
858
849
  const tags = fn(parsed.tags);
859
- await import_promises5.default.writeFile(
860
- filePath,
861
- serializeArticle({
862
- title: parsed.title,
863
- tags,
864
- created: parsed.created,
865
- updated: (/* @__PURE__ */ new Date()).toISOString(),
866
- body: parsed.body
867
- }),
868
- "utf8"
869
- );
850
+ await import_promises5.default.writeFile(filePath, serializeArticle({ title: parsed.title, tags, body: parsed.body }), "utf8");
870
851
  void fireHook2("article-updated", collection, import_node_path3.default.relative(dir, filePath));
871
852
  return { title: parsed.title, tags, path: filePath, collection };
872
853
  }
@@ -888,7 +869,7 @@ async function removeArticle(target) {
888
869
  }
889
870
 
890
871
  // src/repo.ts
891
- var import_promises6 = __toESM(require("fs/promises"), 1);
872
+ var import_promises7 = __toESM(require("fs/promises"), 1);
892
873
  var import_node_path5 = __toESM(require("path"), 1);
893
874
  var import_node_child_process3 = require("child_process");
894
875
  var import_node_util2 = require("util");
@@ -897,6 +878,7 @@ init_collections();
897
878
  init_registry();
898
879
 
899
880
  // src/git.ts
881
+ var import_promises6 = __toESM(require("fs/promises"), 1);
900
882
  var import_node_path4 = __toESM(require("path"), 1);
901
883
  var import_node_child_process2 = require("child_process");
902
884
  var import_node_util = require("util");
@@ -959,6 +941,35 @@ async function ensureGitRepo(cwd) {
959
941
  if (!after) throw new Error("git init did not produce a repository");
960
942
  return after;
961
943
  }
944
+ function isEpochish(d) {
945
+ return !Number.isFinite(d.getTime()) || d.getTime() <= 0;
946
+ }
947
+ var timestampCache = /* @__PURE__ */ new Map();
948
+ async function fileTimestamps(absPath) {
949
+ const cached2 = timestampCache.get(absPath);
950
+ if (cached2) return cached2;
951
+ const dir = import_node_path4.default.dirname(absPath);
952
+ const base = import_node_path4.default.basename(absPath);
953
+ const [addLog, lastLog] = await Promise.all([
954
+ git(dir, ["log", "--follow", "--diff-filter=A", "--format=%aI", "--", base]),
955
+ git(dir, ["log", "-1", "--format=%aI", "--", base])
956
+ ]);
957
+ const gitCreated = addLog ? addLog.split("\n").filter(Boolean).pop() : void 0;
958
+ const gitUpdated = lastLog || void 0;
959
+ let created = gitCreated;
960
+ let updated = gitUpdated;
961
+ if (!created || !updated) {
962
+ try {
963
+ const st = await import_promises6.default.stat(absPath);
964
+ if (!updated) updated = st.mtime.toISOString();
965
+ if (!created) created = isEpochish(st.birthtime) ? st.mtime.toISOString() : st.birthtime.toISOString();
966
+ } catch {
967
+ }
968
+ }
969
+ const result = { created, updated };
970
+ timestampCache.set(absPath, result);
971
+ return result;
972
+ }
962
973
 
963
974
  // src/repo.ts
964
975
  init_errors();
@@ -974,7 +985,7 @@ async function detectRepoName(cwd) {
974
985
  }
975
986
  async function readRepoConfig(cwd) {
976
987
  try {
977
- const raw = await import_promises6.default.readFile(import_node_path5.default.join(cwd, REPO_CONFIG_FILE), "utf8");
988
+ const raw = await import_promises7.default.readFile(import_node_path5.default.join(cwd, REPO_CONFIG_FILE), "utf8");
978
989
  const parsed = (0, import_yaml2.parse)(raw) ?? {};
979
990
  if (!parsed.path) return void 0;
980
991
  return {
@@ -1004,9 +1015,9 @@ async function initRepoWiki(opts) {
1004
1015
  n += 1;
1005
1016
  }
1006
1017
  }
1007
- await import_promises6.default.mkdir(absWiki, { recursive: true });
1018
+ await import_promises7.default.mkdir(absWiki, { recursive: true });
1008
1019
  const config = { collection: name, path: wikiPath };
1009
- await import_promises6.default.writeFile(import_node_path5.default.join(cwd, REPO_CONFIG_FILE), (0, import_yaml2.stringify)(config), "utf8");
1020
+ await import_promises7.default.writeFile(import_node_path5.default.join(cwd, REPO_CONFIG_FILE), (0, import_yaml2.stringify)(config), "utf8");
1010
1021
  const entry = {
1011
1022
  name,
1012
1023
  type: "repo",
@@ -1078,7 +1089,7 @@ async function collectionGitStatus(entry) {
1078
1089
  init_collections();
1079
1090
 
1080
1091
  // src/browse.ts
1081
- var import_promises7 = __toESM(require("fs/promises"), 1);
1092
+ var import_promises8 = __toESM(require("fs/promises"), 1);
1082
1093
  var import_node_path6 = __toESM(require("path"), 1);
1083
1094
  init_collections();
1084
1095
  init_errors();
@@ -1125,7 +1136,7 @@ function buildArticleTree(relPaths) {
1125
1136
  async function collectRelPaths(dir, rel = "") {
1126
1137
  let dirents;
1127
1138
  try {
1128
- dirents = await import_promises7.default.readdir(dir, { withFileTypes: true });
1139
+ dirents = await import_promises8.default.readdir(dir, { withFileTypes: true });
1129
1140
  } catch {
1130
1141
  return [];
1131
1142
  }
@@ -1143,7 +1154,7 @@ async function collectRelPaths(dir, rel = "") {
1143
1154
  }
1144
1155
  async function readdirSafe(dir) {
1145
1156
  try {
1146
- return await import_promises7.default.readdir(dir);
1157
+ return await import_promises8.default.readdir(dir);
1147
1158
  } catch {
1148
1159
  return [];
1149
1160
  }
@@ -1160,7 +1171,7 @@ function pickIndexFile(names) {
1160
1171
  async function cheapTitle(absPath) {
1161
1172
  let handle;
1162
1173
  try {
1163
- handle = await import_promises7.default.open(absPath, "r");
1174
+ handle = await import_promises8.default.open(absPath, "r");
1164
1175
  const buf = Buffer.alloc(512);
1165
1176
  const { bytesRead } = await handle.read(buf, 0, 512, 0);
1166
1177
  const snippet = buf.subarray(0, bytesRead).toString("utf8");
@@ -1210,11 +1221,12 @@ async function readArticle(coll, slug) {
1210
1221
  const safeSlug = normalized.split("/").filter((s) => s !== "" && s !== ".").join("/");
1211
1222
  const inJail = (p) => p === collDir || p.startsWith(collDir + import_node_path6.default.sep);
1212
1223
  let abs;
1224
+ let isIndex = false;
1213
1225
  for (const rel of safeSlug ? [`${safeSlug}.mdx`, `${safeSlug}.md`] : []) {
1214
1226
  const candidate = import_node_path6.default.resolve(collDir, rel);
1215
1227
  if (!inJail(candidate)) throw new InvalidTargetError(slug, "path traversal detected");
1216
1228
  try {
1217
- await import_promises7.default.access(candidate);
1229
+ await import_promises8.default.access(candidate);
1218
1230
  abs = candidate;
1219
1231
  break;
1220
1232
  } catch {
@@ -1224,16 +1236,20 @@ async function readArticle(coll, slug) {
1224
1236
  const dir = import_node_path6.default.resolve(collDir, safeSlug);
1225
1237
  if (inJail(dir)) {
1226
1238
  const idx = pickIndexFile(await readdirSafe(dir));
1227
- if (idx) abs = import_node_path6.default.join(dir, idx);
1239
+ if (idx) {
1240
+ abs = import_node_path6.default.join(dir, idx);
1241
+ isIndex = true;
1242
+ }
1228
1243
  }
1229
1244
  }
1230
1245
  if (!abs) throw new ArticleNotFoundError(`${coll}/${safeSlug}`);
1231
- const parsed = parseArticle(await import_promises7.default.readFile(abs, "utf8"));
1246
+ const parsed = parseArticle(await import_promises8.default.readFile(abs, "utf8"));
1232
1247
  const title = parsed.title?.trim() || (safeSlug ? safeSlug.split("/").pop() ?? safeSlug : coll);
1233
- return { coll, slug: safeSlug, title, tags: parsed.tags, body: parsed.body, path: abs };
1248
+ return { coll, slug: safeSlug, title, tags: parsed.tags, body: parsed.body, path: abs, isIndex };
1234
1249
  }
1235
1250
 
1236
1251
  // src/render.ts
1252
+ var import_node_path7 = __toESM(require("path"), 1);
1237
1253
  var import_unified = require("unified");
1238
1254
  var import_remark_parse = __toESM(require("remark-parse"), 1);
1239
1255
  var import_remark_gfm = __toESM(require("remark-gfm"), 1);
@@ -1243,6 +1259,36 @@ var import_rehype = __toESM(require("@shikijs/rehype"), 1);
1243
1259
  var import_rehype_stringify = __toESM(require("rehype-stringify"), 1);
1244
1260
  var import_unist_util_visit = require("unist-util-visit");
1245
1261
  var import_hast_util_to_string = require("hast-util-to-string");
1262
+ function isNonRelativeHref(href) {
1263
+ if (href === "") return true;
1264
+ if (href.startsWith("/") || href.startsWith("#") || href.startsWith("?")) return true;
1265
+ if (href.startsWith("//")) return true;
1266
+ return /^[a-z][a-z0-9+.-]*:/i.test(href);
1267
+ }
1268
+ function rehypeRewriteLinks(ctx) {
1269
+ const slugDir = ctx.isIndex ? ctx.slug || "." : import_node_path7.default.posix.dirname(ctx.slug || ".");
1270
+ return (tree) => {
1271
+ (0, import_unist_util_visit.visit)(tree, "element", (node) => {
1272
+ if (node.tagName !== "a") return;
1273
+ const raw = node.properties?.href;
1274
+ if (typeof raw !== "string" || isNonRelativeHref(raw)) return;
1275
+ const suffixMatch = /[#?].*$/.exec(raw);
1276
+ const suffix = suffixMatch ? suffixMatch[0] : "";
1277
+ const pathPart = suffix ? raw.slice(0, raw.length - suffix.length) : raw;
1278
+ if (pathPart === "") return;
1279
+ const joined = import_node_path7.default.posix.join(slugDir === "." ? "" : slugDir, pathPart);
1280
+ const normalized = import_node_path7.default.posix.normalize(joined);
1281
+ if (normalized.startsWith("..")) return;
1282
+ let resolved = normalized.replace(/\.(mdx?)$/i, "");
1283
+ resolved = resolved.replace(/(^|\/)(index|readme)$/i, "$1");
1284
+ resolved = resolved.replace(/\/+$/, "").replace(/^\/+/, "");
1285
+ node.properties = {
1286
+ ...node.properties,
1287
+ href: `/${ctx.coll}${resolved ? `/${resolved}` : ""}${suffix}`
1288
+ };
1289
+ });
1290
+ };
1291
+ }
1246
1292
  var LANG_ALIASES = {
1247
1293
  "c#": "csharp",
1248
1294
  cs: "csharp",
@@ -1261,6 +1307,56 @@ function rehypeCollectToc(toc) {
1261
1307
  });
1262
1308
  };
1263
1309
  }
1310
+ function isExternalHref(href) {
1311
+ if (!href) return true;
1312
+ if (href.startsWith("#")) return true;
1313
+ if (href.startsWith("//")) return true;
1314
+ return /^[a-z][a-z0-9+.-]*:/i.test(href);
1315
+ }
1316
+ function resolveHref(href, ctx) {
1317
+ if (isExternalHref(href)) return null;
1318
+ const clean = (p) => p.replace(/[?#].*$/, "").replace(/\.(mdx?|html)$/i, "").replace(/\/+$/, "");
1319
+ const target = clean(href);
1320
+ if (!target) return null;
1321
+ if (target.startsWith("/")) {
1322
+ const base = ctx.base ?? process.env.DIFFWIKI_BASE ?? "";
1323
+ const baseSegs2 = base.split("/").filter(Boolean);
1324
+ let segs = target.split("/").filter(Boolean);
1325
+ if (baseSegs2.length > 0 && baseSegs2.every((b, i) => segs[i] === b)) {
1326
+ segs = segs.slice(baseSegs2.length);
1327
+ }
1328
+ if (segs.length < 2) return null;
1329
+ return segs.join("/");
1330
+ }
1331
+ const dir = ctx.isIndex ? ctx.slug : ctx.slug.includes("/") ? ctx.slug.slice(0, ctx.slug.lastIndexOf("/")) : "";
1332
+ const baseSegs = dir ? dir.split("/") : [];
1333
+ const out = [...baseSegs];
1334
+ for (const seg of target.split("/")) {
1335
+ if (seg === "" || seg === ".") continue;
1336
+ if (seg === "..") {
1337
+ if (out.length === 0) return null;
1338
+ out.pop();
1339
+ continue;
1340
+ }
1341
+ out.push(seg);
1342
+ }
1343
+ if (out.length === 0) return null;
1344
+ return `${ctx.coll}/${out.join("/")}`;
1345
+ }
1346
+ function rehypeCollectLinks(links, ctx) {
1347
+ const seen = /* @__PURE__ */ new Set();
1348
+ return (tree) => {
1349
+ (0, import_unist_util_visit.visit)(tree, "element", (node) => {
1350
+ if (node.tagName !== "a") return;
1351
+ const href = node.properties?.href;
1352
+ if (typeof href !== "string") return;
1353
+ const id = resolveHref(href, ctx);
1354
+ if (!id || seen.has(id)) return;
1355
+ seen.add(id);
1356
+ links.push(id);
1357
+ });
1358
+ };
1359
+ }
1264
1360
  function rehypeStripLeadingH1() {
1265
1361
  return (tree) => {
1266
1362
  let removed = false;
@@ -1286,15 +1382,114 @@ function rehypeMarkMermaid() {
1286
1382
  });
1287
1383
  };
1288
1384
  }
1289
- async function renderArticle(body) {
1385
+ async function renderArticle(body, ctx) {
1290
1386
  const toc = [];
1291
- const file = await (0, import_unified.unified)().use(import_remark_parse.default).use(import_remark_gfm.default).use(import_remark_rehype.default).use(import_rehype_slug.default).use(rehypeCollectToc, toc).use(rehypeStripLeadingH1).use(rehypeMarkMermaid).use(import_rehype.default, {
1387
+ const links = [];
1388
+ const pipeline = (0, import_unified.unified)().use(import_remark_parse.default).use(import_remark_gfm.default).use(import_remark_rehype.default).use(import_rehype_slug.default).use(rehypeCollectToc, toc).use(rehypeCollectLinks, links, ctx);
1389
+ const file = await pipeline.use(rehypeStripLeadingH1).use(rehypeMarkMermaid).use(rehypeRewriteLinks, ctx).use(import_rehype.default, {
1292
1390
  themes: { light: "github-light", dark: "github-dark" },
1293
1391
  defaultColor: false,
1294
1392
  langAlias: LANG_ALIASES,
1295
1393
  fallbackLanguage: "plaintext"
1296
1394
  }).use(import_rehype_stringify.default).process(body);
1297
- return { html: String(file), toc };
1395
+ return { html: String(file), toc, links };
1396
+ }
1397
+
1398
+ // src/graph.ts
1399
+ init_collections();
1400
+ init_errors();
1401
+ function tagId(tag) {
1402
+ return `tag:${tag}`;
1403
+ }
1404
+ async function collectArticle(coll, slug, fallbackTitle, outNodes, outEdges, outTags) {
1405
+ let art;
1406
+ try {
1407
+ art = await readArticle(coll, slug);
1408
+ } catch (err) {
1409
+ if (err instanceof ArticleNotFoundError) return;
1410
+ throw err;
1411
+ }
1412
+ const id = `${coll}/${slug}`;
1413
+ outNodes.push({ id, kind: "article", coll, slug, title: art.title ?? fallbackTitle ?? slug });
1414
+ const { links } = await renderArticle(art.body, { coll, slug, isIndex: art.isIndex });
1415
+ for (const target of links) outEdges.push({ source: id, target, kind: "link" });
1416
+ for (const tag of art.tags) outTags.push({ source: id, tag });
1417
+ }
1418
+ async function collectCollection(coll, nodes, outNodes, outEdges, outTags) {
1419
+ for (const n of nodes) {
1420
+ if (n.slug) await collectArticle(coll, n.slug, n.title ?? n.name, outNodes, outEdges, outTags);
1421
+ if (n.children) await collectCollection(coll, n.children, outNodes, outEdges, outTags);
1422
+ }
1423
+ }
1424
+ var cached;
1425
+ function invalidateLinkGraph() {
1426
+ cached = void 0;
1427
+ }
1428
+ async function buildLinkGraph() {
1429
+ if (cached) return cached;
1430
+ const nodes = [];
1431
+ const rawEdges = [];
1432
+ const rawTags = [];
1433
+ for (const e of await listCollections()) {
1434
+ try {
1435
+ await collectArticle(e.name, "", e.name, nodes, rawEdges, rawTags);
1436
+ await collectCollection(e.name, await listArticleTree(e.name), nodes, rawEdges, rawTags);
1437
+ } catch {
1438
+ }
1439
+ }
1440
+ const nodeIds = new Set(nodes.map((n) => n.id));
1441
+ const seen = /* @__PURE__ */ new Set();
1442
+ const edges = [];
1443
+ for (const edge of rawEdges) {
1444
+ if (edge.source === edge.target) continue;
1445
+ if (!nodeIds.has(edge.target)) continue;
1446
+ const key = `${edge.source} ${edge.target}`;
1447
+ if (seen.has(key)) continue;
1448
+ seen.add(key);
1449
+ edges.push(edge);
1450
+ }
1451
+ const tagSeen = /* @__PURE__ */ new Set();
1452
+ for (const { source, tag } of rawTags) {
1453
+ if (!nodeIds.has(source)) continue;
1454
+ const id = tagId(tag);
1455
+ if (!tagSeen.has(id)) {
1456
+ tagSeen.add(id);
1457
+ nodes.push({ id, kind: "tag", coll: "", slug: "", title: `#${tag}` });
1458
+ }
1459
+ const key = `${source} ${id}`;
1460
+ if (seen.has(key)) continue;
1461
+ seen.add(key);
1462
+ edges.push({ source, target: id, kind: "tag" });
1463
+ }
1464
+ cached = { nodes, edges };
1465
+ return cached;
1466
+ }
1467
+ function localGraph(graph, focusId, include, siblingCap = 12) {
1468
+ const scoped = include ? {
1469
+ // Tag hubs survive the filter — never a page, so they can't 404.
1470
+ nodes: graph.nodes.filter((n) => n.kind === "tag" || include.has(n.coll)),
1471
+ edges: graph.edges
1472
+ } : graph;
1473
+ const byId = new Map(scoped.nodes.map((n) => [n.id, n]));
1474
+ const focus = byId.get(focusId);
1475
+ if (!focus) return { focus: focusId, nodes: [], edges: [] };
1476
+ const keep = /* @__PURE__ */ new Set([focusId]);
1477
+ for (const e of scoped.edges) {
1478
+ if (e.source === focusId && byId.has(e.target)) keep.add(e.target);
1479
+ if (e.target === focusId && byId.has(e.source)) keep.add(e.source);
1480
+ }
1481
+ const focusTagHubs = new Set([...keep].filter((id) => byId.get(id)?.kind === "tag"));
1482
+ let siblings = 0;
1483
+ for (const e of scoped.edges) {
1484
+ if (siblings >= siblingCap) break;
1485
+ if (e.kind === "tag" && focusTagHubs.has(e.target) && byId.has(e.source) && !keep.has(e.source)) {
1486
+ keep.add(e.source);
1487
+ siblings++;
1488
+ }
1489
+ }
1490
+ const nodes = scoped.nodes.filter((n) => keep.has(n.id));
1491
+ const edges = scoped.edges.filter((e) => keep.has(e.source) && keep.has(e.target));
1492
+ return { focus: focusId, nodes, edges };
1298
1493
  }
1299
1494
 
1300
1495
  // src/site.ts
@@ -1348,10 +1543,16 @@ function findNode(nodes, slug) {
1348
1543
  }
1349
1544
  return void 0;
1350
1545
  }
1351
- async function resolvePage(coll, slug) {
1546
+ async function resolvePage(coll, slug, opts) {
1352
1547
  try {
1353
1548
  const art = await readArticle(coll, slug);
1354
- const { html, toc } = await renderArticle(art.body);
1549
+ const { html, toc } = await renderArticle(art.body, {
1550
+ coll: art.coll,
1551
+ slug: art.slug,
1552
+ isIndex: art.isIndex
1553
+ });
1554
+ const { created, updated } = await fileTimestamps(art.path);
1555
+ const graph = localGraph(await buildLinkGraph(), `${art.coll}/${art.slug}`, opts?.include);
1355
1556
  return {
1356
1557
  kind: "doc",
1357
1558
  coll: art.coll,
@@ -1360,7 +1561,10 @@ async function resolvePage(coll, slug) {
1360
1561
  tags: art.tags,
1361
1562
  html,
1362
1563
  toc,
1363
- path: `/${art.coll}/${art.slug}`
1564
+ path: `/${art.coll}/${art.slug}`,
1565
+ created,
1566
+ updated,
1567
+ graph
1364
1568
  };
1365
1569
  } catch (err) {
1366
1570
  if (err instanceof CollectionNotFoundError) return null;
@@ -1418,8 +1622,8 @@ function resolveCollectionSelection(all, sel = {}) {
1418
1622
  }
1419
1623
 
1420
1624
  // src/query.ts
1421
- var import_promises8 = __toESM(require("fs/promises"), 1);
1422
- var import_node_path7 = __toESM(require("path"), 1);
1625
+ var import_promises9 = __toESM(require("fs/promises"), 1);
1626
+ var import_node_path8 = __toESM(require("path"), 1);
1423
1627
  init_collections();
1424
1628
  init_errors();
1425
1629
  init_logger();
@@ -1488,13 +1692,13 @@ var log2 = getLogger("query");
1488
1692
  async function collectMarkdownFiles(dir) {
1489
1693
  let entries;
1490
1694
  try {
1491
- entries = await import_promises8.default.readdir(dir, { withFileTypes: true });
1695
+ entries = await import_promises9.default.readdir(dir, { withFileTypes: true });
1492
1696
  } catch {
1493
1697
  return [];
1494
1698
  }
1495
1699
  const files = [];
1496
1700
  for (const entry of entries) {
1497
- const full = import_node_path7.default.join(dir, entry.name);
1701
+ const full = import_node_path8.default.join(dir, entry.name);
1498
1702
  if (entry.isDirectory()) {
1499
1703
  files.push(...await collectMarkdownFiles(full));
1500
1704
  } else if (entry.isFile() && /\.mdx?$/.test(entry.name)) {
@@ -1513,12 +1717,12 @@ async function nativeSearch(term, collections, collection) {
1513
1717
  const docs = [];
1514
1718
  for (const entry of targets) {
1515
1719
  for (const full of await collectMarkdownFiles(entry.path)) {
1516
- const fallbackTitle = import_node_path7.default.basename(full).replace(/\.mdx?$/, "");
1720
+ const fallbackTitle = import_node_path8.default.basename(full).replace(/\.mdx?$/, "");
1517
1721
  let title = fallbackTitle;
1518
1722
  let tags = [];
1519
1723
  let body = "";
1520
1724
  try {
1521
- const parsed = parseArticle(await import_promises8.default.readFile(full, "utf8"));
1725
+ const parsed = parseArticle(await import_promises9.default.readFile(full, "utf8"));
1522
1726
  if (parsed.title) title = parsed.title;
1523
1727
  tags = parsed.tags;
1524
1728
  body = parsed.body;
@@ -1601,13 +1805,13 @@ async function query(term, opts) {
1601
1805
  }
1602
1806
 
1603
1807
  // src/search.ts
1604
- var import_promises9 = __toESM(require("fs/promises"), 1);
1605
- var import_node_path8 = __toESM(require("path"), 1);
1808
+ var import_promises10 = __toESM(require("fs/promises"), 1);
1809
+ var import_node_path9 = __toESM(require("path"), 1);
1606
1810
  init_collections();
1607
1811
  async function readArticleParts(collDir, slug) {
1608
1812
  for (const ext of [".mdx", ".md"]) {
1609
1813
  try {
1610
- const parsed = parseArticle(await import_promises9.default.readFile(import_node_path8.default.join(collDir, `${slug}${ext}`), "utf8"));
1814
+ const parsed = parseArticle(await import_promises10.default.readFile(import_node_path9.default.join(collDir, `${slug}${ext}`), "utf8"));
1611
1815
  return { tags: parsed.tags, body: parsed.body };
1612
1816
  } catch {
1613
1817
  }
@@ -1641,7 +1845,7 @@ async function buildSearchIndex(collection) {
1641
1845
  for (const e of entries) {
1642
1846
  try {
1643
1847
  const collEntry = await findCollection(e.name);
1644
- const collDir = import_node_path8.default.resolve(collEntry?.path ?? e.path);
1848
+ const collDir = import_node_path9.default.resolve(collEntry?.path ?? e.path);
1645
1849
  await flatten(e.name, collDir, await listArticleTree(e.name), out);
1646
1850
  } catch {
1647
1851
  }
@@ -1650,15 +1854,15 @@ async function buildSearchIndex(collection) {
1650
1854
  }
1651
1855
 
1652
1856
  // src/doctor.ts
1653
- var import_promises11 = __toESM(require("fs/promises"), 1);
1857
+ var import_promises12 = __toESM(require("fs/promises"), 1);
1654
1858
  init_paths();
1655
1859
  init_registry();
1656
1860
  init_collections();
1657
1861
 
1658
1862
  // src/plugins/manager.ts
1659
- var import_promises10 = __toESM(require("fs/promises"), 1);
1863
+ var import_promises11 = __toESM(require("fs/promises"), 1);
1660
1864
  var import_node_os2 = __toESM(require("os"), 1);
1661
- var import_node_path10 = __toESM(require("path"), 1);
1865
+ var import_node_path11 = __toESM(require("path"), 1);
1662
1866
  init_paths();
1663
1867
  init_errors();
1664
1868
  init_collections();
@@ -1669,7 +1873,7 @@ init_host();
1669
1873
  var import_node_child_process4 = require("child_process");
1670
1874
  var import_node_util3 = require("util");
1671
1875
  var import_node_fs = require("fs");
1672
- var import_node_path9 = require("path");
1876
+ var import_node_path10 = require("path");
1673
1877
  var import_node_url = require("url");
1674
1878
  var execFileAsync3 = (0, import_node_util3.promisify)(import_node_child_process4.execFile);
1675
1879
  async function runProcess(file, args, opts) {
@@ -1685,7 +1889,7 @@ async function runProcess(file, args, opts) {
1685
1889
  // src/plugins/manager.ts
1686
1890
  async function readPackageJson(file) {
1687
1891
  try {
1688
- return JSON.parse(await import_promises10.default.readFile(file, "utf8"));
1892
+ return JSON.parse(await import_promises11.default.readFile(file, "utf8"));
1689
1893
  } catch {
1690
1894
  return null;
1691
1895
  }
@@ -1701,7 +1905,7 @@ function binOf(manifest) {
1701
1905
  async function pluginManifestDirs(nodeModules) {
1702
1906
  let entries;
1703
1907
  try {
1704
- entries = await import_promises10.default.readdir(nodeModules);
1908
+ entries = await import_promises11.default.readdir(nodeModules);
1705
1909
  } catch {
1706
1910
  return [];
1707
1911
  }
@@ -1711,44 +1915,44 @@ async function pluginManifestDirs(nodeModules) {
1711
1915
  if (entry.startsWith("@")) {
1712
1916
  let scoped;
1713
1917
  try {
1714
- scoped = await import_promises10.default.readdir(import_node_path10.default.join(nodeModules, entry));
1918
+ scoped = await import_promises11.default.readdir(import_node_path11.default.join(nodeModules, entry));
1715
1919
  } catch {
1716
1920
  continue;
1717
1921
  }
1718
1922
  for (const pkg of scoped) {
1719
1923
  if (pkg.startsWith(".")) continue;
1720
- dirs.push(import_node_path10.default.join(nodeModules, entry, pkg));
1924
+ dirs.push(import_node_path11.default.join(nodeModules, entry, pkg));
1721
1925
  }
1722
1926
  } else {
1723
- dirs.push(import_node_path10.default.join(nodeModules, entry));
1927
+ dirs.push(import_node_path11.default.join(nodeModules, entry));
1724
1928
  }
1725
1929
  }
1726
1930
  return dirs;
1727
1931
  }
1728
1932
  async function ensurePluginRoot() {
1729
1933
  const dir = pluginsDir();
1730
- await import_promises10.default.mkdir(dir, { recursive: true });
1731
- const pkgPath = import_node_path10.default.join(dir, "package.json");
1934
+ await import_promises11.default.mkdir(dir, { recursive: true });
1935
+ const pkgPath = import_node_path11.default.join(dir, "package.json");
1732
1936
  if (await readPackageJson(pkgPath) === null) {
1733
1937
  const pkg = { name: "diffwiki-plugins", private: true, version: "0.0.0" };
1734
- await import_promises10.default.writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}
1938
+ await import_promises11.default.writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}
1735
1939
  `, "utf8");
1736
1940
  }
1737
1941
  return dir;
1738
1942
  }
1739
1943
  async function asSearchPackage(pkgDir) {
1740
- const manifest = await readPackageJson(import_node_path10.default.join(pkgDir, "package.json"));
1944
+ const manifest = await readPackageJson(import_node_path11.default.join(pkgDir, "package.json"));
1741
1945
  if (!manifest?.name || manifest.diffwiki?.kind !== "search") return null;
1742
1946
  const bin = binOf(manifest);
1743
1947
  if (!bin) return null;
1744
- return { name: manifest.name, version: manifest.version, binAbs: import_node_path10.default.resolve(pkgDir, bin) };
1948
+ return { name: manifest.name, version: manifest.version, binAbs: import_node_path11.default.resolve(pkgDir, bin) };
1745
1949
  }
1746
1950
  async function packageNameFromSpec(spec) {
1747
1951
  const looksLikePath = spec.startsWith(".") || spec.startsWith("/") || spec.startsWith("~") || spec.startsWith("file:");
1748
1952
  if (looksLikePath) {
1749
1953
  const raw = spec.startsWith("file:") ? spec.slice("file:".length) : spec;
1750
- const abs = import_node_path10.default.resolve(raw.replace(/^~(?=$|\/)/, import_node_os2.default.homedir()));
1751
- return (await readPackageJson(import_node_path10.default.join(abs, "package.json")))?.name ?? null;
1954
+ const abs = import_node_path11.default.resolve(raw.replace(/^~(?=$|\/)/, import_node_os2.default.homedir()));
1955
+ return (await readPackageJson(import_node_path11.default.join(abs, "package.json")))?.name ?? null;
1752
1956
  }
1753
1957
  if (/^(git\+|git:|https?:|ssh:)/.test(spec)) return null;
1754
1958
  if (spec.startsWith("@")) {
@@ -1759,10 +1963,10 @@ async function packageNameFromSpec(spec) {
1759
1963
  return at <= 0 ? spec : spec.slice(0, at);
1760
1964
  }
1761
1965
  async function resolveSearchPackage(dir, name) {
1762
- return asSearchPackage(import_node_path10.default.join(dir, "node_modules", ...name.split("/")));
1966
+ return asSearchPackage(import_node_path11.default.join(dir, "node_modules", ...name.split("/")));
1763
1967
  }
1764
1968
  async function discoverSearchPackage(dir) {
1765
- const nodeModules = import_node_path10.default.join(dir, "node_modules");
1969
+ const nodeModules = import_node_path11.default.join(dir, "node_modules");
1766
1970
  for (const pkgDir of await pluginManifestDirs(nodeModules)) {
1767
1971
  const found = await asSearchPackage(pkgDir);
1768
1972
  if (found) return found;
@@ -1894,7 +2098,7 @@ init_host();
1894
2098
  init_types();
1895
2099
  async function exists(target) {
1896
2100
  try {
1897
- await import_promises11.default.access(target);
2101
+ await import_promises12.default.access(target);
1898
2102
  return true;
1899
2103
  } catch {
1900
2104
  return false;
@@ -2047,6 +2251,7 @@ init_registry2();
2047
2251
  addTags,
2048
2252
  buildArticleTree,
2049
2253
  buildCollections,
2254
+ buildLinkGraph,
2050
2255
  buildNavTree,
2051
2256
  buildSearchIndex,
2052
2257
  collectionDir,
@@ -2061,6 +2266,7 @@ init_registry2();
2061
2266
  emitPluginEvent,
2062
2267
  ensureGitRepo,
2063
2268
  ensurePluginRoot,
2269
+ fileTimestamps,
2064
2270
  findCollection,
2065
2271
  findCollectionById,
2066
2272
  findEnabledPlugin,
@@ -2070,6 +2276,7 @@ init_registry2();
2070
2276
  gitToplevel,
2071
2277
  initRepoWiki,
2072
2278
  installPlugin,
2279
+ invalidateLinkGraph,
2073
2280
  isLinkedWorktree,
2074
2281
  listArticleTree,
2075
2282
  listAvailablePlugins,
@@ -2078,6 +2285,7 @@ init_registry2();
2078
2285
  listPlugins,
2079
2286
  listSearchModes,
2080
2287
  loadSearchProvider,
2288
+ localGraph,
2081
2289
  nativeSearch,
2082
2290
  parseAddTarget,
2083
2291
  parseArticle,