diffwiki-core 0.6.1 → 0.7.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.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,
@@ -601,6 +602,7 @@ __export(index_exports, {
601
602
  gitToplevel: () => gitToplevel,
602
603
  initRepoWiki: () => initRepoWiki,
603
604
  installPlugin: () => installPlugin,
605
+ invalidateLinkGraph: () => invalidateLinkGraph,
604
606
  isLinkedWorktree: () => isLinkedWorktree,
605
607
  listArticleTree: () => listArticleTree,
606
608
  listAvailablePlugins: () => listAvailablePlugins,
@@ -609,6 +611,7 @@ __export(index_exports, {
609
611
  listPlugins: () => listPlugins,
610
612
  listSearchModes: () => listSearchModes,
611
613
  loadSearchProvider: () => loadSearchProvider,
614
+ localGraph: () => localGraph,
612
615
  nativeSearch: () => nativeSearch,
613
616
  parseAddTarget: () => parseAddTarget,
614
617
  parseArticle: () => parseArticle,
@@ -943,8 +946,8 @@ function isEpochish(d) {
943
946
  }
944
947
  var timestampCache = /* @__PURE__ */ new Map();
945
948
  async function fileTimestamps(absPath) {
946
- const cached = timestampCache.get(absPath);
947
- if (cached) return cached;
949
+ const cached2 = timestampCache.get(absPath);
950
+ if (cached2) return cached2;
948
951
  const dir = import_node_path4.default.dirname(absPath);
949
952
  const base = import_node_path4.default.basename(absPath);
950
953
  const [addLog, lastLog] = await Promise.all([
@@ -1304,6 +1307,56 @@ function rehypeCollectToc(toc) {
1304
1307
  });
1305
1308
  };
1306
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
+ }
1307
1360
  function rehypeStripLeadingH1() {
1308
1361
  return (tree) => {
1309
1362
  let removed = false;
@@ -1331,13 +1384,112 @@ function rehypeMarkMermaid() {
1331
1384
  }
1332
1385
  async function renderArticle(body, ctx) {
1333
1386
  const toc = [];
1334
- 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(rehypeRewriteLinks, ctx).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, {
1335
1390
  themes: { light: "github-light", dark: "github-dark" },
1336
1391
  defaultColor: false,
1337
1392
  langAlias: LANG_ALIASES,
1338
1393
  fallbackLanguage: "plaintext"
1339
1394
  }).use(import_rehype_stringify.default).process(body);
1340
- 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 };
1341
1493
  }
1342
1494
 
1343
1495
  // src/site.ts
@@ -1391,7 +1543,7 @@ function findNode(nodes, slug) {
1391
1543
  }
1392
1544
  return void 0;
1393
1545
  }
1394
- async function resolvePage(coll, slug) {
1546
+ async function resolvePage(coll, slug, opts) {
1395
1547
  try {
1396
1548
  const art = await readArticle(coll, slug);
1397
1549
  const { html, toc } = await renderArticle(art.body, {
@@ -1400,6 +1552,7 @@ async function resolvePage(coll, slug) {
1400
1552
  isIndex: art.isIndex
1401
1553
  });
1402
1554
  const { created, updated } = await fileTimestamps(art.path);
1555
+ const graph = localGraph(await buildLinkGraph(), `${art.coll}/${art.slug}`, opts?.include);
1403
1556
  return {
1404
1557
  kind: "doc",
1405
1558
  coll: art.coll,
@@ -1410,7 +1563,8 @@ async function resolvePage(coll, slug) {
1410
1563
  toc,
1411
1564
  path: `/${art.coll}/${art.slug}`,
1412
1565
  created,
1413
- updated
1566
+ updated,
1567
+ graph
1414
1568
  };
1415
1569
  } catch (err) {
1416
1570
  if (err instanceof CollectionNotFoundError) return null;
@@ -2097,6 +2251,7 @@ init_registry2();
2097
2251
  addTags,
2098
2252
  buildArticleTree,
2099
2253
  buildCollections,
2254
+ buildLinkGraph,
2100
2255
  buildNavTree,
2101
2256
  buildSearchIndex,
2102
2257
  collectionDir,
@@ -2121,6 +2276,7 @@ init_registry2();
2121
2276
  gitToplevel,
2122
2277
  initRepoWiki,
2123
2278
  installPlugin,
2279
+ invalidateLinkGraph,
2124
2280
  isLinkedWorktree,
2125
2281
  listArticleTree,
2126
2282
  listAvailablePlugins,
@@ -2129,6 +2285,7 @@ init_registry2();
2129
2285
  listPlugins,
2130
2286
  listSearchModes,
2131
2287
  loadSearchProvider,
2288
+ localGraph,
2132
2289
  nativeSearch,
2133
2290
  parseAddTarget,
2134
2291
  parseArticle,
package/dist/index.d.cts CHANGED
@@ -192,6 +192,8 @@ interface DocData {
192
192
  created?: string;
193
193
  /** ISO-8601 last-update date, sourced from git history / filesystem (not frontmatter). */
194
194
  updated?: string;
195
+ /** Local knowledge-graph neighbourhood for this page (this node + its direct links). */
196
+ graph: LocalGraph;
195
197
  }
196
198
  interface ListingEntry {
197
199
  name: string;
@@ -208,6 +210,44 @@ type PageData = ({
208
210
  title: string;
209
211
  entries: ListingEntry[];
210
212
  };
213
+ /**
214
+ * One node in the knowledge graph. Two kinds:
215
+ * - `article` — a wiki page. `id` is `"<coll>/<slug>"`, with `coll`/`slug` set.
216
+ * - `tag` — an Obsidian-style tag hub. `id` is `"tag:<name>"`; `coll`/`slug` are
217
+ * empty (a tag is not a page, so clicking it never navigates), `title` is `#name`.
218
+ */
219
+ interface GraphNode {
220
+ id: string;
221
+ kind: 'article' | 'tag';
222
+ coll: string;
223
+ slug: string;
224
+ title: string;
225
+ }
226
+ /**
227
+ * A graph edge. `kind` distinguishes an explicit hyperlink between two articles
228
+ * (`link`) from an article↔tag-hub membership edge (`tag`). For `link` edges the
229
+ * direction (source→target) is the link direction; `tag` edges go article→tag.
230
+ */
231
+ interface GraphEdge {
232
+ source: string;
233
+ target: string;
234
+ kind: 'link' | 'tag';
235
+ }
236
+ /** The whole-wiki knowledge graph. */
237
+ interface LinkGraph {
238
+ nodes: GraphNode[];
239
+ edges: GraphEdge[];
240
+ }
241
+ /**
242
+ * The local neighbourhood of one node: the node itself plus its directly linked
243
+ * (outgoing) and linking (incoming) neighbours, and the edges among them. `focus`
244
+ * is the id of the page this neighbourhood is centred on.
245
+ */
246
+ interface LocalGraph {
247
+ focus: string;
248
+ nodes: GraphNode[];
249
+ edges: GraphEdge[];
250
+ }
211
251
 
212
252
  /** Base class for all expected, user-facing diffwiki failures. */
213
253
  declare class DiffwikiError extends Error {
@@ -400,8 +440,18 @@ declare function fileTimestamps(absPath: string): Promise<FileTimestamps>;
400
440
  declare function toTreeNodes(coll: string, nodes: ArticleNode[]): TreeNode[];
401
441
  declare function buildCollections(): Promise<CollectionInfo[]>;
402
442
  declare function buildNavTree(): Promise<CollectionTree[]>;
403
- /** Resolve a route to a rendered doc or a generated directory listing (or null). */
404
- declare function resolvePage(coll: string, slug: string): Promise<PageData | null>;
443
+ /**
444
+ * Resolve a route to a rendered doc or a generated directory listing (or null).
445
+ *
446
+ * `opts.include` restricts the doc's sidebar `graph` preview to the given set of
447
+ * collection names — supplied by the static exporter for subset exports so the
448
+ * per-page local graph matches the filtered overlay `graph.json` and never links
449
+ * to a page that was excluded from the build. Omitting it (the dynamic server
450
+ * path, which serves every collection) keeps the whole-wiki neighbourhood.
451
+ */
452
+ declare function resolvePage(coll: string, slug: string, opts?: {
453
+ include?: ReadonlySet<string>;
454
+ }): Promise<PageData | null>;
405
455
 
406
456
  /**
407
457
  * Collection selection for `diffwiki export` — resolve which collection names to
@@ -603,18 +653,26 @@ declare function listArticleTree(coll: string): Promise<ArticleNode[]>;
603
653
  */
604
654
  declare function readArticle(coll: string, slug: string): Promise<ArticleContent>;
605
655
 
606
- /** Route context for an article being rendered — used to resolve relative links. */
656
+ /**
657
+ * Route context for an article being rendered — used both to rewrite relative
658
+ * inter-page links into in-app routes and to resolve links to `coll/slug` graph
659
+ * node ids.
660
+ */
607
661
  interface RenderContext {
608
662
  /** Collection name (first route segment). */
609
663
  coll: string;
610
664
  /** Article route slug (path within the collection, no extension), e.g. "a/b/deep". */
611
665
  slug: string;
612
666
  /**
613
- * True when the source file is a folder-index page (`<slug>/index.md`). Such a
614
- * page shares its route slug with the folder, so relative links must resolve
615
- * against the folder itself, not its parent. Defaults to false (leaf page).
667
+ * True for a directory index page: relative links resolve against the folder
668
+ * (`slug`) itself, not its parent so `./sub` from `guide/index.md` `guide/sub`.
616
669
  */
617
670
  isIndex?: boolean;
671
+ /**
672
+ * Deployment base path (e.g. `/my-repo/`) stripped from absolute route links so
673
+ * the collection segment resolves cleanly. Defaults to `DIFFWIKI_BASE`.
674
+ */
675
+ base?: string;
618
676
  }
619
677
  /** The result of rendering an article body to HTML. */
620
678
  interface RenderedArticle {
@@ -622,17 +680,47 @@ interface RenderedArticle {
622
680
  html: string;
623
681
  /** h2/h3 headings collected during render, in document order. */
624
682
  toc: TocItem[];
683
+ /**
684
+ * Internal outgoing links as `coll/slug` node ids, de-duplicated, in document
685
+ * order. Only populated when a {@link RenderContext} is supplied; external
686
+ * links, in-page anchors, and unresolvable targets are dropped.
687
+ */
688
+ links: string[];
625
689
  }
626
690
  /**
627
691
  * Render an article body (markdown) to Shiki-highlighted HTML plus its table of
628
- * contents, rewriting relative links to in-app routes via `ctx`.
629
- * Runs entirely server-side; the client mounts the HTML directly with
630
- * no `eval`/`new Function`, so it works under a strict CSP. Code is highlighted
631
- * here (dual github-light/github-dark via CSS variables); mermaid fences are
632
- * marked for client rendering.
692
+ * contents. Rewrites relative inter-page links to in-app routes and collects the
693
+ * article's internal outgoing links (`coll/slug` node ids) for the knowledge graph.
694
+ * Runs entirely server-side; the client mounts the HTML directly with no
695
+ * `eval`/`new Function`, so it works under a strict CSP. Code is highlighted here
696
+ * (dual github-light/github-dark via CSS variables); mermaid fences are marked for
697
+ * client rendering.
633
698
  */
634
699
  declare function renderArticle(body: string, ctx: RenderContext): Promise<RenderedArticle>;
635
700
 
701
+ /** Clear the memoised whole-wiki graph (tests; future live-reload invalidation). */
702
+ declare function invalidateLinkGraph(): void;
703
+ /**
704
+ * Build the whole-wiki graph: one node per article, one directed edge per resolved
705
+ * internal link (self/dead/external dropped, duplicates collapsed), plus tag hubs.
706
+ * Memoised per process; {@link invalidateLinkGraph} clears it.
707
+ *
708
+ * TODO(live-reload): the memo is process-keyed and the dynamic server fn never
709
+ * invalidates, so a long-lived server freezes the graph until restart.
710
+ */
711
+ declare function buildLinkGraph(): Promise<LinkGraph>;
712
+ /**
713
+ * Slice the local neighbourhood of `focusId`: the focus, its direct link neighbours,
714
+ * its tag hubs, and its co-tagged sibling articles (2 hops through a shared hub, so
715
+ * the tag-proximity view has neighbours rather than a lone dot). All edges among the
716
+ * kept nodes are retained; an unknown focus yields an empty-but-focused graph.
717
+ * `siblingCap` bounds the co-tagged siblings pulled in.
718
+ *
719
+ * `include` prunes to the given collection set before slicing (tag hubs kept), so a
720
+ * subset export's preview never links to an excluded page.
721
+ */
722
+ declare function localGraph(graph: LinkGraph, focusId: string, include?: ReadonlySet<string>, siblingCap?: number): LocalGraph;
723
+
636
724
  /**
637
725
  * Host-side plugin invocation and search-provider loading.
638
726
  *
@@ -802,4 +890,4 @@ declare function findEnabledPlugin(kind: PluginKind, name?: string): Promise<Plu
802
890
  /** Find a plugin record by name (regardless of enabled state). */
803
891
  declare function findPluginRecord(name: string): Promise<PluginRecord | undefined>;
804
892
 
805
- export { type AddExternalOptions, type Article, type ArticleContent, type ArticleNode, ArticleNotFoundError, CollectionAlreadyAddedError, type CollectionEntry, CollectionExistsError, type CollectionGitStatus, type CollectionInfo, CollectionNotFoundError, type CollectionSelection, type CollectionSelectionResult, type CollectionTree, type CollectionType, type Config, type Diagnostic, type DiagnosticLevel, DiffwikiError, type DiffwikiLogLevel, type DocData, type FileTimestamps, type InitOptions, type InstallOutcome, InvalidExportSelectionError, InvalidTargetError, type KnownPlugin, type ListingEntry, NATIVE_ENGINE, NoDefaultCollectionError, type NodeMeta, type PageData, type ParsedArticle, type PluginCapabilities, PluginError, type PluginEvent, type PluginHealth, type PluginKind, type PluginReadiness, type PluginRecord, type PluginRegistryFile, type QueryHit, type QueryOptions, REPO_CONFIG_FILE, type Registry, type RenderContext, type RenderedArticle, type RepoConfig, SearchDoc, type SearchMode, type SearchOutcome, type SearchProvider, type SerializableArticle, type SetupOutcome, type SetupStep, type TocItem, type TreeNode, type UpstreamStatus, WorktreeNotAllowedError, addExternalCollection, addTags, buildArticleTree, buildCollections, buildNavTree, buildSearchIndex, collectionDir, collectionGitStatus, collectionsDir, configPath, createArticle, createCollection, currentBranch, deregisterPlugin, doctor, emitPluginEvent, ensureGitRepo, ensurePluginRoot, fileTimestamps, findCollection, findCollectionById, findEnabledPlugin, findPluginRecord, getConfigValue, getLogger, gitToplevel, initRepoWiki, installPlugin, isLinkedWorktree, listArticleTree, listAvailablePlugins, listCollections, listEnabledPlugins, listPlugins, listSearchModes, loadSearchProvider, nativeSearch, parseAddTarget, parseArticle, parsePathTarget, pluginsDir, pluginsRegistryPath, query, readArticle, readConfig, readPluginRegistry, readRegistry, readRepoConfig, registerCollection, registerPlugin, registryPath, removeArticle, removeCollection, removePlugin, removeTags, renderArticle, repoId, repoRemoteName, resolveCollectionSelection, resolveCwdCollection, resolveDefaultSearchMode, resolveHome, resolveLogLevel, resolvePage, search, serializeArticle, setConfigValue, setTags, slugify, toTreeNodes, unregisterCollection, updateArticleBody, upsertPlugin, upstreamStatus, writeConfig, writePluginRegistry, writeRegistry };
893
+ export { type AddExternalOptions, type Article, type ArticleContent, type ArticleNode, ArticleNotFoundError, CollectionAlreadyAddedError, type CollectionEntry, CollectionExistsError, type CollectionGitStatus, type CollectionInfo, CollectionNotFoundError, type CollectionSelection, type CollectionSelectionResult, type CollectionTree, type CollectionType, type Config, type Diagnostic, type DiagnosticLevel, DiffwikiError, type DiffwikiLogLevel, type DocData, type FileTimestamps, type GraphEdge, type GraphNode, type InitOptions, type InstallOutcome, InvalidExportSelectionError, InvalidTargetError, type KnownPlugin, type LinkGraph, type ListingEntry, type LocalGraph, NATIVE_ENGINE, NoDefaultCollectionError, type NodeMeta, type PageData, type ParsedArticle, type PluginCapabilities, PluginError, type PluginEvent, type PluginHealth, type PluginKind, type PluginReadiness, type PluginRecord, type PluginRegistryFile, type QueryHit, type QueryOptions, REPO_CONFIG_FILE, type Registry, type RenderContext, type RenderedArticle, type RepoConfig, SearchDoc, type SearchMode, type SearchOutcome, type SearchProvider, type SerializableArticle, type SetupOutcome, type SetupStep, type TocItem, type TreeNode, type UpstreamStatus, WorktreeNotAllowedError, addExternalCollection, addTags, buildArticleTree, buildCollections, buildLinkGraph, buildNavTree, buildSearchIndex, collectionDir, collectionGitStatus, collectionsDir, configPath, createArticle, createCollection, currentBranch, deregisterPlugin, doctor, emitPluginEvent, ensureGitRepo, ensurePluginRoot, fileTimestamps, findCollection, findCollectionById, findEnabledPlugin, findPluginRecord, getConfigValue, getLogger, gitToplevel, initRepoWiki, installPlugin, invalidateLinkGraph, isLinkedWorktree, listArticleTree, listAvailablePlugins, listCollections, listEnabledPlugins, listPlugins, listSearchModes, loadSearchProvider, localGraph, nativeSearch, parseAddTarget, parseArticle, parsePathTarget, pluginsDir, pluginsRegistryPath, query, readArticle, readConfig, readPluginRegistry, readRegistry, readRepoConfig, registerCollection, registerPlugin, registryPath, removeArticle, removeCollection, removePlugin, removeTags, renderArticle, repoId, repoRemoteName, resolveCollectionSelection, resolveCwdCollection, resolveDefaultSearchMode, resolveHome, resolveLogLevel, resolvePage, search, serializeArticle, setConfigValue, setTags, slugify, toTreeNodes, unregisterCollection, updateArticleBody, upsertPlugin, upstreamStatus, writeConfig, writePluginRegistry, writeRegistry };
package/dist/index.d.ts CHANGED
@@ -192,6 +192,8 @@ interface DocData {
192
192
  created?: string;
193
193
  /** ISO-8601 last-update date, sourced from git history / filesystem (not frontmatter). */
194
194
  updated?: string;
195
+ /** Local knowledge-graph neighbourhood for this page (this node + its direct links). */
196
+ graph: LocalGraph;
195
197
  }
196
198
  interface ListingEntry {
197
199
  name: string;
@@ -208,6 +210,44 @@ type PageData = ({
208
210
  title: string;
209
211
  entries: ListingEntry[];
210
212
  };
213
+ /**
214
+ * One node in the knowledge graph. Two kinds:
215
+ * - `article` — a wiki page. `id` is `"<coll>/<slug>"`, with `coll`/`slug` set.
216
+ * - `tag` — an Obsidian-style tag hub. `id` is `"tag:<name>"`; `coll`/`slug` are
217
+ * empty (a tag is not a page, so clicking it never navigates), `title` is `#name`.
218
+ */
219
+ interface GraphNode {
220
+ id: string;
221
+ kind: 'article' | 'tag';
222
+ coll: string;
223
+ slug: string;
224
+ title: string;
225
+ }
226
+ /**
227
+ * A graph edge. `kind` distinguishes an explicit hyperlink between two articles
228
+ * (`link`) from an article↔tag-hub membership edge (`tag`). For `link` edges the
229
+ * direction (source→target) is the link direction; `tag` edges go article→tag.
230
+ */
231
+ interface GraphEdge {
232
+ source: string;
233
+ target: string;
234
+ kind: 'link' | 'tag';
235
+ }
236
+ /** The whole-wiki knowledge graph. */
237
+ interface LinkGraph {
238
+ nodes: GraphNode[];
239
+ edges: GraphEdge[];
240
+ }
241
+ /**
242
+ * The local neighbourhood of one node: the node itself plus its directly linked
243
+ * (outgoing) and linking (incoming) neighbours, and the edges among them. `focus`
244
+ * is the id of the page this neighbourhood is centred on.
245
+ */
246
+ interface LocalGraph {
247
+ focus: string;
248
+ nodes: GraphNode[];
249
+ edges: GraphEdge[];
250
+ }
211
251
 
212
252
  /** Base class for all expected, user-facing diffwiki failures. */
213
253
  declare class DiffwikiError extends Error {
@@ -400,8 +440,18 @@ declare function fileTimestamps(absPath: string): Promise<FileTimestamps>;
400
440
  declare function toTreeNodes(coll: string, nodes: ArticleNode[]): TreeNode[];
401
441
  declare function buildCollections(): Promise<CollectionInfo[]>;
402
442
  declare function buildNavTree(): Promise<CollectionTree[]>;
403
- /** Resolve a route to a rendered doc or a generated directory listing (or null). */
404
- declare function resolvePage(coll: string, slug: string): Promise<PageData | null>;
443
+ /**
444
+ * Resolve a route to a rendered doc or a generated directory listing (or null).
445
+ *
446
+ * `opts.include` restricts the doc's sidebar `graph` preview to the given set of
447
+ * collection names — supplied by the static exporter for subset exports so the
448
+ * per-page local graph matches the filtered overlay `graph.json` and never links
449
+ * to a page that was excluded from the build. Omitting it (the dynamic server
450
+ * path, which serves every collection) keeps the whole-wiki neighbourhood.
451
+ */
452
+ declare function resolvePage(coll: string, slug: string, opts?: {
453
+ include?: ReadonlySet<string>;
454
+ }): Promise<PageData | null>;
405
455
 
406
456
  /**
407
457
  * Collection selection for `diffwiki export` — resolve which collection names to
@@ -603,18 +653,26 @@ declare function listArticleTree(coll: string): Promise<ArticleNode[]>;
603
653
  */
604
654
  declare function readArticle(coll: string, slug: string): Promise<ArticleContent>;
605
655
 
606
- /** Route context for an article being rendered — used to resolve relative links. */
656
+ /**
657
+ * Route context for an article being rendered — used both to rewrite relative
658
+ * inter-page links into in-app routes and to resolve links to `coll/slug` graph
659
+ * node ids.
660
+ */
607
661
  interface RenderContext {
608
662
  /** Collection name (first route segment). */
609
663
  coll: string;
610
664
  /** Article route slug (path within the collection, no extension), e.g. "a/b/deep". */
611
665
  slug: string;
612
666
  /**
613
- * True when the source file is a folder-index page (`<slug>/index.md`). Such a
614
- * page shares its route slug with the folder, so relative links must resolve
615
- * against the folder itself, not its parent. Defaults to false (leaf page).
667
+ * True for a directory index page: relative links resolve against the folder
668
+ * (`slug`) itself, not its parent so `./sub` from `guide/index.md` `guide/sub`.
616
669
  */
617
670
  isIndex?: boolean;
671
+ /**
672
+ * Deployment base path (e.g. `/my-repo/`) stripped from absolute route links so
673
+ * the collection segment resolves cleanly. Defaults to `DIFFWIKI_BASE`.
674
+ */
675
+ base?: string;
618
676
  }
619
677
  /** The result of rendering an article body to HTML. */
620
678
  interface RenderedArticle {
@@ -622,17 +680,47 @@ interface RenderedArticle {
622
680
  html: string;
623
681
  /** h2/h3 headings collected during render, in document order. */
624
682
  toc: TocItem[];
683
+ /**
684
+ * Internal outgoing links as `coll/slug` node ids, de-duplicated, in document
685
+ * order. Only populated when a {@link RenderContext} is supplied; external
686
+ * links, in-page anchors, and unresolvable targets are dropped.
687
+ */
688
+ links: string[];
625
689
  }
626
690
  /**
627
691
  * Render an article body (markdown) to Shiki-highlighted HTML plus its table of
628
- * contents, rewriting relative links to in-app routes via `ctx`.
629
- * Runs entirely server-side; the client mounts the HTML directly with
630
- * no `eval`/`new Function`, so it works under a strict CSP. Code is highlighted
631
- * here (dual github-light/github-dark via CSS variables); mermaid fences are
632
- * marked for client rendering.
692
+ * contents. Rewrites relative inter-page links to in-app routes and collects the
693
+ * article's internal outgoing links (`coll/slug` node ids) for the knowledge graph.
694
+ * Runs entirely server-side; the client mounts the HTML directly with no
695
+ * `eval`/`new Function`, so it works under a strict CSP. Code is highlighted here
696
+ * (dual github-light/github-dark via CSS variables); mermaid fences are marked for
697
+ * client rendering.
633
698
  */
634
699
  declare function renderArticle(body: string, ctx: RenderContext): Promise<RenderedArticle>;
635
700
 
701
+ /** Clear the memoised whole-wiki graph (tests; future live-reload invalidation). */
702
+ declare function invalidateLinkGraph(): void;
703
+ /**
704
+ * Build the whole-wiki graph: one node per article, one directed edge per resolved
705
+ * internal link (self/dead/external dropped, duplicates collapsed), plus tag hubs.
706
+ * Memoised per process; {@link invalidateLinkGraph} clears it.
707
+ *
708
+ * TODO(live-reload): the memo is process-keyed and the dynamic server fn never
709
+ * invalidates, so a long-lived server freezes the graph until restart.
710
+ */
711
+ declare function buildLinkGraph(): Promise<LinkGraph>;
712
+ /**
713
+ * Slice the local neighbourhood of `focusId`: the focus, its direct link neighbours,
714
+ * its tag hubs, and its co-tagged sibling articles (2 hops through a shared hub, so
715
+ * the tag-proximity view has neighbours rather than a lone dot). All edges among the
716
+ * kept nodes are retained; an unknown focus yields an empty-but-focused graph.
717
+ * `siblingCap` bounds the co-tagged siblings pulled in.
718
+ *
719
+ * `include` prunes to the given collection set before slicing (tag hubs kept), so a
720
+ * subset export's preview never links to an excluded page.
721
+ */
722
+ declare function localGraph(graph: LinkGraph, focusId: string, include?: ReadonlySet<string>, siblingCap?: number): LocalGraph;
723
+
636
724
  /**
637
725
  * Host-side plugin invocation and search-provider loading.
638
726
  *
@@ -802,4 +890,4 @@ declare function findEnabledPlugin(kind: PluginKind, name?: string): Promise<Plu
802
890
  /** Find a plugin record by name (regardless of enabled state). */
803
891
  declare function findPluginRecord(name: string): Promise<PluginRecord | undefined>;
804
892
 
805
- export { type AddExternalOptions, type Article, type ArticleContent, type ArticleNode, ArticleNotFoundError, CollectionAlreadyAddedError, type CollectionEntry, CollectionExistsError, type CollectionGitStatus, type CollectionInfo, CollectionNotFoundError, type CollectionSelection, type CollectionSelectionResult, type CollectionTree, type CollectionType, type Config, type Diagnostic, type DiagnosticLevel, DiffwikiError, type DiffwikiLogLevel, type DocData, type FileTimestamps, type InitOptions, type InstallOutcome, InvalidExportSelectionError, InvalidTargetError, type KnownPlugin, type ListingEntry, NATIVE_ENGINE, NoDefaultCollectionError, type NodeMeta, type PageData, type ParsedArticle, type PluginCapabilities, PluginError, type PluginEvent, type PluginHealth, type PluginKind, type PluginReadiness, type PluginRecord, type PluginRegistryFile, type QueryHit, type QueryOptions, REPO_CONFIG_FILE, type Registry, type RenderContext, type RenderedArticle, type RepoConfig, SearchDoc, type SearchMode, type SearchOutcome, type SearchProvider, type SerializableArticle, type SetupOutcome, type SetupStep, type TocItem, type TreeNode, type UpstreamStatus, WorktreeNotAllowedError, addExternalCollection, addTags, buildArticleTree, buildCollections, buildNavTree, buildSearchIndex, collectionDir, collectionGitStatus, collectionsDir, configPath, createArticle, createCollection, currentBranch, deregisterPlugin, doctor, emitPluginEvent, ensureGitRepo, ensurePluginRoot, fileTimestamps, findCollection, findCollectionById, findEnabledPlugin, findPluginRecord, getConfigValue, getLogger, gitToplevel, initRepoWiki, installPlugin, isLinkedWorktree, listArticleTree, listAvailablePlugins, listCollections, listEnabledPlugins, listPlugins, listSearchModes, loadSearchProvider, nativeSearch, parseAddTarget, parseArticle, parsePathTarget, pluginsDir, pluginsRegistryPath, query, readArticle, readConfig, readPluginRegistry, readRegistry, readRepoConfig, registerCollection, registerPlugin, registryPath, removeArticle, removeCollection, removePlugin, removeTags, renderArticle, repoId, repoRemoteName, resolveCollectionSelection, resolveCwdCollection, resolveDefaultSearchMode, resolveHome, resolveLogLevel, resolvePage, search, serializeArticle, setConfigValue, setTags, slugify, toTreeNodes, unregisterCollection, updateArticleBody, upsertPlugin, upstreamStatus, writeConfig, writePluginRegistry, writeRegistry };
893
+ export { type AddExternalOptions, type Article, type ArticleContent, type ArticleNode, ArticleNotFoundError, CollectionAlreadyAddedError, type CollectionEntry, CollectionExistsError, type CollectionGitStatus, type CollectionInfo, CollectionNotFoundError, type CollectionSelection, type CollectionSelectionResult, type CollectionTree, type CollectionType, type Config, type Diagnostic, type DiagnosticLevel, DiffwikiError, type DiffwikiLogLevel, type DocData, type FileTimestamps, type GraphEdge, type GraphNode, type InitOptions, type InstallOutcome, InvalidExportSelectionError, InvalidTargetError, type KnownPlugin, type LinkGraph, type ListingEntry, type LocalGraph, NATIVE_ENGINE, NoDefaultCollectionError, type NodeMeta, type PageData, type ParsedArticle, type PluginCapabilities, PluginError, type PluginEvent, type PluginHealth, type PluginKind, type PluginReadiness, type PluginRecord, type PluginRegistryFile, type QueryHit, type QueryOptions, REPO_CONFIG_FILE, type Registry, type RenderContext, type RenderedArticle, type RepoConfig, SearchDoc, type SearchMode, type SearchOutcome, type SearchProvider, type SerializableArticle, type SetupOutcome, type SetupStep, type TocItem, type TreeNode, type UpstreamStatus, WorktreeNotAllowedError, addExternalCollection, addTags, buildArticleTree, buildCollections, buildLinkGraph, buildNavTree, buildSearchIndex, collectionDir, collectionGitStatus, collectionsDir, configPath, createArticle, createCollection, currentBranch, deregisterPlugin, doctor, emitPluginEvent, ensureGitRepo, ensurePluginRoot, fileTimestamps, findCollection, findCollectionById, findEnabledPlugin, findPluginRecord, getConfigValue, getLogger, gitToplevel, initRepoWiki, installPlugin, invalidateLinkGraph, isLinkedWorktree, listArticleTree, listAvailablePlugins, listCollections, listEnabledPlugins, listPlugins, listSearchModes, loadSearchProvider, localGraph, nativeSearch, parseAddTarget, parseArticle, parsePathTarget, pluginsDir, pluginsRegistryPath, query, readArticle, readConfig, readPluginRegistry, readRegistry, readRepoConfig, registerCollection, registerPlugin, registryPath, removeArticle, removeCollection, removePlugin, removeTags, renderArticle, repoId, repoRemoteName, resolveCollectionSelection, resolveCwdCollection, resolveDefaultSearchMode, resolveHome, resolveLogLevel, resolvePage, search, serializeArticle, setConfigValue, setTags, slugify, toTreeNodes, unregisterCollection, updateArticleBody, upsertPlugin, upstreamStatus, writeConfig, writePluginRegistry, writeRegistry };
package/dist/index.js CHANGED
@@ -318,8 +318,8 @@ function isEpochish(d) {
318
318
  }
319
319
  var timestampCache = /* @__PURE__ */ new Map();
320
320
  async function fileTimestamps(absPath) {
321
- const cached = timestampCache.get(absPath);
322
- if (cached) return cached;
321
+ const cached2 = timestampCache.get(absPath);
322
+ if (cached2) return cached2;
323
323
  const dir = path2.dirname(absPath);
324
324
  const base = path2.basename(absPath);
325
325
  const [addLog, lastLog] = await Promise.all([
@@ -673,6 +673,56 @@ function rehypeCollectToc(toc) {
673
673
  });
674
674
  };
675
675
  }
676
+ function isExternalHref(href) {
677
+ if (!href) return true;
678
+ if (href.startsWith("#")) return true;
679
+ if (href.startsWith("//")) return true;
680
+ return /^[a-z][a-z0-9+.-]*:/i.test(href);
681
+ }
682
+ function resolveHref(href, ctx) {
683
+ if (isExternalHref(href)) return null;
684
+ const clean = (p) => p.replace(/[?#].*$/, "").replace(/\.(mdx?|html)$/i, "").replace(/\/+$/, "");
685
+ const target = clean(href);
686
+ if (!target) return null;
687
+ if (target.startsWith("/")) {
688
+ const base = ctx.base ?? process.env.DIFFWIKI_BASE ?? "";
689
+ const baseSegs2 = base.split("/").filter(Boolean);
690
+ let segs = target.split("/").filter(Boolean);
691
+ if (baseSegs2.length > 0 && baseSegs2.every((b, i) => segs[i] === b)) {
692
+ segs = segs.slice(baseSegs2.length);
693
+ }
694
+ if (segs.length < 2) return null;
695
+ return segs.join("/");
696
+ }
697
+ const dir = ctx.isIndex ? ctx.slug : ctx.slug.includes("/") ? ctx.slug.slice(0, ctx.slug.lastIndexOf("/")) : "";
698
+ const baseSegs = dir ? dir.split("/") : [];
699
+ const out = [...baseSegs];
700
+ for (const seg of target.split("/")) {
701
+ if (seg === "" || seg === ".") continue;
702
+ if (seg === "..") {
703
+ if (out.length === 0) return null;
704
+ out.pop();
705
+ continue;
706
+ }
707
+ out.push(seg);
708
+ }
709
+ if (out.length === 0) return null;
710
+ return `${ctx.coll}/${out.join("/")}`;
711
+ }
712
+ function rehypeCollectLinks(links, ctx) {
713
+ const seen = /* @__PURE__ */ new Set();
714
+ return (tree) => {
715
+ visit(tree, "element", (node) => {
716
+ if (node.tagName !== "a") return;
717
+ const href = node.properties?.href;
718
+ if (typeof href !== "string") return;
719
+ const id = resolveHref(href, ctx);
720
+ if (!id || seen.has(id)) return;
721
+ seen.add(id);
722
+ links.push(id);
723
+ });
724
+ };
725
+ }
676
726
  function rehypeStripLeadingH1() {
677
727
  return (tree) => {
678
728
  let removed = false;
@@ -700,13 +750,110 @@ function rehypeMarkMermaid() {
700
750
  }
701
751
  async function renderArticle(body, ctx) {
702
752
  const toc = [];
703
- const file = await unified().use(remarkParse).use(remarkGfm).use(remarkRehype).use(rehypeSlug).use(rehypeCollectToc, toc).use(rehypeStripLeadingH1).use(rehypeMarkMermaid).use(rehypeRewriteLinks, ctx).use(rehypeShiki, {
753
+ const links = [];
754
+ const pipeline = unified().use(remarkParse).use(remarkGfm).use(remarkRehype).use(rehypeSlug).use(rehypeCollectToc, toc).use(rehypeCollectLinks, links, ctx);
755
+ const file = await pipeline.use(rehypeStripLeadingH1).use(rehypeMarkMermaid).use(rehypeRewriteLinks, ctx).use(rehypeShiki, {
704
756
  themes: { light: "github-light", dark: "github-dark" },
705
757
  defaultColor: false,
706
758
  langAlias: LANG_ALIASES,
707
759
  fallbackLanguage: "plaintext"
708
760
  }).use(rehypeStringify).process(body);
709
- return { html: String(file), toc };
761
+ return { html: String(file), toc, links };
762
+ }
763
+
764
+ // src/graph.ts
765
+ function tagId(tag) {
766
+ return `tag:${tag}`;
767
+ }
768
+ async function collectArticle(coll, slug, fallbackTitle, outNodes, outEdges, outTags) {
769
+ let art;
770
+ try {
771
+ art = await readArticle(coll, slug);
772
+ } catch (err) {
773
+ if (err instanceof ArticleNotFoundError) return;
774
+ throw err;
775
+ }
776
+ const id = `${coll}/${slug}`;
777
+ outNodes.push({ id, kind: "article", coll, slug, title: art.title ?? fallbackTitle ?? slug });
778
+ const { links } = await renderArticle(art.body, { coll, slug, isIndex: art.isIndex });
779
+ for (const target of links) outEdges.push({ source: id, target, kind: "link" });
780
+ for (const tag of art.tags) outTags.push({ source: id, tag });
781
+ }
782
+ async function collectCollection(coll, nodes, outNodes, outEdges, outTags) {
783
+ for (const n of nodes) {
784
+ if (n.slug) await collectArticle(coll, n.slug, n.title ?? n.name, outNodes, outEdges, outTags);
785
+ if (n.children) await collectCollection(coll, n.children, outNodes, outEdges, outTags);
786
+ }
787
+ }
788
+ var cached;
789
+ function invalidateLinkGraph() {
790
+ cached = void 0;
791
+ }
792
+ async function buildLinkGraph() {
793
+ if (cached) return cached;
794
+ const nodes = [];
795
+ const rawEdges = [];
796
+ const rawTags = [];
797
+ for (const e of await listCollections()) {
798
+ try {
799
+ await collectArticle(e.name, "", e.name, nodes, rawEdges, rawTags);
800
+ await collectCollection(e.name, await listArticleTree(e.name), nodes, rawEdges, rawTags);
801
+ } catch {
802
+ }
803
+ }
804
+ const nodeIds = new Set(nodes.map((n) => n.id));
805
+ const seen = /* @__PURE__ */ new Set();
806
+ const edges = [];
807
+ for (const edge of rawEdges) {
808
+ if (edge.source === edge.target) continue;
809
+ if (!nodeIds.has(edge.target)) continue;
810
+ const key = `${edge.source} ${edge.target}`;
811
+ if (seen.has(key)) continue;
812
+ seen.add(key);
813
+ edges.push(edge);
814
+ }
815
+ const tagSeen = /* @__PURE__ */ new Set();
816
+ for (const { source, tag } of rawTags) {
817
+ if (!nodeIds.has(source)) continue;
818
+ const id = tagId(tag);
819
+ if (!tagSeen.has(id)) {
820
+ tagSeen.add(id);
821
+ nodes.push({ id, kind: "tag", coll: "", slug: "", title: `#${tag}` });
822
+ }
823
+ const key = `${source} ${id}`;
824
+ if (seen.has(key)) continue;
825
+ seen.add(key);
826
+ edges.push({ source, target: id, kind: "tag" });
827
+ }
828
+ cached = { nodes, edges };
829
+ return cached;
830
+ }
831
+ function localGraph(graph, focusId, include, siblingCap = 12) {
832
+ const scoped = include ? {
833
+ // Tag hubs survive the filter — never a page, so they can't 404.
834
+ nodes: graph.nodes.filter((n) => n.kind === "tag" || include.has(n.coll)),
835
+ edges: graph.edges
836
+ } : graph;
837
+ const byId = new Map(scoped.nodes.map((n) => [n.id, n]));
838
+ const focus = byId.get(focusId);
839
+ if (!focus) return { focus: focusId, nodes: [], edges: [] };
840
+ const keep = /* @__PURE__ */ new Set([focusId]);
841
+ for (const e of scoped.edges) {
842
+ if (e.source === focusId && byId.has(e.target)) keep.add(e.target);
843
+ if (e.target === focusId && byId.has(e.source)) keep.add(e.source);
844
+ }
845
+ const focusTagHubs = new Set([...keep].filter((id) => byId.get(id)?.kind === "tag"));
846
+ let siblings = 0;
847
+ for (const e of scoped.edges) {
848
+ if (siblings >= siblingCap) break;
849
+ if (e.kind === "tag" && focusTagHubs.has(e.target) && byId.has(e.source) && !keep.has(e.source)) {
850
+ keep.add(e.source);
851
+ siblings++;
852
+ }
853
+ }
854
+ const nodes = scoped.nodes.filter((n) => keep.has(n.id));
855
+ const edges = scoped.edges.filter((e) => keep.has(e.source) && keep.has(e.target));
856
+ return { focus: focusId, nodes, edges };
710
857
  }
711
858
 
712
859
  // src/site.ts
@@ -759,7 +906,7 @@ function findNode(nodes, slug) {
759
906
  }
760
907
  return void 0;
761
908
  }
762
- async function resolvePage(coll, slug) {
909
+ async function resolvePage(coll, slug, opts) {
763
910
  try {
764
911
  const art = await readArticle(coll, slug);
765
912
  const { html, toc } = await renderArticle(art.body, {
@@ -768,6 +915,7 @@ async function resolvePage(coll, slug) {
768
915
  isIndex: art.isIndex
769
916
  });
770
917
  const { created, updated } = await fileTimestamps(art.path);
918
+ const graph = localGraph(await buildLinkGraph(), `${art.coll}/${art.slug}`, opts?.include);
771
919
  return {
772
920
  kind: "doc",
773
921
  coll: art.coll,
@@ -778,7 +926,8 @@ async function resolvePage(coll, slug) {
778
926
  toc,
779
927
  path: `/${art.coll}/${art.slug}`,
780
928
  created,
781
- updated
929
+ updated,
930
+ graph
782
931
  };
783
932
  } catch (err) {
784
933
  if (err instanceof CollectionNotFoundError) return null;
@@ -1360,6 +1509,7 @@ export {
1360
1509
  addTags,
1361
1510
  buildArticleTree,
1362
1511
  buildCollections,
1512
+ buildLinkGraph,
1363
1513
  buildNavTree,
1364
1514
  buildSearchIndex,
1365
1515
  collectionDir,
@@ -1384,6 +1534,7 @@ export {
1384
1534
  gitToplevel,
1385
1535
  initRepoWiki,
1386
1536
  installPlugin,
1537
+ invalidateLinkGraph,
1387
1538
  isLinkedWorktree,
1388
1539
  listArticleTree,
1389
1540
  listAvailablePlugins,
@@ -1392,6 +1543,7 @@ export {
1392
1543
  listPlugins,
1393
1544
  listSearchModes,
1394
1545
  loadSearchProvider,
1546
+ localGraph,
1395
1547
  nativeSearch,
1396
1548
  parseAddTarget,
1397
1549
  parseArticle,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "diffwiki-core",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "Core configuration, registry, and article management for diffwiki",
5
5
  "license": "MIT",
6
6
  "type": "module",