/** * docs-graph.ts — content link-graph generator + connectivity checker. * * Builds a directed graph of the docs where nodes are pages and edges are * internal `/docs/...` links in page bodies (markdown links + `href=`). * * It then checks the *topology* (not broken links — that's validate-links.ts): * 1. Weakly-connected components. The biggest is "the main graph". Anything * else is an island — a page (or a little 2-/3-loop) that doesn't mainline * back into the main graph through content links. * 2. Reachability from `index` — pages you can't click your way to starting * from the homepage. * 3. Short directed cycles (A->B->A, A->B->C->A) that are *isolated* (their * own tiny component). Cycles embedded in the main graph are fine. * * Usage (from docs/): * bun run scripts/docs-graph.ts # report for content/docs * bun run scripts/docs-graph.ts --html # also write docs-graph.html * bun run scripts/docs-graph.ts --dot # also write docs-graph.dot * bun run scripts/docs-graph.ts --root content/docs --json */ import { readFile, writeFile } from 'node:fs/promises'; import { glob } from 'node:fs/promises'; import { relative, resolve } from 'node:path'; // --------------------------------------------------------------------------- // Config / args // --------------------------------------------------------------------------- const args = process.argv.slice(2); const flag = (name: string) => args.includes(`--${name}`); const opt = (name: string, fallback: string) => { const i = args.indexOf(`--${name}`); return i >= 0 && args[i + 1] ? args[i + 1] : fallback; }; const ROOT = opt('root', 'content/docs'); // dir to scan, relative to cwd const URL_PREFIX = opt('prefix', '/docs'); // route prefix these files serve at const MAX_ISLAND = Number(opt('max-island', '4')); // components <= this are "small" // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- interface Node { /** route relative to URL_PREFIX, e.g. "providers/openai" ("" for index) */ id: string; /** display label */ label: string; file: string; out: Set; // internal doc targets (node ids) in: Set; /** links to other sections (/toolkits, /reference, ...) — keeps a page from * looking orphaned when its only links point outside docs */ crossOut: string[]; /** links to /docs/ that resolve to no known page (dangling) */ dangling: string[]; } // --------------------------------------------------------------------------- // Build nodes // --------------------------------------------------------------------------- function routeFromFile(file: string): string { // file is relative to ROOT, e.g. "providers/openai.mdx" or "providers/index.mdx" let r = file.replace(/\.mdx$/, ''); r = r.replace(/\/index$/, ''); if (r === 'index') r = ''; return r; } const cwd = process.cwd(); const rootAbs = resolve(cwd, ROOT); const nodes = new Map(); const filesByRoute = new Map(); for await (const abs of glob(`${rootAbs}/**/*.mdx`)) { const rel = relative(rootAbs, abs); const id = routeFromFile(rel); nodes.set(id, { id, label: id === '' ? 'index' : id, file: `${ROOT}/${rel}`, out: new Set(), in: new Set(), crossOut: [], dangling: [], }); filesByRoute.set(id, abs); } // --------------------------------------------------------------------------- // Extract edges // --------------------------------------------------------------------------- // Matches markdown links `](/path...)`, href="/path", href={'/path'} etc. const LINK_RE = /(?:\]\(|href\s*=\s*["'{]?)(\/[A-Za-z0-9/_\-.#?=&]+)/g; function normalizeDocTarget(raw: string): string | null { // strip anchor + query, trailing slash let t = raw.split('#')[0].split('?')[0].replace(/\/+$/, ''); if (!t.startsWith(`${URL_PREFIX}`)) return null; t = t.slice(URL_PREFIX.length).replace(/^\//, ''); // -> route id return t; } for (const node of nodes.values()) { const content = await readFile(filesByRoute.get(node.id)!, 'utf8'); // strip fenced code blocks so code samples don't create edges const body = content.replace(/```[\s\S]*?```/g, ''); for (const m of body.matchAll(LINK_RE)) { const href = m[1]; if (href.startsWith(`${URL_PREFIX}/`) || href === URL_PREFIX) { const target = normalizeDocTarget(href); if (target === null) continue; if (target === node.id) continue; // self-anchor link if (nodes.has(target)) { node.out.add(target); nodes.get(target)!.in.add(node.id); } else { node.dangling.push(href); } } else { // cross-section internal link (/toolkits, /reference, /examples, ...) node.crossOut.push(href.split('#')[0]); } } } // --------------------------------------------------------------------------- // Graph algorithms // --------------------------------------------------------------------------- const ids = [...nodes.keys()]; // Weakly-connected components (treat edges as undirected) function weaklyConnectedComponents(): string[][] { const seen = new Set(); const comps: string[][] = []; for (const start of ids) { if (seen.has(start)) continue; const stack = [start]; const comp: string[] = []; seen.add(start); while (stack.length) { const n = stack.pop()!; comp.push(n); const node = nodes.get(n)!; for (const nb of [...node.out, ...node.in]) { if (!seen.has(nb)) { seen.add(nb); stack.push(nb); } } } comps.push(comp); } return comps.sort((a, b) => b.length - a.length); } // Reachability from a root over directed out-edges function reachableFrom(root: string): Set { const seen = new Set([root]); const stack = [root]; while (stack.length) { const n = stack.pop()!; for (const nb of nodes.get(n)!.out) { if (!seen.has(nb)) { seen.add(nb); stack.push(nb); } } } return seen; } // Directed cycles up to length `maxLen` (deduped by canonical rotation) function shortCycles(maxLen: number): string[][] { const found = new Map(); const key = (cyc: string[]) => { const rots = cyc.map((_, i) => [...cyc.slice(i), ...cyc.slice(0, i)].join('>')); return rots.sort()[0]; }; const dfs = (start: string, path: string[]) => { const last = path[path.length - 1]; for (const nb of nodes.get(last)!.out) { if (nb === start && path.length >= 2) { const cyc = [...path]; found.set(key(cyc), cyc); } else if (path.length < maxLen && !path.includes(nb) && nb > start) { // nb > start keeps each cycle anchored at its smallest node dfs(start, [...path, nb]); } } }; for (const id of ids) dfs(id, [id]); return [...found.values()]; } const components = weaklyConnectedComponents(); const main = new Set(components[0] ?? []); const islands = components.slice(1); const reachFromIndex = nodes.has('') ? reachableFrom('') : new Set(); const cycles = shortCycles(3); // A short cycle is "concerning" only if its nodes are NOT part of the main // component (i.e. the loop is itself an island) — embedded loops are fine. const isolatedCycles = cycles.filter((c) => c.some((n) => !main.has(n))); // Degree + hubs. A "hub" is a high-degree page (lots of things link to/from it). const degree = (id: string) => nodes.get(id)!.in.size + nodes.get(id)!.out.size; const degrees = ids.map(degree).sort((a, b) => a - b); // top ~15% by degree, with a floor so tiny graphs don't flag everything const HUB_DEGREE = Math.max( Number(opt('hub-degree', '0')) || 0, degrees[Math.floor(degrees.length * 0.85)] ?? 0, 6, ); const isHub = (id: string) => degree(id) >= HUB_DEGREE; // Mutual 2-cycles (A->B and B->A) — an immediate circle-back. Benign between // sibling leaf pages, but a smell between two hubs: flow ping-pongs at the top // of the graph instead of moving forward. We flag only the hub<->hub ones. const mutual2: Array<[string, string]> = []; const seenPair = new Set(); for (const id of ids) { for (const t of nodes.get(id)!.out) { if (nodes.get(t)!.out.has(id)) { const key = [id, t].sort().join('|'); if (!seenPair.has(key)) { seenPair.add(key); mutual2.push([id, t].sort() as [string, string]); } } } } const hubLoops = mutual2 .filter(([a, b]) => isHub(a) && isHub(b)) .sort((x, y) => degree(y[0]) + degree(y[1]) - degree(x[0]) - degree(x[1])); // --------------------------------------------------------------------------- // Report // --------------------------------------------------------------------------- const totalEdges = ids.reduce((a, id) => a + nodes.get(id)!.out.size, 0); const orphans = ids.filter( (id) => nodes.get(id)!.in.size === 0 && nodes.get(id)!.out.size === 0, ); const noInbound = ids.filter( (id) => id !== '' && nodes.get(id)!.in.size === 0, ); const unreachable = ids.filter((id) => !reachFromIndex.has(id)); const C = { red: (s: string) => `\x1b[31m${s}\x1b[0m`, yellow: (s: string) => `\x1b[33m${s}\x1b[0m`, green: (s: string) => `\x1b[32m${s}\x1b[0m`, dim: (s: string) => `\x1b[2m${s}\x1b[0m`, bold: (s: string) => `\x1b[1m${s}\x1b[0m`, }; console.log(C.bold(`\nDocs link graph — ${ROOT}`)); console.log( `${nodes.size} pages · ${totalEdges} internal links · ${components.length} component(s)\n`, ); console.log(C.bold('Main graph')); console.log(` ${main.size}/${nodes.size} pages connected into the main component\n`); if (islands.length) { console.log(C.red(C.bold(`Islands (${islands.length}) — not connected to the main graph`))); for (const comp of islands) { const kind = comp.length === 1 ? 'orphan page' : `${comp.length}-page loop/cluster`; console.log(` ${C.red('●')} ${kind}`); for (const n of comp) { const node = nodes.get(n)!; const outNote = node.crossOut.length ? C.dim(` (links out to ${[...new Set(node.crossOut)].slice(0, 3).join(', ')})`) : node.out.size || node.in.size ? '' : C.dim(' (no links at all)'); console.log(` ${node.label || 'index'}${outNote}`); } } console.log(); } else { console.log(C.green('No islands — every page connects into the main graph.\n')); } if (hubLoops.length) { console.log(C.red(C.bold(`Hub ping-pong loops (${hubLoops.length})`))); console.log(C.dim(' (two high-traffic pages that only circle back to each other — flow should move forward)')); for (const [a, b] of hubLoops) { console.log(` ${C.red('⇄')} ${a || 'index'} ⇄ ${b || 'index'} ${C.dim(`(deg ${degree(a)} / ${degree(b)})`)}`); } console.log(); } else { console.log(C.green('No hub ping-pong loops.\n')); } if (isolatedCycles.length) { console.log(C.yellow(C.bold(`Isolated short cycles (${isolatedCycles.length})`))); console.log(C.dim(' (pages that circle straight back without joining the main graph)')); for (const c of isolatedCycles) { console.log(` ${C.yellow('↻')} ${c.map((n) => n || 'index').join(' → ')} → ${c[0] || 'index'}`); } console.log(); } // Click-reachability from index is only meaningful if index actually links out. // (Docs here navigate by sidebar, so an index with no body links makes every // page "unreachable" — that's noise, and index already shows up as an island.) if (nodes.has('') && nodes.get('')!.out.size === 0) { console.log(C.dim('Click-reachability from index: N/A (index has no outbound content links)\n')); } else if (unreachable.length && nodes.has('')) { console.log(C.yellow(C.bold(`Not reachable from index by clicking (${unreachable.length})`))); console.log(C.dim(' (in the sidebar, but no content link path from the homepage)')); for (const id of unreachable.slice(0, 40)) { console.log(` ${C.yellow('·')} ${id || 'index'}`); } if (unreachable.length > 40) console.log(C.dim(` … +${unreachable.length - 40} more`)); console.log(); } if (noInbound.length) { console.log(C.dim(`Pages with no inbound content links (${noInbound.length}): ${noInbound.slice(0, 12).join(', ')}${noInbound.length > 12 ? ', …' : ''}\n`)); } // --------------------------------------------------------------------------- // Optional outputs // --------------------------------------------------------------------------- if (flag('json')) { const out = { nodes: ids.map((id) => ({ id, out: [...nodes.get(id)!.out], in: [...nodes.get(id)!.in], crossOut: nodes.get(id)!.crossOut, dangling: nodes.get(id)!.dangling, })), components, islands, isolatedCycles, unreachable, }; await writeFile('docs-graph.json', JSON.stringify(out, null, 2)); console.log(C.dim('Wrote docs-graph.json')); } if (flag('dot')) { const compIndex = new Map(); components.forEach((c, i) => c.forEach((n) => compIndex.set(n, i))); const palette = ['#2563eb', '#dc2626', '#d97706', '#7c3aed', '#059669', '#db2777']; const lines = ['digraph docs {', ' rankdir=LR;', ' node [shape=box, style=rounded, fontname="Helvetica"];']; for (const id of ids) { const ci = compIndex.get(id)!; const color = ci === 0 ? '#94a3b8' : palette[(ci - 1) % palette.length]; const isIsland = ci !== 0; lines.push(` "${id || 'index'}" [color="${color}"${isIsland ? ', penwidth=2, fontcolor="' + color + '"' : ''}];`); } for (const id of ids) { for (const t of nodes.get(id)!.out) { lines.push(` "${id || 'index'}" -> "${t || 'index'}";`); } } lines.push('}'); await writeFile('docs-graph.dot', lines.join('\n')); console.log(C.dim('Wrote docs-graph.dot (render: dot -Tsvg docs-graph.dot -o docs-graph.svg)')); } if (flag('html')) { const compIndex = new Map(); components.forEach((c, i) => c.forEach((n) => compIndex.set(n, i))); const data = { nodes: ids.map((id) => ({ id: id || 'index', comp: compIndex.get(id)!, island: compIndex.get(id)! !== 0, degree: nodes.get(id)!.in.size + nodes.get(id)!.out.size, })), edges: ids.flatMap((id) => [...nodes.get(id)!.out].map((t) => ({ from: id || 'index', to: t || 'index' })), ), }; const html = `Docs link graph
Docs link graph
main graph   island (disconnected)
node size = link count
`; await writeFile('docs-graph.html', html); console.log(C.dim('Wrote docs-graph.html (open in a browser)')); } // exit non-zero if there are islands, so this can gate CI if (flag('check') && (islands.length || isolatedCycles.length || hubLoops.length)) { process.exit(1); }