Graph server
Your bundle as a clickable graph, served locally.
What it serves
okf server <dir> boots a self-contained HTML page over the bundle: concepts as nodes colored by type and sized by connectedness, plain Markdown links as edges. Click a node and the inspector shows its metadata and rendered Markdown, with "links to" and "linked from" backlinks; relative links inside a body navigate in-app instead of 404ing. Mermaid code blocks render as diagrams, and a click or tap opens the diagram fullscreen with drag to pan and wheel or pinch to zoom, so a big flowchart is never stuck at panel width. Beyond the graph there are catalog, files, tags, and stats views, and every view narrows by type, directory, and tag. There is also a file-tree mode that redraws the bundle as folders and leaves. Try all of it on the public demo.
Links draw in three amounts, and a dense bundle opens on its spine. A toolbar control switches between every link, the spine, and none, and a selected concept always shows its own links in full. The spine is each concept's single strongest edge, chosen so it still touches every linked concept and strands nobody, so a dense bundle opens on it rather than greeting the reader with a thicket of arrows. Above 800 edges the first layout runs on the spine alone and the rest arrive a frame later with no re-layout, so you reach something readable sooner and nothing on screen jumps. okf server --map and okf render --map start at the far end of the same control: no links, the directories boxed, and a concept's own links appearing when you select it.
The authored layer is a layer, not a mode. Turn on Show indexes and every directory map joins the canvas as a tile, edged to the concepts it lists and to the maps below it, under whatever layout is already running. Authorship shows as form: solid where an author wrote a map, hollow and dashed where the bundle only implies one, so the toggle reads as curation as much as navigation. Maps are drawn rather than modelled, since index.md is reserved: they are built straight onto the canvas, and the catalog, the type index, and the tag index never learn they exist. Filters pass them over, because a map has no type or tags, but a map whose concepts are all filtered away leaves with them. File-tree mode disables the toggle rather than doubling the folders it already draws. The index layer on the graph is the story behind it.
In the Files view, the authored files sit where they belong. The tree nests by path segment, folders before files, and index.md and log.md are rows at the top of the folder they document, badged map and log. Indexes only is a toggle over that same tree, not a second tab: same rows, fewer of them, structure intact. It yields when it would hide what was just opened, so a map stays under it and a concept releases it, and browsing the authored layer never destroys the list being browsed. Narrowed, a folder owns exactly one row, so the row stands at that folder's depth carrying its path rather than nesting a lone child under a header. Collapsing a folder takes its subtree with it, and "collapse all" folds into the root rather than over it, leaving the top-level folders standing.
The log is fetched fresh on every read, so the entry a maintain pass just appended shows without a restart, and it offers no graph button at all: a chronology is not a place in the graph. Everywhere a graph button does appear it carries one label, because the question it answers is the same whatever is open. Links between the authored files navigate too: an index.md, log.md, or bare directory link opens its map or history instead of dead-ending. The rail's Index is an action rather than a view, opening the root map through the same read a first-time visitor gets; ?view=index resolves to that same action.
The page boots from a lean payload (node ids and titles plus compact type/tag indexes) and fetches each concept's body live from disk as you click, so big bundles load fast and edits show without a restart. The CLI page covers flags and invocation; this page covers what the thing is.
The search box is a ranked index, not a filter. One MiniSearch index backs the graph, catalog, and files views: multi-term (AND), prefix as you type, and typo-tolerant, over title, id, type, tags, and description, plus each concept's body wherever the page already holds it. Since okf render bakes every body in, a static file searches bodies offline; the live server keeps bodies lazy, so its index stays metadata-only. The index is pinned to the same MiniSearch build the Ruby port tracks, so a browser result and an okf search --engine index result rank identically. Until the index loads, or if the CDN is out of reach, each view falls back to its own substring filter, so the box is never dead.
On the server side the search corpus is built once and held, not rebuilt per request: okf server warms it at boot, so a query answers in milliseconds rather than paying a full index build every time. A single-bundle server answers GET /search?q= so its Cmd/Ctrl-K palette can find things, while a hub's /search ranks a term across every bundle it hosts over one shared index. The corpus is a snapshot, like the graph, so a hub drops it on any registry write rather than let a held index outlive the set it was built from.
A newcomer is told the index exists. The page opens on the graph, because that is what makes a bundle legible at a glance, but a first-time reader meeting unlabelled dots now gets a dismissible note saying what the picture is, how to touch it, and where the index is. Read the index goes straight there, and the dismissal is remembered. The wording follows the device on two gates rather than one: what a reader does follows pointer coarseness, since a touch tablet in landscape is wide and still taps, while what a reader can reach follows the width at which the rail collapses behind the menu button.
It is one template from a phone to a desktop. At phone widths the navigation rail becomes a drawer, the toolbar folds into a settings sheet (which shows when a filter is active, so a folded control can never silently narrow the graph), and panels go full-bleed. Where the file list is stacked above the reader, collapsing the root folds the list away rather than leaving one row above a column of nothing, and reopening the list undoes that collapse, so it is one gesture rather than two states to escape. The breakpoint tracks the width available to the chrome rather than a device class, so rotating a tablet re-evaluates the layout and refits the graph. The keyboard reaches everything: Cmd/Ctrl-K opens a command palette in every mode, / focuses the current view's search, ? opens a sheet of every shortcut, Esc drops the graph selection (a dense graph leaves almost no empty canvas to click), and the number keys jump between views.
Many bundles behind one hub
okf server takes zero or more bundles. One is the classic single bundle at /; two or more mount behind a hub, one page per bundle at /b/<slug>/, the first at /; none at all serves every bundle in the registry, its default at /. Under a hub, /b/ is a browsable index with the default marked, an unknown slug answers with a page listing the hosted bundles instead of bare text, and the Cmd/Ctrl-K palette leads with the other bundles, so moving between them never leaves the page. Deep links survive: the mount-relative endpoints mean the same page works at / or under any prefix, and redirects preserve the query string.
The hub reads its bundles at boot. Registering or editing one while a server runs changes nothing on the page until a restart; that is the trade for a hub that never re-scans disk per request.
The rail carries a Bundles panel. Behind ⚙ it lists every bundle the server knows about, each with its title, @slug, folder, concept count, and a health verdict, so the people who read bundles get a surface the registry CLI was the wrong tool for. Four write routes sit behind it, POST /registry/{default,rename,remove,add}, so a reader can set the default, rename, or drop a bundle from the page itself. Management is the default on a loopback bind, and --read-only declines it, hiding the controls and refusing the request that skipped them; any non-loopback bind is refused management outright, with no flag that opens it, since --bind 0.0.0.0 is how a personal tool becomes a public one. Every write passes four gates (is the server writable at all, is there a registry to write to, is the verb one of the frozen four, and did the request carry this page's per-boot token) and rebuilds the hub's bundles from disk before it answers, so the next click never trusts a stale set. There is no Add control on the page, since a browser cannot hand over a filesystem path; registering a new bundle stays the agent's act.
The same page, without a server
The same template renders as a static file. okf render <dir> writes this whole page as one self-contained HTML file with the bundle baked in, so the graph hosts where no server runs: GitHub Pages, an object store, a file you send. It is one switch away from the server, not a second implementation. Where the server fetches each body, description, catalog, index map, and log live over HTTP, render resolves those same five reads from an embedded payload, through the same getter functions, so what you browse is identical. The trade-off is weight, since every body is inlined, so okf server stays the choice for a bundle too large to ship whole. The trust boundary below holds either way: the embedded data is escaped exactly like the boot payload, and every body still renders through DOMPurify.
It is a Rack app
The built-in WEBrick runner is a convenience. The same app mounts in any Rack host:
require "okf/server/app" # the server is an on-demand require
folder = OKF::Bundle::Folder.load("docs")
OKF::Server::App.new(folder) # mount it in Rails, Sinatra, rackup, anywhere
Endpoints stay mount-relative, so the page works at / or under a prefix. The Rails guide walks the routes and auth patterns; see the library API for the surrounding surface.
The trust boundary
A bundle can carry active content in a Markdown body, so the page defends the two paths that could execute it:
- Inlined graph data is escaped. Every
<in the boot payload becomes a JSON unicode escape, so data cannot break out of its<script>tag. - Fetched bodies are sanitized. Each concept's Markdown renders through marked and then through DOMPurify before touching the DOM, so scripts and event handlers hidden in a body are stripped, not executed.
What remains is ordinary web caution: the page loads Cytoscape, marked, and DOMPurify from a CDN (Mermaid and Panzoom join lazily, on the first diagram, and MiniSearch on the first search), and it renders whatever links a body contains. Treat an unfamiliar bundle the way you would treat any document from a source you do not know.
Design notes
The whole UI is one ERB template with inline CSS and JavaScript: no build step, no bundler, no framework. That is a deliberate architecture constraint, and it is why the server works identically from a gem install on a decade of Rubies.