wiki-browser — Design
Draft

wiki-browser — Design

v1: render & navigate2026-05-09Daniel

Problem

The orcha monorepo accumulates a lot of authored content: HTML proposals (docs/proposals/*.html), product docs (docs/orcha-*.html), Markdown plans (orcha/docs/plans/*.md), feature specs (feature-specs/*.md), the product roadmap, and so on. Today, reading any of it well requires a local checkout and either opening files in a browser by path or loading them in an editor preview. Sharing a doc with a cofounder mid-conversation means screenshots or pasted snippets.

What's missing is a simple, always-on view: open one URL on the LAN, see every authored document in the repo, navigate between them, render Markdown like GitHub does (including mermaid), and find content by name or text. The host is a Raspberry Pi, so resource frugality is part of the problem.

Goals & non-goals

Goals (v1)

Non-goals (v1)

Approach

Two decisions: the server/frontend architecture, and how rendered content is hosted in the browser.

Server/frontend architecture

OptionResource costSnappinessv2 readinessComplexity
A. Single binary, server-side render, full reloadsLowestOK (~50 ms LAN reload)Low — would need rework for live annotationsLowest
C. Pre-rendered static siteLowest at request timeBest (FileServer)Low — v2 dynamic features force a real server anywayMedium (watcher + build dir + invalidation)
Decision matrix — recommended row uses tr.recommended.
Decision

Option B: Go server with html/template + HTMX driving the chrome (sidebar, search, keyboard). Content rendering is delegated to an iframe; see the next decision.

Content rendering: iframe vs same-document

The chrome is HTMX-driven, but the rendered document body still has to live somewhere. Either inline inside the chrome's DOM (same-document) or as a standalone HTML document loaded into an <iframe>.

Iframe pros

  • CSS isolation is total — authored HTML can use body, *, @page without bleeding into chrome
  • JS isolation — authored <script> tags can't touch HTMX, mermaid, or sidebar listeners
  • No HTML body-extraction step — pass-through HTML is served as-is
  • Relative URLs and <base> in authored HTML work naturally
  • Markdown and HTML render through one path — server emits a standalone document either way

Iframe cons

  • Outer URL must be synced from iframe load events via history.pushState for shareable deep-links
  • Keyboard shortcuts need listeners in both contexts; iframe forwards unhandled keys to parent via postMessage
  • Mermaid loads per iframe (cached by the browser after first hit; ~500 KB)
  • Annotations (v2) need a small postMessage protocol — well-trodden (Hypothesis, code-review tools), bounded scope
Decision

Render all content (Markdown and authored HTML alike) inside an <iframe> served from the same origin. The iframe boundary eliminates the chrome-vs-content CSS contract that same-document rendering would otherwise force into the codebase. v1 ships without a sandbox attribute — content is trusted (the user's own repo); see Open questions.

Note

An earlier sketch considered iframe for HTML and same-document for Markdown. The dual-path tax (two annotation rendering paths to test in v2) outweighed the marginal speed gain on MD-to-MD navigation, so v1 unifies on iframe.

Design

System overview

One Go process, no external services. It reads the repo from disk, holds an in-memory render cache and a SQLite FTS5 index on disk, and serves HTTP. A single root directory is configured; everything under it is auto-discovered, filtered by an exclude list, and indexed by a watcher. Browser-side, the server returns two kinds of HTML: a chrome shell (sidebar, topbar, empty iframe) and standalone content documents loaded into the iframe.

Browser Shell · HTMX sidebar · search · keys iframe · content standalone doc mermaid v2: annotation client HTTP wiki-browser (Go) server · render · walker · index · search render cache FTS5 (SQLite) read repo .md / .html git pull
Components and data flow. The browser holds a chrome shell (HTMX) with an iframe child for content. The render cache is in-memory; FTS5 lives in a single SQLite file alongside the binary.

Packages and boundaries

PackageResponsibilityDepends on
configLoad and validate wiki-browser.yaml; produce a typed Config.
walkerWalk the configured root, apply excludes, emit the canonical file list. Watch via fsnotify with one Add() per directory (recursion is the walker's job, not the kernel's). Debounce events per-path. Owns the source of truth for "what files exist".config
renderPure function Render(absPath) → (Document, err) where Document is a complete HTML document for iframe consumption plus a HasMermaid flag set when the source contained at least one mermaid fence. .md via goldmark + chroma wrapped in the prose template; .html served verbatim. The server uses HasMermaid to gate mermaid script injection — most docs don't need it. In-memory LRU cache keyed by (absPath, mtime, size), byte-bounded.
indexOwns the SQLite FTS5 database. Exposes Reindex(path), Remove(path), Search(q, limit). Serializes mutations through a single goroutine to avoid write/remove races. Reacts to walker events.walker, render
navBuilds the sidebar HTML from the walker's file list, grouped by top-level directory. Re-rendered per request — cheap at the FTS5 scaling target (~5,000 entries) — so the live walker view is always reflected.walker
serverWires net/http routes. Two template families: the chrome shell (sidebar + topbar + iframe) and the content document (rendered MD or pass-through HTML, served standalone for the iframe).everything above
Each package has one responsibility and a small surface; consumers depend on functions, not internals.

Configuration

A single YAML file drives the server. Sensible defaults are baked into the code; the file lists only what differs.

yaml# wiki-browser.yaml
listen: ":8080"
title: "Orcha wiki"
root: "/home/volrath/code/orcha"
extensions: [".md", ".html"]
index_db: "./wiki-browser-index.db"
exclude:
  # user-configurable additions, on top of baked-in defaults:
  - "www/**"
  - "marketing/**"

Baked-in default excludes (always applied; no way to opt out): **/.git/**, **/node_modules/**, **/.worktrees/**, **/.obsidian/**, **/.claude/**, **/tmp-*/**. These exist purely to keep the index sane on any repo; they are not specific to orcha.

Discovery and indexing

sqlCREATE VIRTUAL TABLE docs USING fts5(
  path,                 -- repo-relative path, tokenized as text
  title,                -- front-matter title or first H1, fallback: filename
  body,                 -- rendered plain text (HTML/MD stripped)
  mtime UNINDEXED,      -- unix seconds; used for change detection
  size  UNINDEXED,      -- bytes; tiebreaker when mtime is preserved across edits
  tokenize = 'unicode61 remove_diacritics 2'
);

Filename matches use the FTS5 query path:<q> OR title:<q> with column-weighted bm25(docs, 8.0, 4.0, 1.0) (path > title > body). Content matches use body:<q> with the FTS5 snippet() helper for ~10-word context windows. The 8/4/1 weights are a starting point — tune during the Pi smoke test against 5–10 known queries from the actual orcha corpus, then update this number. The unicode61 tokenizer with remove_diacritics 2 handles English/Spanish/Portuguese/French well; CJK content would need the trigram tokenizer (SQLite ≥ 3.34) and is deferred — none of the orcha corpus is CJK today.

Routing and templates

Two top-level namespaces — /doc for chrome shells, /content for iframe documents — keep file paths from colliding with reserved chrome routes (search, partials, static, healthz).

Method & pathBehavior
GET /Landing page (chrome shell, iframe pointing at the welcome content).
GET /doc/{path...}Chrome shell with the iframe pre-pointed at /content/{path...}. This is the deep-linkable URL.
GET /content/{path...}Standalone HTML document for iframe consumption. .md rendered with prose CSS + mermaid; .html served verbatim. ?raw=1 returns the raw file bytes with text/plain for either extension (view-source convenience).
GET /search?q=...Returns the search-results.html fragment with two sections — Filename matches, Content matches. Result clicks navigate via the chrome's iframe-swap helper, not full reloads.
GET /partials/navSidebar fragment. Used rarely; the sidebar is normally rendered with the chrome shell.
GET /static/...Embedded assets (CSS, chrome.js, content.js, htmx.min.js, mermaid.esm.min.mjs, fonts) via embed.FS.
GET /healthz200 OK plain text.
Chrome and content responses come from separate template families; the chrome is rendered once per visit, content per navigation.

Three reserved content paths are baked into the binary via embed.FS: /content/_welcome, /content/_404, and /content/_search-offline. They take registration precedence over /content/{path...} so a real file can never shadow them. GET / always serves the chrome shell pointing at /content/_welcome, which doubles as the empty-tree landing: when the walker finds zero files matching extensions, the server still starts and the welcome page explains the empty index and references the config — we never fail-fast on an empty tree. 404 responses serve the chrome shell with the iframe pointing at /content/_404; the search-offline fragment is sourced from /content/_search-offline.

Content frame

Content lives in a same-origin iframe; chrome lives in the parent document. There is no shared CSS or JS scope, so no naming discipline is required to keep them apart.

No Content-Security-Policy header in v1 — content is trusted. With assets already vendored under static/, a future default-src 'self' policy would apply without changes and is the right v2 hardening lever to reach for if untrusted authored HTML ever needs to be served.

Search UX

Client JS

Split into two scripts. Together ~150 lines.

postMessage protocol uses a typed envelope: { kind: 'key', key: '/' }, { kind: 'nav', path: '...' }. targetOrigin is always location.origin.

Resource budget on a Raspberry Pi

ComponentBudgetNotes
Binary size≤ 25 MBStatic, includes embedded assets.
Steady-state RAM≤ 50 MBRender cache (LRU, ≤ 32 MB) + FTS5 page cache (2 MB default) + Go runtime (~12 MB) + headroom.
Render cache≤ 32 MB (LRU, byte-bounded)Approximate byte size = len(html) + len(plaintext), computed on insert. Entry count is not capped — bytes is the right proxy for memory pressure.
Markdown render time≤ 5 ms typicalgoldmark + chroma; cached after first hit.
FTS5 search latency≤ 50 ms typicalFor corpora up to ~5,000 docs.
Cold start≤ 1 sIndex diff against existing DB; only changed files reindex.
Server-side targets, not promises. Verified by smoke test on the actual Pi before declaring v1 done.

Client-side cost (browser, not Pi). Mermaid is ~500 KB per cold load and is now gated on Document.HasMermaid — only docs that actually contain a mermaid fence pay the script load and parse cost. Browser-cached after first hit. LAN bandwidth is not the bottleneck on a Pi; this row is about the user's tab, not the host.

Library choices

Build & deploy

The whole reason for picking modernc.org/sqlite and embedding assets via embed.FS is a one-line cross-compile from a dev box.

bash# build for the Pi (64-bit Raspberry Pi OS / Manjaro ARM)
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 \
  go build -trimpath -ldflags="-s -w" \
  -o dist/wiki-browser ./cmd/wiki-browser

# deploy
scp dist/wiki-browser pi:/home/pi/bin/wiki-browser
scp deploy/wiki-browser.service pi:/etc/systemd/system/
ssh pi "sudo systemctl daemon-reload && sudo systemctl restart wiki-browser"

A systemd unit is committed at deploy/wiki-browser.service:

ini[Unit]
Description=wiki-browser
After=network.target

[Service]
ExecStart=/home/pi/bin/wiki-browser -config=/home/pi/.config/wiki-browser.yaml
Restart=on-failure
RestartSec=2s
User=pi

[Install]
WantedBy=multi-user.target

No Docker, no orchestrator — the binary is the unit of deployment. Logs go to journald (journalctl -u wiki-browser).

Error handling

Testing

v2 readiness

The shape we're building maps cleanly onto the v2 features that were brainstormed and deferred:

Open questions

None blocking v1. The three previously-open items (iframe sandbox, render-cache bound, empty-tree behavior) are now resolved in the body of the design. Below: v2-adjacent items, no decision needed for v1.

References