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 +163 -6
- package/dist/index.d.cts +100 -12
- package/dist/index.d.ts +100 -12
- package/dist/index.js +158 -6
- package/package.json +1 -1
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
|
|
947
|
-
if (
|
|
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
|
|
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
|
-
/**
|
|
404
|
-
|
|
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
|
-
/**
|
|
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
|
|
614
|
-
*
|
|
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
|
|
629
|
-
*
|
|
630
|
-
*
|
|
631
|
-
*
|
|
632
|
-
* marked for
|
|
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
|
-
/**
|
|
404
|
-
|
|
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
|
-
/**
|
|
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
|
|
614
|
-
*
|
|
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
|
|
629
|
-
*
|
|
630
|
-
*
|
|
631
|
-
*
|
|
632
|
-
* marked for
|
|
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
|
|
322
|
-
if (
|
|
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
|
|
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,
|