From c278a74c83481a79a25fa708fab68a0c2abdeba9 Mon Sep 17 00:00:00 2001 From: Colin Francis Date: Thu, 23 Jul 2026 19:09:20 -0700 Subject: [PATCH 1/7] add wiki graph core --- src/visualize/graph.ts | 338 +++++++++++++++++++++++++++++++++++ test/visualize-graph.test.ts | 124 +++++++++++++ 2 files changed, 462 insertions(+) create mode 100644 src/visualize/graph.ts create mode 100644 test/visualize-graph.test.ts diff --git a/src/visualize/graph.ts b/src/visualize/graph.ts new file mode 100644 index 00000000..e68fb596 --- /dev/null +++ b/src/visualize/graph.ts @@ -0,0 +1,338 @@ +import { readFile, readdir } from "node:fs/promises"; +import path from "node:path"; + +/** + * The known-typed subset of frontmatter OpenWiki writes at the top of each page. + */ +export interface WikiMeta { + /** + * The page's declared kind, e.g. "Reference" or "Section". + * + * @default undefined - a node falls back to "Section" for index pages, else "Reference". + */ + type?: string; + + /** + * Explicit page title. + * + * @default undefined - a node falls back to the section name (index), first H1, then the filename. + */ + title?: string; + + /** + * One-line page summary. + * + * @default undefined - the node's description becomes "". + */ + description?: string; + + /** + * Topic tags for the page. + * + * @default undefined - the node's tags become an empty array. + */ + tags?: string[]; +} + +/** + * A single wiki page, as one node in the graph. + */ +export interface WikiNode { + /** + * Stable id: the page path relative to the wiki root, without the .md suffix. + */ + id: string; + + /** + * Display title, resolved from frontmatter, first heading, or filename. + */ + title: string; + + /** + * Page kind, used for node coloring and the legend. + */ + type: string; + + /** + * One-line summary, or "" when the page declares none. + */ + description: string; + + /** + * Topic tags, or an empty array when the page declares none. + */ + tags: string[]; + + /** + * Raw markdown body with frontmatter stripped. + */ + body: string; + + /** + * Body length in characters, used to scale the node's rendered radius. + */ + size: number; + + /** + * Ids of pages this page links to (outgoing edges). + */ + links: string[]; + + /** + * Ids of pages that link to this page (incoming edges). + */ + backlinks: string[]; +} + +/** + * A directed link from one page to another. + */ +export interface WikiEdge { + /** + * Id of the page the link starts from. + */ + source: string; + + /** + * Id of the page the link points to. + */ + target: string; +} + +/** + * The complete in-memory graph, serialized to the browser at /api/graph. + */ +export interface WikiGraph { + /** + * Basename of the wiki root directory, shown in the page header. + */ + root: string; + + /** + * ISO-8601 timestamp of when this graph was built. + */ + generatedAt: string; + + /** + * All distinct node types present, sorted, for the legend. + */ + types: string[]; + + /** + * Every page in the wiki. + */ + nodes: WikiNode[]; + + /** + * Every resolved directed link between pages. + */ + edges: WikiEdge[]; +} + +/** + * A raw frontmatter map: each key is either a scalar string or a string list. + */ +type RawMeta = Record; + +/** + * Pages that are generation scaffolding, not real wiki content. + */ +const EXCLUDED_FILES = new Set(["INSTRUCTIONS.md", "log.md", "_plan.md"]); + +/** + * Matches a relative markdown link target (`foo.md`, optionally with an `#anchor`). + */ +const MARKDOWN_LINK = /\]\(([^)\s]+\.md)(?:#[^)]*)?\)/g; + +/** + * Strip a single pair of surrounding single or double quotes. + */ +function stripQuotes(value: string): string { + return value.replace(/^['"]|['"]$/g, ""); +} + +/** + * Split a markdown file's YAML frontmatter from its body. Only the small subset + * OpenWiki emits (scalars, inline `[a, b]` arrays, and dashed lists) is parsed. + */ +export function splitFrontmatter(raw: string): { meta: RawMeta; body: string } { + if (!raw.startsWith("---")) { + return { meta: {}, body: raw }; + } + const end = raw.indexOf("\n---", 3); + if (end === -1) { + return { meta: {}, body: raw }; + } + const block = raw.slice(3, end).trim(); + const body = raw.slice(raw.indexOf("\n", end + 1) + 1); + const meta: RawMeta = {}; + let pendingListKey: string | undefined; + for (const line of block.split("\n")) { + const listItem = line.match(/^\s*-\s+(.*)$/); + if (listItem && pendingListKey) { + (meta[pendingListKey] as string[]).push(stripQuotes(listItem[1].trim())); + continue; + } + const kv = line.match(/^([A-Za-z0-9_]+):\s*(.*)$/); + if (!kv) continue; + const [, key, rawValue] = kv; + const value = rawValue.trim(); + if (value === "") { + pendingListKey = key; + meta[key] = []; + } else if (value.startsWith("[") && value.endsWith("]")) { + pendingListKey = undefined; + meta[key] = value + .slice(1, -1) + .split(",") + .map((item) => stripQuotes(item.trim())) + .filter(Boolean); + } else { + pendingListKey = undefined; + meta[key] = stripQuotes(value); + } + } + return { meta, body }; +} + +/** + * Read the known OpenWiki fields out of a raw frontmatter map, typed. + */ +function readMeta(raw: RawMeta): WikiMeta { + const scalar = (value: string | string[] | undefined): string | undefined => + typeof value === "string" ? value : undefined; + return { + type: scalar(raw.type), + title: scalar(raw.title), + description: scalar(raw.description), + tags: Array.isArray(raw.tags) ? raw.tags : undefined, + }; +} + +/** + * First H1 in a markdown body, or undefined when there is none. + */ +export function firstHeading(body: string): string | undefined { + return body.match(/^#\s+(.+)$/m)?.[1].trim(); +} + +/** + * Turn an absolute wiki file path into a stable node id (relative, no .md). + */ +export function toId(wikiRoot: string, fullPath: string): string { + return path + .relative(wikiRoot, fullPath) + .replace(/\\/g, "/") + .replace(/\.md$/, ""); +} + +/** + * Title for an index page: its section directory name, capitalized ("Home" at the root). + */ +function sectionTitle(file: string, wikiRoot: string): string { + const dir = path.dirname(file); + const name = path.resolve(dir) === wikiRoot ? "Home" : path.basename(dir); + return name.charAt(0).toUpperCase() + name.slice(1); +} + +/** + * Every relative markdown link target found in a body. + */ +function markdownLinks(body: string): string[] { + return [...body.matchAll(MARKDOWN_LINK)].map((match) => match[1]); +} + +/** + * Recursively collect markdown files under `dir`. Two guards keep the walk inside the + * wiki: the resolved path must stay within `wikiRoot`, and only real directories and + * files are traversed. A symlink dirent is neither `isDirectory()` nor `isFile()`, so + * a symlink pointing outside the wiki is never followed or read. + */ +async function collectMarkdown( + dir: string, + wikiRoot: string, + out: string[] = [], +): Promise { + for (const entry of await readdir(dir, { withFileTypes: true })) { + const full = path.resolve(dir, entry.name); + if (full !== wikiRoot && !full.startsWith(wikiRoot + path.sep)) continue; + if (entry.isDirectory()) { + await collectMarkdown(full, wikiRoot, out); + } else if ( + entry.isFile() && + entry.name.endsWith(".md") && + !EXCLUDED_FILES.has(entry.name) + ) { + out.push(full); + } + } + return out; +} + +/** + * Read one markdown file into a fully-populated but not-yet-linked graph node. + */ +async function readNode(file: string, wikiRoot: string): Promise { + const { meta: raw, body } = splitFrontmatter(await readFile(file, "utf8")); + const meta = readMeta(raw); + const isIndex = path.basename(file) === "index.md"; + // Index pages carry a generic "# Files" heading, so prefer the section name. + const title = + meta.title ?? + (isIndex ? sectionTitle(file, wikiRoot) : firstHeading(body)) ?? + path.basename(file, ".md"); + return { + id: toId(wikiRoot, file), + title, + type: meta.type ?? (isIndex ? "Section" : "Reference"), + description: meta.description ?? "", + tags: meta.tags ?? [], + body, + size: body.length, + links: [], + backlinks: [], + }; +} + +/** + * Resolve each node's markdown links into directed edges between existing nodes, + * recording them on the nodes' `links`/`backlinks` in place. Self-links, links to + * unknown pages, and duplicate edges are dropped. + */ +function linkNodes(nodes: WikiNode[], wikiRoot: string): WikiEdge[] { + const byId = new Map(nodes.map((node) => [node.id, node])); + const edges: WikiEdge[] = []; + const seen = new Set(); + for (const node of nodes) { + const fileDir = path.dirname(path.join(wikiRoot, `${node.id}.md`)); + for (const link of markdownLinks(node.body)) { + const target = toId(wikiRoot, path.resolve(fileDir, link)); + const targetNode = byId.get(target); + const key = `${node.id}\n${target}`; + if (!targetNode || target === node.id || seen.has(key)) continue; + seen.add(key); + edges.push({ source: node.id, target }); + node.links.push(target); + targetNode.backlinks.push(node.id); + } + } + return edges; +} + +/** + * Build the in-memory node/edge graph from a wiki directory. + */ +export async function buildGraph(wikiRoot: string): Promise { + const files = (await collectMarkdown(wikiRoot, wikiRoot)).sort(); + const nodes = await Promise.all( + files.map((file) => readNode(file, wikiRoot)), + ); + const edges = linkNodes(nodes, wikiRoot); + return { + root: path.basename(wikiRoot), + generatedAt: new Date().toISOString(), + types: [...new Set(nodes.map((node) => node.type))].sort(), + nodes, + edges, + }; +} diff --git a/test/visualize-graph.test.ts b/test/visualize-graph.test.ts new file mode 100644 index 00000000..c8bcbd47 --- /dev/null +++ b/test/visualize-graph.test.ts @@ -0,0 +1,124 @@ +import { mkdtemp, mkdir, rm, writeFile, symlink } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; +import { + buildGraph, + firstHeading, + splitFrontmatter, +} from "../src/visualize/graph.ts"; + +const tempDirs: string[] = []; + +async function makeWiki(files: Record): Promise { + const root = await mkdtemp(path.join(tmpdir(), "openwiki-viz-")); + tempDirs.push(root); + for (const [rel, content] of Object.entries(files)) { + const full = path.join(root, rel); + await mkdir(path.dirname(full), { recursive: true }); + await writeFile(full, content, "utf8"); + } + return root; +} + +afterEach(async () => { + await Promise.all( + tempDirs.splice(0).map((dir) => rm(dir, { force: true, recursive: true })), + ); +}); + +describe("splitFrontmatter", () => { + test("parses scalars, inline lists, and dashed lists", () => { + const { meta, body } = splitFrontmatter( + [ + "---", + "type: Reference", + 'title: "Quoted Title"', + "tags: [alpha, beta]", + "authors:", + " - Ada", + " - Grace", + "---", + "# Heading", + "", + "Body text.", + ].join("\n"), + ); + expect(meta.type).toBe("Reference"); + expect(meta.title).toBe("Quoted Title"); + expect(meta.tags).toEqual(["alpha", "beta"]); + expect(meta.authors).toEqual(["Ada", "Grace"]); + expect(body.startsWith("# Heading")).toBe(true); + }); + + test("returns the raw body when there is no frontmatter", () => { + const { meta, body } = splitFrontmatter("# Just markdown\n"); + expect(meta).toEqual({}); + expect(body).toBe("# Just markdown\n"); + }); +}); + +describe("firstHeading", () => { + test("returns the first H1 or undefined", () => { + expect(firstHeading("intro\n# Title\n")).toBe("Title"); + expect(firstHeading("no heading here")).toBeUndefined(); + }); +}); + +describe("buildGraph", () => { + test("builds nodes, resolves links to edges, and records backlinks", async () => { + const root = await makeWiki({ + "index.md": "---\ntype: Section\n---\n# Files\n[Arch](architecture.md)\n", + "architecture.md": + "---\ntype: Reference\ntitle: Architecture\n---\n# Architecture\nSee [home](index.md).\n", + "INSTRUCTIONS.md": "scaffolding, must be excluded", + }); + + const graph = await buildGraph(root); + + // INSTRUCTIONS.md is excluded; the two real pages remain. + expect(graph.nodes.map((n) => n.id).sort()).toEqual([ + "architecture", + "index", + ]); + // Root index.md is titled "Home", not its generic "# Files" heading. + expect(graph.nodes.find((n) => n.id === "index")?.title).toBe("Home"); + expect(graph.nodes.find((n) => n.id === "architecture")?.title).toBe( + "Architecture", + ); + // Two directed edges, one each way. + expect(graph.edges).toContainEqual({ + source: "index", + target: "architecture", + }); + expect(graph.edges).toContainEqual({ + source: "architecture", + target: "index", + }); + // Backlinks are recorded on the target node. + expect( + graph.nodes.find((n) => n.id === "architecture")?.backlinks, + ).toContain("index"); + }); + + test("ignores links to non-existent pages and self-links", async () => { + const root = await makeWiki({ + "a.md": "# A\n[missing](nope.md) and [self](a.md)\n", + }); + const graph = await buildGraph(root); + expect(graph.edges).toEqual([]); + }); + + test("does not follow a symlink that escapes the wiki root", async () => { + const secret = await mkdtemp(path.join(tmpdir(), "openwiki-secret-")); + tempDirs.push(secret); + await writeFile(path.join(secret, "leak.md"), "# Secret\n", "utf8"); + + const root = await makeWiki({ "index.md": "# Home\n" }); + // A symlink inside the wiki pointing outside it must not be collected. + await symlink(secret, path.join(root, "escape")); + + const graph = await buildGraph(root); + expect(graph.nodes.some((n) => n.id.includes("leak"))).toBe(false); + }); +}); From 9b630dde5a6a584637da0ea18b756f36ce8cc130 Mon Sep 17 00:00:00 2001 From: Colin Francis Date: Thu, 23 Jul 2026 20:01:23 -0700 Subject: [PATCH 2/7] add browser client as testable TS modules --- package.json | 4 +- src/visualize/client-lib.ts | 156 ++++++ src/visualize/client.ts | 874 ++++++++++++++++++++++++++++++ src/visualize/page.ts | 239 ++++++++ test/visualize-client-lib.test.ts | 138 +++++ tsconfig.client.json | 9 + tsconfig.eslint.json | 6 +- tsconfig.json | 3 +- 8 files changed, 1425 insertions(+), 4 deletions(-) create mode 100644 src/visualize/client-lib.ts create mode 100644 src/visualize/client.ts create mode 100644 src/visualize/page.ts create mode 100644 test/visualize-client-lib.test.ts create mode 100644 tsconfig.client.json diff --git a/package.json b/package.json index 34685363..85a548e9 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ ], "scripts": { "openwiki": "node dist/cli.js", - "build": "tsc -p tsconfig.json", + "build": "tsc -p tsconfig.json && tsc -p tsconfig.client.json", "clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"", "coverage": "vitest run --coverage", "dev": "tsx src/cli.tsx", @@ -39,7 +39,7 @@ "prepack": "pnpm run build", "start": "node dist/cli.js", "test": "vitest run", - "typecheck": "tsc --noEmit -p tsconfig.json" + "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.client.json" }, "dependencies": { "@anthropic-ai/vertex-sdk": "^0.19.0", diff --git a/src/visualize/client-lib.ts b/src/visualize/client-lib.ts new file mode 100644 index 00000000..be36f486 --- /dev/null +++ b/src/visualize/client-lib.ts @@ -0,0 +1,156 @@ +import type { WikiGraph } from "./graph.js"; + +/** + * The minimal node shape the search/type filter needs. Both the raw wiki nodes + * from /api/graph and the force-graph render objects satisfy it structurally. + */ +export interface FilterableNode { + /** + * Stable page id (path relative to the wiki root, without .md). + */ + id: string; + + /** + * Display title. + */ + title: string; + + /** + * Page kind, matched against the active type filter. + */ + type: string; + + /** + * Topic tags, folded into the free-text search haystack. + * + * @default undefined - treated as no tags. + */ + tags?: readonly string[]; +} + +/** + * Node sphere colors, keyed by draw order. Saturated enough to hold their hue as + * lit 3D spheres (pale pastels blow out to white under the scene lighting); the + * legend swatches reuse these same values. + */ +export const PALETTE: readonly string[] = [ + "#4FA8F0", + "#B6DE3E", + "#D96FA6", + "#A97FE0", + "#D98A6B", + "#3FBFA0", + "#E0A63E", + "#6E8FF0", +]; + +/** + * HTML-escape the five characters that could break out of text or an attribute + * value, so wiki-sourced strings are safe to assign to innerHTML. + */ +const HTML_ESCAPES: Record = { + "&": "&", + "<": "<", + ">": ">", + '"': """, +}; + +/** + * Escape the HTML-significant characters in a string before it is inserted into + * the DOM. This is the sole XSS gate for wiki-sourced text. + */ +export function escapeHtml(value: string): string { + return value.replace(/[&<>"]/g, (char) => HTML_ESCAPES[char] ?? char); +} + +/** + * Map each distinct node type to a palette color by its position in the list, so + * the graph and legend agree on colors and they stay stable across reloads. + */ +export function colorsForTypes( + types: readonly string[], + palette: readonly string[] = PALETTE, +): Record { + const colors: Record = {}; + types.forEach((type, i) => { + colors[type] = palette[i % palette.length]; + }); + return colors; +} + +/** + * Convert a `#RRGGBB` hex color plus an alpha into an `rgba(...)` string, for + * canvas glow fills and dimming. A non-6-digit input is returned unchanged. + */ +export function hexA(hex: string, alpha: number): string { + const c = (hex || "").replace("#", ""); + if (c.length !== 6) return hex; + const channel = (i: number): number => parseInt(c.slice(i, i + 2), 16); + return `rgba(${channel(0)}, ${channel(2)}, ${channel(4)}, ${alpha})`; +} + +/** + * Node circle radius in graph units, scaled by page length and capped, with a + * bonus for the entry (anchor) page so it reads as the starting point. + */ +export function nodeRadius(size: number, isAnchor: boolean): number { + return 4 + Math.min(7, (size || 0) / 480) + (isAnchor ? 4 : 0); +} + +/** + * Whether a node survives the active search text and type filter. An empty query + * or empty type matches everything. + */ +export function matchesFilter( + node: FilterableNode, + query: string, + type: string, +): boolean { + const haystack = `${node.title} ${node.id} ${(node.tags ?? []).join(" ")}`; + const matchesQuery = !query || haystack.toLowerCase().includes(query); + const matchesType = !type || node.type === type; + return matchesQuery && matchesType; +} + +/** + * A stable fingerprint of the graph's topology (its node ids and directed edges). + * When it is unchanged across a reload, the scene can be left untouched so the + * layout and viewport do not snap. + */ +export function signature(graph: Pick): string { + const nodes = graph.nodes + .map((node) => node.id) + .sort() + .join("|"); + const edges = graph.edges + .map((edge) => `${edge.source}>${edge.target}`) + .sort() + .join("|"); + return `${nodes}::${edges}`; +} + +/** + * Strip a leading YAML frontmatter block from a markdown body before it is + * rendered in the reader. A body without frontmatter is returned unchanged. + */ +export function stripFrontmatter(body: string): string { + if (!body.startsWith("---")) return body; + const end = body.indexOf("\n---", 3); + return end === -1 ? body : body.slice(body.indexOf("\n", end + 1) + 1); +} + +/** + * Resolve a relative link (`rel`) against a page's directory (`baseDir`) into a + * normalized wiki path, collapsing `.` and `..` segments. Used to turn in-page + * markdown links into node ids for in-app navigation. + */ +export function normalize(baseDir: string, rel: string): string { + const parts = (baseDir ? baseDir.split("/") : []).concat(rel.split("/")); + const out: string[] = []; + for (const part of parts) { + if (part === "" || part === ".") continue; + if (part === "..") out.pop(); + else out.push(part); + } + return out.join("/"); +} diff --git a/src/visualize/client.ts b/src/visualize/client.ts new file mode 100644 index 00000000..30bf66b0 --- /dev/null +++ b/src/visualize/client.ts @@ -0,0 +1,874 @@ +import type { WikiGraph, WikiNode } from "./graph.js"; +import { + colorsForTypes, + escapeHtml, + hexA, + matchesFilter, + nodeRadius, + normalize, + signature, + stripFrontmatter, +} from "./client-lib.js"; + +// --- Render model ----------------------------------------------------------- + +/** + * A force-graph render node: a persisted object (its identity survives reloads + * so positions and camera stay put) carrying the fields the canvas painter reads. + */ +interface GraphNode { + /** + * Stable page id: the path relative to the wiki root, without the .md suffix. + */ + id: string; + + /** + * Display title. + */ + title: string; + + /** + * Page kind; selects the node's color from the palette. + */ + type: string; + + /** + * Body length in characters; scales the rendered radius. + */ + size: number; + + /** + * Topic tags. + */ + tags: string[]; + + /** + * One-line summary, or "" when the page declares none. + */ + description: string; + + /** + * Resolved fill color for this node's type. + */ + color: string; + + /** + * Whether this is the entry page, drawn larger and always labelled. + */ + anchor: boolean; + + /** + * Rendered radius in graph units. + */ + r: number; + + /** + * Layout x position, assigned by force-graph once simulated (undefined before). + */ + x?: number; + + /** + * Layout y position, assigned by force-graph once simulated (undefined before). + */ + y?: number; +} + +/** + * A force-graph link. force-graph resolves the string endpoints from the wire + * format into node object references in place once the data is ingested, so by + * the time any accessor below runs, source and target are GraphNode objects. + */ +interface GraphLink { + /** + * Origin node (a wire-format id string until force-graph resolves it in place). + */ + source: GraphNode; + + /** + * Destination node (a wire-format id string until resolved in place). + */ + target: GraphNode; +} + +/** + * The node/link payload force-graph renders. + */ +interface GraphData { + /** + * Every render node in the graph. + */ + nodes: GraphNode[]; + + /** + * Every link between render nodes. + */ + links: GraphLink[]; +} + +// --- Third-party globals (loaded from the CDN + + + + + +
+
+ +
+
OpenWikiwiki visualizer
+
+
+
Live
+
+
+
+ +
+
+
Drag to pan · Scroll to zoom · Click a node to read
+
+
+ +
Select a page to read it, or explore the graph.
+
+
+
+
Wiki updated
+ + +`; diff --git a/test/visualize-client-lib.test.ts b/test/visualize-client-lib.test.ts new file mode 100644 index 00000000..afde63bc --- /dev/null +++ b/test/visualize-client-lib.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, test } from "vitest"; +import { + PALETTE, + colorsForTypes, + escapeHtml, + hexA, + matchesFilter, + nodeRadius, + normalize, + signature, + stripFrontmatter, +} from "../src/visualize/client-lib.ts"; + +describe("escapeHtml", () => { + test("escapes the HTML-significant characters", () => { + expect(escapeHtml(`Tom & Jerry`)).toBe( + "<a href="x">Tom & Jerry</a>", + ); + }); + + test("leaves a plain string untouched", () => { + expect(escapeHtml("just text")).toBe("just text"); + }); +}); + +describe("colorsForTypes", () => { + test("assigns palette colors by position", () => { + expect(colorsForTypes(["A", "B"], ["#111", "#222", "#333"])).toEqual({ + A: "#111", + B: "#222", + }); + }); + + test("wraps around when there are more types than colors", () => { + const colors = colorsForTypes(["A", "B", "C"], ["#111", "#222"]); + expect(colors.C).toBe("#111"); + }); + + test("defaults to the shared PALETTE", () => { + expect(colorsForTypes(["A"]).A).toBe(PALETTE[0]); + }); +}); + +describe("hexA", () => { + test("expands #RRGGBB plus alpha into rgba()", () => { + expect(hexA("#4FA8F0", 0.5)).toBe("rgba(79, 168, 240, 0.5)"); + }); + + test("returns a non-6-digit input unchanged", () => { + expect(hexA("#abc", 0.5)).toBe("#abc"); + expect(hexA("", 1)).toBe(""); + }); +}); + +describe("nodeRadius", () => { + test("scales with size and caps the size contribution", () => { + expect(nodeRadius(0, false)).toBe(4); + expect(nodeRadius(480, false)).toBe(5); + expect(nodeRadius(100000, false)).toBe(11); // 4 + min(7, huge) + }); + + test("adds a bonus for the anchor page", () => { + expect(nodeRadius(0, true)).toBe(8); + }); +}); + +describe("matchesFilter", () => { + const node = { + id: "arch/overview", + title: "Overview", + type: "Section", + tags: ["core"], + }; + + test("empty query and type match everything", () => { + expect(matchesFilter(node, "", "")).toBe(true); + }); + + test("matches free text across title, id, and tags", () => { + expect(matchesFilter(node, "overview", "")).toBe(true); + expect(matchesFilter(node, "arch", "")).toBe(true); + expect(matchesFilter(node, "core", "")).toBe(true); + expect(matchesFilter(node, "missing", "")).toBe(false); + }); + + test("filters by exact type", () => { + expect(matchesFilter(node, "", "Section")).toBe(true); + expect(matchesFilter(node, "", "Reference")).toBe(false); + }); +}); + +describe("signature", () => { + test("is stable regardless of node and edge order", () => { + const a = { + nodes: [{ id: "a" }, { id: "b" }], + edges: [{ source: "a", target: "b" }], + }; + const b = { + nodes: [{ id: "b" }, { id: "a" }], + edges: [{ source: "a", target: "b" }], + }; + expect(signature(a)).toBe(signature(b)); + }); + + test("changes when an edge is added", () => { + const base = { nodes: [{ id: "a" }, { id: "b" }], edges: [] }; + const linked = { + nodes: [{ id: "a" }, { id: "b" }], + edges: [{ source: "a", target: "b" }], + }; + expect(signature(base)).not.toBe(signature(linked)); + }); +}); + +describe("stripFrontmatter", () => { + test("removes a leading frontmatter block", () => { + expect(stripFrontmatter("---\ntitle: X\n---\n# Body\n")).toBe("# Body\n"); + }); + + test("returns a body without frontmatter unchanged", () => { + expect(stripFrontmatter("# Body\n")).toBe("# Body\n"); + }); +}); + +describe("normalize", () => { + test("resolves a sibling link within a directory", () => { + expect(normalize("arch", "server.md")).toBe("arch/server.md"); + }); + + test("collapses .. and . segments", () => { + expect(normalize("arch/deep", "../server.md")).toBe("arch/server.md"); + expect(normalize("arch", "./server.md")).toBe("arch/server.md"); + }); + + test("resolves from the wiki root when baseDir is empty", () => { + expect(normalize("", "index.md")).toBe("index.md"); + }); +}); diff --git a/tsconfig.client.json b/tsconfig.client.json new file mode 100644 index 00000000..09f93395 --- /dev/null +++ b/tsconfig.client.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": false, + "lib": ["ES2022", "DOM", "DOM.Iterable"] + }, + "include": ["src/visualize/client.ts"], + "exclude": [] +} diff --git a/tsconfig.eslint.json b/tsconfig.eslint.json index 3cb58f56..60842a3c 100644 --- a/tsconfig.eslint.json +++ b/tsconfig.eslint.json @@ -1,4 +1,8 @@ { "extends": "./tsconfig.json", - "include": ["src/**/*.ts", "src/**/*.tsx", "test/**/*.ts", "test/**/*.tsx"] + "compilerOptions": { + "lib": ["ES2022", "DOM", "DOM.Iterable"] + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "test/**/*.ts", "test/**/*.tsx"], + "exclude": [] } diff --git a/tsconfig.json b/tsconfig.json index a78a0ee9..3bea7174 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,5 +13,6 @@ "strict": true, "target": "ES2022" }, - "include": ["src/**/*.ts", "src/**/*.tsx"] + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": ["src/visualize/client.ts"] } From cb65f2a321421857fff9e157054b91eabd462aa8 Mon Sep 17 00:00:00 2001 From: Colin Francis Date: Thu, 23 Jul 2026 20:08:32 -0700 Subject: [PATCH 3/7] implement http server --- src/visualize/server.ts | 244 ++++++++++++++++++++++++++++++++++ test/visualize-server.test.ts | 39 ++++++ 2 files changed, 283 insertions(+) create mode 100644 src/visualize/server.ts create mode 100644 test/visualize-server.test.ts diff --git a/src/visualize/server.ts b/src/visualize/server.ts new file mode 100644 index 00000000..21bad9ed --- /dev/null +++ b/src/visualize/server.ts @@ -0,0 +1,244 @@ +import { + createServer, + type IncomingMessage, + type Server, + type ServerResponse, +} from "node:http"; +import { watch } from "node:fs"; +import { readFile, stat } from "node:fs/promises"; +import { execFile } from "node:child_process"; +import { buildGraph, type WikiGraph } from "./graph.js"; +import { PAGE } from "./page.js"; + +const HOST = "127.0.0.1"; // loopback only (never expose the wiki on the network) +const PORT_ATTEMPTS = 20; // ports to try before giving up when the preferred one is busy +const WATCH_DEBOUNCE_MS = 150; // collapse a burst of file-change events into one rebuild + +// The client JS is an external module (/client.js), so scripts need only 'self' plus the +// jsdelivr CDN origin for the three browser libraries (whose integrity is pinned by the SRI +// hashes on the +