wiki-browser v1 Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build wiki-browser v1: a Go binary that serves the orcha repo's .md and .html files in a browser with GitHub-flavored Markdown rendering, mermaid diagrams, full-text search, and HTMX-driven chrome.

Architecture: One Go process. The HTTP server returns a chrome shell (HTMX-driven; sidebar, search topbar) that hosts an <iframe>. The iframe loads standalone content documents — Markdown rendered server-side, authored HTML served byte-identical. Filesystem walker (fsnotify) keeps a SQLite FTS5 index in sync. Pure Go (no CGO) so the binary cross-compiles to ARM from a dev box.

Tech Stack: Go 1.22+, modernc.org/sqlite (FTS5), github.com/yuin/goldmark + github.com/alecthomas/chroma/v2, github.com/fsnotify/fsnotify, github.com/bmatcuk/doublestar/v4, HTMX, mermaid.js, gopkg.in/yaml.v3. Tests use the standard testing package + github.com/google/go-cmp/cmp. End-to-end iframe test uses github.com/chromedp/chromedp.

Spec: ../specs/2026-05-09-wiki-browser-design.html. Read it once before starting; this plan assumes you've seen it.


File map

wiki-browser/
├── cmd/wiki-browser/main.go          ← entrypoint
├── deploy/wiki-browser.service       ← systemd unit
├── docs/superpowers/                 ← spec + this plan (already exist)
├── go.mod, go.sum
├── Makefile                          ← build / test / cross-compile
├── README.md
├── wiki-browser.example.yaml         ← config example
└── internal/
    ├── config/                       ← Load + validate YAML
    ├── walker/                       ← scan + fsnotify watch + debounce
    ├── render/                       ← MD/HTML → Document; LRU cache
    ├── index/                        ← FTS5 schema, Reindex/Remove/Search
    ├── nav/                          ← sidebar HTML from walker output
    └── server/                       ← routes, templates, embed.FS root
        ├── templates/                ← shell, content_md, search_results, nav
        ├── static/                   ← CSS, chrome.js, content.js, htmx, mermaid
        └── content/                  ← _welcome.md, _404.md, _search-offline.md

Test data is under each package's testdata/ (Go convention; go test ignores files there).


Conventions used by every task


Phase 0 — Bootstrap

Task 1: Project bootstrap

Files:

go mod init github.com/getorcha/wiki-browser

Expected: writes go.mod with go 1.22 (or higher).

go get gopkg.in/yaml.v3
go get github.com/bmatcuk/doublestar/v4
go get github.com/fsnotify/fsnotify
go get github.com/yuin/goldmark
go get github.com/yuin/goldmark-highlighting/v2
go get github.com/alecthomas/chroma/v2
go get modernc.org/sqlite
go get github.com/google/go-cmp/cmp
go get github.com/chromedp/chromedp
go get golang.org/x/sync/singleflight
go get golang.org/x/net/html
go mod tidy

Expected: go.sum populated; no errors.

# wiki-browser

Wiki-style server (Go) for the orcha repo. Serves every `.md` and `.html` file as a browseable wiki with GitHub-flavored Markdown rendering, mermaid diagrams, and full-text search. Designed to run on a Raspberry Pi.

See [`docs/superpowers/specs/2026-05-09-wiki-browser-design.html`](docs/superpowers/specs/2026-05-09-wiki-browser-design.html) for the design.

## Quick start

```bash
cp wiki-browser.example.yaml wiki-browser.yaml
$EDITOR wiki-browser.yaml          # set `root` to the repo you want to serve
make run
# open http://localhost:8080

Build for the Pi

make build-arm64
scp dist/wiki-browser pi:/home/pi/bin/

See deploy/wiki-browser.service for the systemd unit.


- [ ] **Step 4: Write `.gitignore`**

/dist/ /wiki-browser-index.db /wiki-browser.yaml *.test *.out


- [ ] **Step 5: Write `wiki-browser.example.yaml`**

```yaml
# wiki-browser config — copy to wiki-browser.yaml and edit `root`.
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
  # (.git, node_modules, .worktrees, .obsidian, .claude, tmp-* are always excluded).
  - "www/**"
  - "marketing/**"
go build ./...

Expected: no output, no errors. (There's nothing to build yet, but go build ./... succeeds when there are no .go files.)

git add go.mod go.sum README.md .gitignore wiki-browser.example.yaml
git commit -m "wiki-browser: bootstrap Go module, README, and example config"

Phase 1 — Core packages

Task 2: Config — Load and validate YAML

Files:

// internal/config/config_test.go
package config_test

import (
	"path/filepath"
	"testing"

	"github.com/getorcha/wiki-browser/internal/config"
	"github.com/google/go-cmp/cmp"
)

func TestLoad_valid(t *testing.T) {
	got, err := config.Load(filepath.Join("testdata", "valid.yaml"))
	if err != nil {
		t.Fatalf("Load: %v", err)
	}
	want := &config.Config{
		Listen:     ":8080",
		Title:      "Orcha wiki",
		Root:       "/tmp",
		Extensions: []string{".md", ".html"},
		IndexDB:    "./wiki-browser-index.db",
		Exclude:    []string{"www/**", "marketing/**"},
	}
	if diff := cmp.Diff(want, got); diff != "" {
		t.Errorf("Load() mismatch (-want +got):\n%s", diff)
	}
}

func TestLoad_appliesDefaults(t *testing.T) {
	got, err := config.Load(filepath.Join("testdata", "minimal.yaml"))
	if err != nil {
		t.Fatalf("Load: %v", err)
	}
	if got.Listen != ":8080" {
		t.Errorf("Listen default not applied: got %q", got.Listen)
	}
	if got.IndexDB != "./wiki-browser-index.db" {
		t.Errorf("IndexDB default not applied: got %q", got.IndexDB)
	}
	if !cmp.Equal(got.Extensions, []string{".md", ".html"}) {
		t.Errorf("Extensions default not applied: got %v", got.Extensions)
	}
}

func TestLoad_missingRootFails(t *testing.T) {
	_, err := config.Load(filepath.Join("testdata", "missing-root.yaml"))
	if err == nil {
		t.Fatal("expected error for missing root, got nil")
	}
}

func TestLoad_unreadableRootFails(t *testing.T) {
	_, err := config.Load(filepath.Join("testdata", "bad-root.yaml"))
	if err == nil {
		t.Fatal("expected error for unreadable root, got nil")
	}
}

Test fixtures:

# internal/config/testdata/valid.yaml
listen: ":8080"
title: "Orcha wiki"
root: "/tmp"
extensions: [".md", ".html"]
index_db: "./wiki-browser-index.db"
exclude:
  - "www/**"
  - "marketing/**"
# internal/config/testdata/minimal.yaml
title: "Minimal"
root: "/tmp"
# internal/config/testdata/missing-root.yaml
title: "no root"
# internal/config/testdata/bad-root.yaml
title: "bad root"
root: "/path/that/definitely/does/not/exist"
go test ./internal/config/... -v

Expected: build error referencing config.Load, config.Config.

// internal/config/config.go
package config

import (
	"fmt"
	"os"
	"path/filepath"

	"gopkg.in/yaml.v3"
)

// Config is the typed view of wiki-browser.yaml.
type Config struct {
	Listen     string   `yaml:"listen"`
	Title      string   `yaml:"title"`
	Root       string   `yaml:"root"`
	Extensions []string `yaml:"extensions"`
	IndexDB    string   `yaml:"index_db"`
	Exclude    []string `yaml:"exclude"`
}

// Load reads the YAML file at path, applies defaults, and validates.
func Load(path string) (*Config, error) {
	raw, err := os.ReadFile(path)
	if err != nil {
		return nil, fmt.Errorf("read %s: %w", path, err)
	}
	c := &Config{}
	if err := yaml.Unmarshal(raw, c); err != nil {
		return nil, fmt.Errorf("parse %s: %w", path, err)
	}
	c.applyDefaults()
	if err := c.validate(); err != nil {
		return nil, fmt.Errorf("validate %s: %w", path, err)
	}
	return c, nil
}

func (c *Config) applyDefaults() {
	if c.Listen == "" {
		c.Listen = ":8080"
	}
	if c.IndexDB == "" {
		c.IndexDB = "./wiki-browser-index.db"
	}
	if len(c.Extensions) == 0 {
		c.Extensions = []string{".md", ".html"}
	}
}

func (c *Config) validate() error {
	if c.Root == "" {
		return fmt.Errorf("root is required")
	}
	abs, err := filepath.Abs(c.Root)
	if err != nil {
		return fmt.Errorf("root: abs: %w", err)
	}
	c.Root = abs
	info, err := os.Stat(abs)
	if err != nil {
		return fmt.Errorf("root %s: %w", abs, err)
	}
	if !info.IsDir() {
		return fmt.Errorf("root %s: not a directory", abs)
	}
	for _, ext := range c.Extensions {
		if len(ext) == 0 || ext[0] != '.' {
			return fmt.Errorf("extension %q must start with '.'", ext)
		}
	}
	return nil
}
go test ./internal/config/... -v

Expected: 4 tests PASS.

git add internal/config go.mod go.sum
git commit -m "wiki-browser: config package — Load + validate YAML"

Task 3: Walker — exclude-pattern matcher

The walker needs to know which paths to skip. Default excludes are baked into the binary; user excludes from config layer on top.

Files:

// internal/walker/exclude_test.go
package walker

import "testing"

func TestExcludeMatcher_defaultsAlwaysApply(t *testing.T) {
	m := NewExcludeMatcher(nil)
	for _, p := range []string{
		".git/HEAD",
		"node_modules/foo/index.js",
		".worktrees/feature/file.md",
		".obsidian/workspace.json",
		".claude/skills/x.md",
		"orcha/tmp-ui-sandbox/index.html",
	} {
		if !m.Match(p) {
			t.Errorf("default exclude should match %q", p)
		}
	}
}

func TestExcludeMatcher_userPatterns(t *testing.T) {
	m := NewExcludeMatcher([]string{"www/**", "marketing/**"})
	cases := map[string]bool{
		"www/index.html":              true,
		"www/de/page.html":            true,
		"marketing/slides/index.html": true,
		"docs/orcha-controlling.html": false,
		"feature-specs/Approval.md":   false,
	}
	for p, want := range cases {
		if got := m.Match(p); got != want {
			t.Errorf("Match(%q) = %v, want %v", p, got, want)
		}
	}
}

func TestExcludeMatcher_doesNotMatchUnrelated(t *testing.T) {
	m := NewExcludeMatcher(nil)
	for _, p := range []string{
		"docs/plan.md",
		"feature-specs/Tax.md",
		"PRODUCT_ROADMAP.md",
	} {
		if m.Match(p) {
			t.Errorf("should not match %q", p)
		}
	}
}
go test ./internal/walker/... -v -run TestExcludeMatcher

Expected: undefined NewExcludeMatcher.

// internal/walker/exclude.go
package walker

import "github.com/bmatcuk/doublestar/v4"

// DefaultExcludes are baked into every walker; users cannot opt out.
// They exist purely to keep the index sane on any repo (not orcha-specific).
var DefaultExcludes = []string{
	"**/.git/**",
	"**/node_modules/**",
	"**/.worktrees/**",
	"**/.obsidian/**",
	"**/.claude/**",
	"**/tmp-*/**",
}

// ExcludeMatcher decides whether a repo-relative path should be skipped.
type ExcludeMatcher struct {
	patterns []string
}

// NewExcludeMatcher returns a matcher that combines DefaultExcludes with user.
func NewExcludeMatcher(user []string) *ExcludeMatcher {
	p := make([]string, 0, len(DefaultExcludes)+len(user))
	p = append(p, DefaultExcludes...)
	p = append(p, user...)
	return &ExcludeMatcher{patterns: p}
}

// Match returns true if path should be excluded.
// path is repo-relative, slash-separated.
func (m *ExcludeMatcher) Match(path string) bool {
	for _, pat := range m.patterns {
		ok, _ := doublestar.Match(pat, path)
		if ok {
			return true
		}
		// Also match against any prefix segment so a directory itself can be skipped:
		// e.g. pattern "**/.git/**" should match the literal directory ".git".
		if ok2, _ := doublestar.Match(pat, path+"/_"); ok2 {
			return true
		}
	}
	return false
}
go test ./internal/walker/... -v -run TestExcludeMatcher

Expected: 3 tests PASS.

git add internal/walker/exclude.go internal/walker/exclude_test.go go.mod go.sum
git commit -m "wiki-browser: walker — default + user exclude matcher"

Task 4: Walker — initial scan

Walk the tree, apply excludes and extension filters, return a sorted list of repo-relative paths.

Files:

// internal/walker/walker_test.go
package walker_test

import (
	"os"
	"path/filepath"
	"testing"

	"github.com/getorcha/wiki-browser/internal/walker"
	"github.com/google/go-cmp/cmp"
)

func writeTree(t *testing.T, files map[string]string) string {
	t.Helper()
	root := t.TempDir()
	for p, contents := range files {
		full := filepath.Join(root, p)
		if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
			t.Fatal(err)
		}
		if err := os.WriteFile(full, []byte(contents), 0o644); err != nil {
			t.Fatal(err)
		}
	}
	return root
}

func TestScan_returnsOnlyMatchingExtensions(t *testing.T) {
	root := writeTree(t, map[string]string{
		"docs/a.md":          "# a",
		"docs/b.html":        "<h1>b</h1>",
		"docs/skip.txt":      "ignore me",
		"feature-specs/x.md": "x",
	})
	w, err := walker.New(walker.Options{
		Root:       root,
		Extensions: []string{".md", ".html"},
	})
	if err != nil {
		t.Fatal(err)
	}
	got := w.Files()
	want := []string{
		"docs/a.md",
		"docs/b.html",
		"feature-specs/x.md",
	}
	if diff := cmp.Diff(want, got); diff != "" {
		t.Errorf("Files() mismatch (-want +got):\n%s", diff)
	}
}

func TestScan_appliesExcludes(t *testing.T) {
	root := writeTree(t, map[string]string{
		"docs/keep.md":           "k",
		"www/skip.md":            "s",
		"marketing/skip.md":      "s",
		".git/HEAD":              "ref",
		"orcha/tmp-foo/x.md":     "s",
		".obsidian/workspace.md": "s",
	})
	w, err := walker.New(walker.Options{
		Root:       root,
		Extensions: []string{".md"},
		Exclude:    []string{"www/**", "marketing/**"},
	})
	if err != nil {
		t.Fatal(err)
	}
	got := w.Files()
	want := []string{"docs/keep.md"}
	if diff := cmp.Diff(want, got); diff != "" {
		t.Errorf("Files() mismatch (-want +got):\n%s", diff)
	}
}
go test ./internal/walker/... -v -run TestScan

Expected: walker.New, walker.Options, Files undefined.

// internal/walker/walker.go
package walker

import (
	"fmt"
	"io/fs"
	"path/filepath"
	"sort"
	"strings"
	"sync"
)

// Options configures a Walker.
type Options struct {
	Root       string   // absolute path
	Extensions []string // e.g. [".md", ".html"]
	Exclude    []string // user excludes; defaults are always applied
}

// Walker is the source of truth for "what files exist under Root".
type Walker struct {
	opts    Options
	exclude *ExcludeMatcher
	extSet  map[string]struct{}

	mu    sync.RWMutex
	files map[string]struct{} // repo-relative slash paths
}

// New runs the initial scan and returns a Walker. The caller can then
// optionally Start() the watcher (Task 5+).
func New(opts Options) (*Walker, error) {
	if !filepath.IsAbs(opts.Root) {
		return nil, fmt.Errorf("Root must be absolute, got %q", opts.Root)
	}
	w := &Walker{
		opts:    opts,
		exclude: NewExcludeMatcher(opts.Exclude),
		extSet:  make(map[string]struct{}, len(opts.Extensions)),
		files:   make(map[string]struct{}),
	}
	for _, e := range opts.Extensions {
		w.extSet[strings.ToLower(e)] = struct{}{}
	}
	if err := w.scan(); err != nil {
		return nil, fmt.Errorf("initial scan: %w", err)
	}
	return w, nil
}

func (w *Walker) scan() error {
	return filepath.WalkDir(w.opts.Root, func(p string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		rel, rerr := filepath.Rel(w.opts.Root, p)
		if rerr != nil {
			return rerr
		}
		rel = filepath.ToSlash(rel)
		if rel == "." {
			return nil
		}
		if w.exclude.Match(rel) {
			if d.IsDir() {
				return fs.SkipDir
			}
			return nil
		}
		if d.IsDir() {
			return nil
		}
		if !w.matchesExt(rel) {
			return nil
		}
		w.files[rel] = struct{}{}
		return nil
	})
}

func (w *Walker) matchesExt(path string) bool {
	_, ok := w.extSet[strings.ToLower(filepath.Ext(path))]
	return ok
}

// Files returns the current sorted list of repo-relative paths.
func (w *Walker) Files() []string {
	w.mu.RLock()
	defer w.mu.RUnlock()
	out := make([]string, 0, len(w.files))
	for p := range w.files {
		out = append(out, p)
	}
	sort.Strings(out)
	return out
}

// Has reports whether path is in the canonical file set.
func (w *Walker) Has(path string) bool {
	w.mu.RLock()
	defer w.mu.RUnlock()
	_, ok := w.files[path]
	return ok
}
go test ./internal/walker/... -v

Expected: 5 tests PASS (3 exclude + 2 scan).

git add internal/walker/walker.go internal/walker/walker_test.go
git commit -m "wiki-browser: walker — initial directory scan with excludes"

Task 5: Walker — fsnotify watcher

Add live updates: per-directory fsnotify subscriptions, recursive subscribe on dir-CREATE, ENOSPC handling, swap-file filtering.

Files:

// internal/walker/watch_test.go
package walker_test

import (
	"errors"
	"os"
	"path/filepath"
	"testing"
	"time"

	"github.com/getorcha/wiki-browser/internal/walker"
)

func waitFor(t *testing.T, ch <-chan walker.Event, want walker.Event, timeout time.Duration) {
	t.Helper()
	deadline := time.After(timeout)
	for {
		select {
		case ev := <-ch:
			if ev == want {
				return
			}
		case <-deadline:
			t.Fatalf("timed out waiting for %+v", want)
		}
	}
}

func TestWatch_emitsCreateEvent(t *testing.T) {
	root := t.TempDir()
	w, err := walker.New(walker.Options{Root: root, Extensions: []string{".md"}})
	if err != nil {
		t.Fatal(err)
	}
	ch := make(chan walker.Event, 16)
	stop, err := w.Start(ch)
	if err != nil {
		t.Fatal(err)
	}
	defer stop()

	if err := os.WriteFile(filepath.Join(root, "new.md"), []byte("# hi"), 0o644); err != nil {
		t.Fatal(err)
	}
	waitFor(t, ch, walker.Event{Path: "new.md", Kind: walker.EventChanged}, 2*time.Second)
}

func TestWatch_emitsRemoveEvent(t *testing.T) {
	root := t.TempDir()
	if err := os.WriteFile(filepath.Join(root, "x.md"), []byte("x"), 0o644); err != nil {
		t.Fatal(err)
	}
	w, err := walker.New(walker.Options{Root: root, Extensions: []string{".md"}})
	if err != nil {
		t.Fatal(err)
	}
	ch := make(chan walker.Event, 16)
	stop, err := w.Start(ch)
	if err != nil {
		t.Fatal(err)
	}
	defer stop()

	if err := os.Remove(filepath.Join(root, "x.md")); err != nil {
		t.Fatal(err)
	}
	waitFor(t, ch, walker.Event{Path: "x.md", Kind: walker.EventRemoved}, 2*time.Second)
}

func TestWatch_subscribesToNewSubdir(t *testing.T) {
	root := t.TempDir()
	w, err := walker.New(walker.Options{Root: root, Extensions: []string{".md"}})
	if err != nil {
		t.Fatal(err)
	}
	ch := make(chan walker.Event, 16)
	stop, err := w.Start(ch)
	if err != nil {
		t.Fatal(err)
	}
	defer stop()

	sub := filepath.Join(root, "fresh")
	if err := os.MkdirAll(sub, 0o755); err != nil {
		t.Fatal(err)
	}
	// Brief settle so the new dir is subscribed.
	time.Sleep(150 * time.Millisecond)
	if err := os.WriteFile(filepath.Join(sub, "deep.md"), []byte("d"), 0o644); err != nil {
		t.Fatal(err)
	}
	waitFor(t, ch, walker.Event{Path: "fresh/deep.md", Kind: walker.EventChanged}, 2*time.Second)
}

func TestWatch_filtersSwapFiles(t *testing.T) {
	root := t.TempDir()
	w, err := walker.New(walker.Options{Root: root, Extensions: []string{".md"}})
	if err != nil {
		t.Fatal(err)
	}
	ch := make(chan walker.Event, 16)
	stop, err := w.Start(ch)
	if err != nil {
		t.Fatal(err)
	}
	defer stop()

	// These should never appear.
	for _, name := range []string{"a.md~", "a.swp", "a.swx", "a.tmp", "4913"} {
		if err := os.WriteFile(filepath.Join(root, name), []byte("x"), 0o644); err != nil {
			t.Fatal(err)
		}
	}
	// And one real file we DO expect.
	if err := os.WriteFile(filepath.Join(root, "real.md"), []byte("r"), 0o644); err != nil {
		t.Fatal(err)
	}
	waitFor(t, ch, walker.Event{Path: "real.md", Kind: walker.EventChanged}, 2*time.Second)

	// Drain briefly and ensure no swap-file events leaked.
	deadline := time.After(300 * time.Millisecond)
	for {
		select {
		case ev := <-ch:
			if ev.Path != "real.md" {
				t.Errorf("unexpected event for swap-like file: %+v", ev)
			}
		case <-deadline:
			return
		}
	}
}

func TestWatch_continuesOnENOSPC(t *testing.T) {
	// We can't induce real ENOSPC portably; instead, ensure Start() does not
	// abort if a subdir cannot be added (simulate by pointing at a removed dir).
	root := t.TempDir()
	subDir := filepath.Join(root, "gone")
	if err := os.MkdirAll(subDir, 0o755); err != nil {
		t.Fatal(err)
	}
	w, err := walker.New(walker.Options{Root: root, Extensions: []string{".md"}})
	if err != nil {
		t.Fatal(err)
	}
	if err := os.RemoveAll(subDir); err != nil {
		t.Fatal(err)
	}
	ch := make(chan walker.Event, 4)
	stop, err := w.Start(ch)
	if !errors.Is(err, nil) {
		// We tolerate but log; we must not abort with hard failure.
		t.Logf("Start() returned %v; expected to continue without panic", err)
	}
	if stop != nil {
		stop()
	}
}
go test ./internal/walker/... -v -run TestWatch

Expected: undefined Start, Event, EventChanged, EventRemoved.

// internal/walker/watch.go
package walker

import (
	"errors"
	"io/fs"
	"log/slog"
	"os"
	"path/filepath"
	"strings"
	"sync"
	"syscall"

	"github.com/fsnotify/fsnotify"
)

// EventKind discriminates walker events.
type EventKind int

const (
	EventChanged EventKind = iota + 1 // file is new or modified
	EventRemoved                      // file is gone
)

// Event is the walker's notification primitive: repo-relative path + kind.
type Event struct {
	Path string
	Kind EventKind
}

// Start begins watching the tree. Events are sent on out (caller must drain).
// Returns a stop func that closes the watcher and waits for the goroutine to exit.
func (w *Walker) Start(out chan<- Event) (stop func(), err error) {
	watcher, err := fsnotify.NewWatcher()
	if err != nil {
		return nil, err
	}
	// Subscribe to every directory under root that survives excludes.
	if err := w.addDirsRecursive(watcher, w.opts.Root); err != nil {
		// Subscription errors (e.g. ENOSPC) are logged but not fatal.
		slog.Warn("walker: watcher add failed", "err", err)
	}
	done := make(chan struct{})
	go w.loop(watcher, out, done)
	stop = func() {
		_ = watcher.Close()
		<-done
	}
	return stop, nil
}

func (w *Walker) addDirsRecursive(watcher *fsnotify.Watcher, dir string) error {
	return filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
		if err != nil {
			return nil // best-effort
		}
		if !d.IsDir() {
			return nil
		}
		rel, rerr := filepath.Rel(w.opts.Root, p)
		if rerr != nil {
			return nil
		}
		rel = filepath.ToSlash(rel)
		if rel != "." && w.exclude.Match(rel) {
			return fs.SkipDir
		}
		if err := watcher.Add(p); err != nil {
			if errors.Is(err, syscall.ENOSPC) {
				slog.Warn("walker: inotify watch limit reached; subtree will not receive live updates",
					"path", p,
					"hint", "raise fs.inotify.max_user_watches via sysctl")
				// Don't keep trying deeper; we'd only fail the same way.
				return fs.SkipDir
			}
			slog.Warn("walker: watcher.Add failed", "path", p, "err", err)
		}
		return nil
	})
}

func (w *Walker) loop(watcher *fsnotify.Watcher, out chan<- Event, done chan<- struct{}) {
	defer close(done)
	for {
		select {
		case ev, ok := <-watcher.Events:
			if !ok {
				return
			}
			w.handleFSEvent(watcher, ev, out)
		case err, ok := <-watcher.Errors:
			if !ok {
				return
			}
			slog.Warn("walker: fsnotify error", "err", err)
		}
	}
}

// swapSuffixes are editor temp/swap files we ignore entirely.
var swapSuffixes = []string{"~", ".swp", ".swx", ".swo", ".tmp"}

func isSwapName(name string) bool {
	for _, s := range swapSuffixes {
		if strings.HasSuffix(name, s) {
			return true
		}
	}
	// Vim's atomic-write sentinel.
	if name == "4913" {
		return true
	}
	return false
}

func (w *Walker) handleFSEvent(watcher *fsnotify.Watcher, ev fsnotify.Event, out chan<- Event) {
	rel, err := filepath.Rel(w.opts.Root, ev.Name)
	if err != nil {
		return
	}
	rel = filepath.ToSlash(rel)
	if isSwapName(filepath.Base(rel)) {
		return
	}

	// If it's a new directory, add it to the watcher.
	if ev.Has(fsnotify.Create) {
		info, statErr := os.Stat(ev.Name)
		if statErr == nil && info.IsDir() {
			if !w.exclude.Match(rel) {
				_ = w.addDirsRecursive(watcher, ev.Name)
			}
			return
		}
	}

	if w.exclude.Match(rel) || !w.matchesExt(rel) {
		return
	}

	w.mu.Lock()
	if ev.Has(fsnotify.Remove) || ev.Has(fsnotify.Rename) {
		delete(w.files, rel)
		w.mu.Unlock()
		out <- Event{Path: rel, Kind: EventRemoved}
		return
	}
	if ev.Has(fsnotify.Create) || ev.Has(fsnotify.Write) {
		w.files[rel] = struct{}{}
		w.mu.Unlock()
		out <- Event{Path: rel, Kind: EventChanged}
		return
	}
	w.mu.Unlock()
}

// (compile-time assertion that sync is used elsewhere)
var _ = sync.RWMutex{}
go test ./internal/walker/... -v

Expected: all tests PASS. (The fsnotify-based tests can flake on slow CI; if you see flakes locally, raise the timeout in waitFor.)

git add internal/walker/watch.go internal/walker/watch_test.go
git commit -m "wiki-browser: walker — fsnotify live updates with swap-file filtering"

Task 6: Walker — debounce + Subscribe API

Coalesce bursty events through a 300 ms quiet window per path. The public output is Subscribe(ctx) <-chan Event.

Files:

// internal/walker/debounce_test.go
package walker_test

import (
	"context"
	"os"
	"path/filepath"
	"testing"
	"time"

	"github.com/getorcha/wiki-browser/internal/walker"
)

func TestSubscribe_coalescesBurstyEvents(t *testing.T) {
	root := t.TempDir()
	w, err := walker.New(walker.Options{Root: root, Extensions: []string{".md"}})
	if err != nil {
		t.Fatal(err)
	}
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	ch := w.Subscribe(ctx)

	// Burst: write a file 10 times rapidly. Expect exactly one Changed event.
	target := filepath.Join(root, "burst.md")
	for i := 0; i < 10; i++ {
		if err := os.WriteFile(target, []byte{byte(i)}, 0o644); err != nil {
			t.Fatal(err)
		}
		time.Sleep(20 * time.Millisecond)
	}
	// Wait for the debounce window to elapse plus a margin.
	deadline := time.After(700 * time.Millisecond)
	got := 0
	for {
		select {
		case ev := <-ch:
			if ev.Path == "burst.md" {
				got++
			}
		case <-deadline:
			if got != 1 {
				t.Errorf("expected 1 coalesced event, got %d", got)
			}
			return
		}
	}
}
go test ./internal/walker/... -v -run TestSubscribe

Expected: undefined Subscribe.

// internal/walker/debounce.go
package walker

import (
	"context"
	"sync"
	"time"
)

// DebounceWindow is the quiet period after the last event for a given path
// before emitting downstream. 300 ms balances coalescing a `git pull`'s burst
// against perceived staleness for an interactive edit.
const DebounceWindow = 300 * time.Millisecond

type debouncer struct {
	mu      sync.Mutex
	pending map[string]*time.Timer
	out     chan<- Event
}

func newDebouncer(out chan<- Event) *debouncer {
	return &debouncer{
		pending: make(map[string]*time.Timer),
		out:     out,
	}
}

func (d *debouncer) push(ctx context.Context, ev Event) {
	d.mu.Lock()
	defer d.mu.Unlock()
	if t, ok := d.pending[ev.Path]; ok {
		t.Stop()
	}
	final := ev // capture by value
	d.pending[ev.Path] = time.AfterFunc(DebounceWindow, func() {
		d.mu.Lock()
		delete(d.pending, final.Path)
		d.mu.Unlock()
		select {
		case d.out <- final:
		case <-ctx.Done():
		}
	})
}

Append to internal/walker/walker.go:

// Subscribe returns a channel of debounced Events. The walker takes care of
// starting the watcher on first call; the channel closes when ctx is cancelled.
func (w *Walker) Subscribe(ctx context.Context) <-chan Event {
	out := make(chan Event, 64)
	raw := make(chan Event, 64)
	stop, err := w.Start(raw)
	if err != nil {
		// Surface a single error event then close; callers should handle.
		go func() { close(out) }()
		return out
	}
	d := newDebouncer(out)
	go func() {
		defer stop()
		defer close(out)
		for {
			select {
			case ev, ok := <-raw:
				if !ok {
					return
				}
				d.push(ctx, ev)
			case <-ctx.Done():
				return
			}
		}
	}()
	return out
}

Add import "context" to walker.go if not already present.

go test ./internal/walker/... -v

Expected: all walker tests PASS.

git add internal/walker
git commit -m "wiki-browser: walker — debounced Subscribe API"

Task 7: Render — Document type and dispatch

Set up the Render entry point and dispatch by file extension.

Files:

// internal/render/render_test.go
package render_test

import (
	"os"
	"path/filepath"
	"strings"
	"testing"

	"github.com/getorcha/wiki-browser/internal/render"
)

func TestRender_dispatchesByExtension(t *testing.T) {
	dir := t.TempDir()
	md := filepath.Join(dir, "a.md")
	if err := os.WriteFile(md, []byte("# Title\n\nbody"), 0o644); err != nil {
		t.Fatal(err)
	}
	doc, err := render.Render(md)
	if err != nil {
		t.Fatal(err)
	}
	if !strings.Contains(doc.HTML, "<h1") {
		t.Errorf("expected rendered MD to contain <h1>, got: %s", doc.HTML)
	}
	if doc.Title != "Title" {
		t.Errorf("Title = %q, want %q", doc.Title, "Title")
	}
}

func TestRender_unknownExtensionFails(t *testing.T) {
	dir := t.TempDir()
	p := filepath.Join(dir, "x.txt")
	if err := os.WriteFile(p, []byte("nope"), 0o644); err != nil {
		t.Fatal(err)
	}
	if _, err := render.Render(p); err == nil {
		t.Error("expected error for unknown extension")
	}
}
go test ./internal/render/... -v -run TestRender_dispatch

Expected: undefined render.Render, render.Document.

// internal/render/render.go
package render

import (
	"fmt"
	"path/filepath"
	"strings"
)

// Document is a fully-rendered standalone HTML document, plus metadata used by
// the index and the server's iframe-injection logic.
type Document struct {
	HTML        string // complete HTML5 document
	PlainText   string // for FTS body indexing
	Title       string // front-matter title or first H1, fallback: filename
	HasMermaid  bool   // true if source contained at least one mermaid fence
}

// Render turns a file at absPath into a Document. Dispatches by extension.
func Render(absPath string) (*Document, error) {
	switch strings.ToLower(filepath.Ext(absPath)) {
	case ".md":
		return renderMarkdown(absPath)
	case ".html":
		return renderHTML(absPath)
	default:
		return nil, fmt.Errorf("render: unsupported extension for %s", absPath)
	}
}

Stub the implementations so the package compiles:

// internal/render/markdown.go (stub for now)
package render

import "fmt"

func renderMarkdown(absPath string) (*Document, error) {
	return nil, fmt.Errorf("renderMarkdown: not implemented")
}
// internal/render/html.go (stub for now)
package render

import "fmt"

func renderHTML(absPath string) (*Document, error) {
	return nil, fmt.Errorf("renderHTML: not implemented")
}
go test ./internal/render/... -v -run TestRender_dispatch

Expected: dispatch test fails (renderMarkdown stub). Unknown-ext test passes.

git add internal/render
git commit -m "wiki-browser: render — Document type and extension dispatch"

Task 8: Render — Markdown via goldmark + chroma + mermaid extension

Wire goldmark with GFM, footnotes, definition lists, autolinks, front-matter, chroma highlighting, and a custom extension that emits <pre class="mermaid"> for ```mermaid fences.

Files:

// internal/render/markdown_test.go
package render

import (
	"flag"
	"os"
	"path/filepath"
	"strings"
	"testing"
)

var update = flag.Bool("update", false, "update golden files")

func TestRenderMarkdown_goldens(t *testing.T) {
	cases := []struct {
		name    string
		hasMerm bool
		title   string
	}{
		{"basic", false, "Hello"},
		{"gfm", false, "GFM"},
		{"code", false, "Code"},
		{"mermaid", true, "Mermaid"},
		{"frontmatter", false, "From front matter"},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			in := filepath.Join("testdata", tc.name+".md")
			doc, err := Render(in)
			if err != nil {
				t.Fatal(err)
			}
			if doc.HasMermaid != tc.hasMerm {
				t.Errorf("HasMermaid = %v, want %v", doc.HasMermaid, tc.hasMerm)
			}
			if doc.Title != tc.title {
				t.Errorf("Title = %q, want %q", doc.Title, tc.title)
			}
			golden := filepath.Join("testdata", tc.name+".golden.html")
			if *update {
				if err := os.WriteFile(golden, []byte(doc.HTML), 0o644); err != nil {
					t.Fatal(err)
				}
				return
			}
			want, err := os.ReadFile(golden)
			if err != nil {
				t.Fatalf("read golden: %v", err)
			}
			if string(want) != doc.HTML {
				// Show a small diff hint.
				ml := func(s string) string {
					if len(s) > 200 {
						return s[:200] + "..."
					}
					return s
				}
				t.Errorf("HTML mismatch.\n--- want ---\n%s\n--- got ---\n%s",
					ml(string(want)), ml(doc.HTML))
			}
			// Positive structural assertions catch the class of bug where the
			// mermaid extension accidentally swallows non-mermaid fences.
			switch tc.name {
			case "code":
				if !strings.Contains(doc.HTML, `class="chroma"`) && !strings.Contains(doc.HTML, `<span class="`) {
					t.Errorf("code output lacks chroma highlight markup; mermaid extension may be intercepting non-mermaid fences")
				}
			case "mermaid":
				if !strings.Contains(doc.HTML, `<pre class="mermaid">`) {
					t.Errorf("mermaid output missing <pre class=\"mermaid\"> wrapper")
				}
			}
		})
	}
}

Test fixtures:

<!-- internal/render/testdata/basic.md -->
# Hello

A paragraph with **bold** and *italic*.

- one
- two
<!-- internal/render/testdata/gfm.md -->
# GFM

| a | b |
|---|---|
| 1 | 2 |

- [x] done
- [ ] todo

~~struck~~
<!-- internal/render/testdata/code.md -->
# Code

```go
package main

func main() {
    println("hi")
}
```
<!-- internal/render/testdata/mermaid.md -->
# Mermaid

```mermaid
graph TD
  A --> B
```
<!-- internal/render/testdata/frontmatter.md -->
---
title: From front matter
---

# Header that should not become Title

text

(Don't pre-create the .golden.html files — Step 5 generates them with -update.)

The naive approach — registering a KindFencedCodeBlock renderer at the same priority as goldmark-highlighting and trying to "fall through" with WalkContinue — does NOT work in goldmark. Renderer registration is a single-slot map per kind, and lower-priority registrations overwrite higher-priority ones. The correct approach is a parser-level AST transformer that converts mermaid fences into a custom node BEFORE the highlighter sees them, plus a NodeRenderer for the new kind. Other fences remain KindFencedCodeBlock and chroma handles them normally.

// internal/render/mermaid_extension.go
package render

import (
	"github.com/yuin/goldmark"
	"github.com/yuin/goldmark/ast"
	"github.com/yuin/goldmark/parser"
	"github.com/yuin/goldmark/renderer"
	"github.com/yuin/goldmark/text"
	"github.com/yuin/goldmark/util"
)

// KindMermaidBlock is the AST kind for mermaid code fences after the
// transformer has rewritten them.
var KindMermaidBlock = ast.NewNodeKind("MermaidBlock")

type mermaidBlock struct {
	ast.BaseBlock
	Source []byte
}

func (n *mermaidBlock) Kind() ast.NodeKind            { return KindMermaidBlock }
func (n *mermaidBlock) Dump(src []byte, level int)    { ast.DumpHelper(n, src, level, nil, nil) }

// mermaidTransformer walks the parsed AST and replaces `mermaid`-fenced
// blocks with a *mermaidBlock so the highlighter never sees them.
type mermaidTransformer struct{ saw *bool }

func (t *mermaidTransformer) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) {
	src := reader.Source()
	var toReplace []*ast.FencedCodeBlock
	_ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
		if !entering {
			return ast.WalkContinue, nil
		}
		cb, ok := n.(*ast.FencedCodeBlock)
		if !ok {
			return ast.WalkContinue, nil
		}
		if string(cb.Language(src)) != "mermaid" {
			return ast.WalkContinue, nil
		}
		toReplace = append(toReplace, cb)
		return ast.WalkSkipChildren, nil
	})
	for _, cb := range toReplace {
		var body []byte
		lines := cb.Lines()
		for i := 0; i < lines.Len(); i++ {
			body = append(body, lines.At(i).Value(src)...)
		}
		*t.saw = true
		replacement := &mermaidBlock{Source: body}
		parent := cb.Parent()
		parent.ReplaceChild(parent, cb, replacement)
	}
}

// mermaidNodeRenderer emits <pre class="mermaid">…</pre> for *mermaidBlock.
// It only ever fires for our custom kind, so it cannot conflict with chroma.
type mermaidNodeRenderer struct{}

func (r *mermaidNodeRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
	reg.Register(KindMermaidBlock, r.render)
}

func (r *mermaidNodeRenderer) render(w util.BufWriter, src []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
	if !entering {
		return ast.WalkContinue, nil
	}
	mb := n.(*mermaidBlock)
	_, _ = w.WriteString(`<pre class="mermaid">`)
	_, _ = w.Write(util.EscapeHTML(mb.Source))
	_, _ = w.WriteString("</pre>\n")
	return ast.WalkSkipChildren, nil
}

// mermaidExt wires the transformer + renderer.
type mermaidExt struct{ saw *bool }

func (e *mermaidExt) Extend(m goldmark.Markdown) {
	m.Parser().AddOptions(parser.WithASTTransformers(
		util.Prioritized(&mermaidTransformer{saw: e.saw}, 100),
	))
	m.Renderer().AddOptions(renderer.WithNodeRenderers(
		util.Prioritized(&mermaidNodeRenderer{}, 100),
	))
}
// internal/render/markdown.go
package render

import (
	"bytes"
	"fmt"
	"os"
	"path/filepath"
	"strings"

	"github.com/yuin/goldmark"
	"github.com/yuin/goldmark/ast"
	"github.com/yuin/goldmark/extension"
	"github.com/yuin/goldmark/parser"
	"github.com/yuin/goldmark/renderer/html"
	"github.com/yuin/goldmark/text"
	highlighting "github.com/yuin/goldmark-highlighting/v2"

	"go.abhg.dev/goldmark/frontmatter" // see step 4 below for the import line
)

// (Note on frontmatter import:)
// The plan uses `go.abhg.dev/goldmark/frontmatter` because goldmark itself
// doesn't ship a front-matter extension. If that import isn't available,
// substitute any stable front-matter extension and adjust the data extraction
// in renderMarkdown accordingly.

func renderMarkdown(absPath string) (*Document, error) {
	src, err := os.ReadFile(absPath)
	if err != nil {
		return nil, fmt.Errorf("read %s: %w", absPath, err)
	}

	hasMermaid := false
	md := goldmark.New(
		goldmark.WithExtensions(
			extension.GFM,
			extension.Footnote,
			extension.DefinitionList,
			extension.Linkify,
			highlighting.NewHighlighting(
				highlighting.WithStyle("github"),
			),
			&frontmatter.Extender{},
			&mermaidExt{saw: &hasMermaid},
		),
		goldmark.WithParserOptions(
			parser.WithAutoHeadingID(),
		),
		goldmark.WithRendererOptions(
			html.WithUnsafe(), // we trust authored content (LAN/WireGuard-gated)
		),
	)

	ctx := parser.NewContext()
	var body bytes.Buffer
	reader := text.NewReader(src)
	doc := md.Parser().Parse(reader, parser.WithContext(ctx))
	if err := md.Renderer().Render(&body, src, doc); err != nil {
		return nil, fmt.Errorf("render %s: %w", absPath, err)
	}

	title := titleFromFrontmatter(ctx)
	if title == "" {
		title = firstH1(doc, src)
	}
	if title == "" {
		title = strings.TrimSuffix(filepath.Base(absPath), filepath.Ext(absPath))
	}

	plain := plaintextFromAST(doc, src)

	full := wrapMarkdownDocument(title, body.String(), hasMermaid)

	return &Document{
		HTML:       full,
		PlainText:  plain,
		Title:      title,
		HasMermaid: hasMermaid,
	}, nil
}

func titleFromFrontmatter(ctx parser.Context) string {
	d := frontmatter.Get(ctx)
	if d == nil {
		return ""
	}
	var meta struct {
		Title string `yaml:"title"`
	}
	if err := d.Decode(&meta); err != nil {
		return ""
	}
	return meta.Title
}

func firstH1(root ast.Node, src []byte) string {
	var title string
	ast.Walk(root, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
		if !entering {
			return ast.WalkContinue, nil
		}
		h, ok := n.(*ast.Heading)
		if !ok || h.Level != 1 {
			return ast.WalkContinue, nil
		}
		title = string(h.Text(src))
		return ast.WalkStop, nil
	})
	return title
}
go get go.abhg.dev/goldmark/frontmatter
go mod tidy

If the import path is unresolvable in your environment, swap to any front-matter extension you trust and adjust titleFromFrontmatter to read its result. The rest of the plan is unaffected.

// internal/render/markdown.go (append)
func wrapMarkdownDocument(title, body string, hasMermaid bool) string {
	// Minimal wrapper for now — Task 18 replaces this with a templated layout.
	mermaidScript := ""
	if hasMermaid {
		mermaidScript = `<script type="module" src="/static/mermaid.esm.min.mjs"></script>` +
			`<script type="module">import m from '/static/mermaid.esm.min.mjs'; m.run();</script>`
	}
	return "<!doctype html><html><head><meta charset=\"utf-8\"><title>" +
		htmlEscape(title) + "</title><link rel=\"stylesheet\" href=\"/static/prose.css\">" +
		mermaidScript +
		"</head><body class=\"wb-prose\">" + body +
		"<script src=\"/static/content.js\"></script></body></html>"
}

func htmlEscape(s string) string {
	r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", "\"", "&quot;")
	return r.Replace(s)
}

func plaintextFromAST(root ast.Node, src []byte) string {
	var b strings.Builder
	ast.Walk(root, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
		if !entering {
			return ast.WalkContinue, nil
		}
		if t, ok := n.(*ast.Text); ok {
			b.Write(t.Segment.Value(src))
			b.WriteByte(' ')
		}
		return ast.WalkContinue, nil
	})
	return strings.TrimSpace(b.String())
}
go test ./internal/render/... -run TestRenderMarkdown_goldens -update

Expected: writes *.golden.html files; tests pass.

Open each golden file and confirm: HTML is sensible, code block has chroma classes, mermaid fence is <pre class="mermaid">, front-matter title was used, the basic and gfm cases look like GitHub-flavored output.

go test ./internal/render/... -v

Expected: all golden tests PASS.

git add internal/render go.mod go.sum
git commit -m "wiki-browser: render — markdown via goldmark/chroma with mermaid extension"

Task 9: Render — HTML pass-through

Authored HTML files are served byte-identical. The Document still carries a Title and a PlainText so the index has something to search on.

Files:

// internal/render/html_test.go
package render

import (
	"os"
	"path/filepath"
	"strings"
	"testing"
)

func TestRenderHTML_byteIdentical(t *testing.T) {
	in := filepath.Join("testdata", "sample.html")
	want, err := os.ReadFile(in)
	if err != nil {
		t.Fatal(err)
	}
	doc, err := Render(in)
	if err != nil {
		t.Fatal(err)
	}
	if doc.HTML != string(want) {
		t.Errorf("HTML mismatch — pass-through must be byte-identical")
	}
	if doc.HasMermaid {
		t.Errorf("HasMermaid should be false for pass-through HTML")
	}
}

func TestRenderHTML_extractsTitleAndPlainText(t *testing.T) {
	doc, err := Render(filepath.Join("testdata", "sample.html"))
	if err != nil {
		t.Fatal(err)
	}
	if doc.Title != "Sample" {
		t.Errorf("Title = %q, want %q", doc.Title, "Sample")
	}
	if !strings.Contains(doc.PlainText, "body text") {
		t.Errorf("PlainText missing expected substring; got %q", doc.PlainText)
	}
}

Fixture:

<!-- internal/render/testdata/sample.html -->
<!doctype html>
<html>
<head><title>Sample</title></head>
<body><h1>Sample</h1><p>body text goes here.</p></body>
</html>
go test ./internal/render/... -v -run TestRenderHTML

Expected: renderHTML not implemented.

// internal/render/html.go
package render

import (
	"bytes"
	"fmt"
	"os"
	"path/filepath"
	"strings"

	"golang.org/x/net/html"
)

func renderHTML(absPath string) (*Document, error) {
	raw, err := os.ReadFile(absPath)
	if err != nil {
		return nil, fmt.Errorf("read %s: %w", absPath, err)
	}
	doc, err := html.Parse(bytes.NewReader(raw))
	if err != nil {
		return nil, fmt.Errorf("parse %s: %w", absPath, err)
	}
	title := htmlExtractTitle(doc)
	if title == "" {
		title = strings.TrimSuffix(filepath.Base(absPath), filepath.Ext(absPath))
	}
	plain := htmlExtractText(doc)
	return &Document{
		HTML:       string(raw),
		PlainText:  plain,
		Title:      title,
		HasMermaid: false,
	}, nil
}

func htmlExtractTitle(n *html.Node) string {
	if n.Type == html.ElementNode && n.Data == "title" && n.FirstChild != nil {
		return strings.TrimSpace(n.FirstChild.Data)
	}
	for c := n.FirstChild; c != nil; c = c.NextSibling {
		if t := htmlExtractTitle(c); t != "" {
			return t
		}
	}
	return ""
}

func htmlExtractText(n *html.Node) string {
	var b strings.Builder
	var walk func(*html.Node)
	walk = func(n *html.Node) {
		if n.Type == html.ElementNode && (n.Data == "script" || n.Data == "style") {
			return
		}
		if n.Type == html.TextNode {
			s := strings.TrimSpace(n.Data)
			if s != "" {
				b.WriteString(s)
				b.WriteByte(' ')
			}
		}
		for c := n.FirstChild; c != nil; c = c.NextSibling {
			walk(c)
		}
	}
	walk(n)
	return strings.TrimSpace(b.String())
}
go get golang.org/x/net/html
go mod tidy
go test ./internal/render/... -v

Expected: all render tests PASS (markdown + html).

git add internal/render go.mod go.sum
git commit -m "wiki-browser: render — HTML pass-through with title/plaintext extraction"

Task 10: Render — LRU byte-bounded cache

Wrap Render in a cache keyed by (absPath, mtime, size), bounded by approximate bytes. The byte size of a cache entry is len(HTML) + len(PlainText).

Files:

// internal/render/cache_test.go
package render_test

import (
	"os"
	"path/filepath"
	"strings"
	"testing"

	"github.com/getorcha/wiki-browser/internal/render"
)

func TestCache_returnsSameDocumentOnHit(t *testing.T) {
	dir := t.TempDir()
	p := filepath.Join(dir, "x.md")
	if err := os.WriteFile(p, []byte("# x"), 0o644); err != nil {
		t.Fatal(err)
	}
	c := render.NewCache(1024 * 1024)
	a, err := c.Get(p)
	if err != nil {
		t.Fatal(err)
	}
	b, err := c.Get(p)
	if err != nil {
		t.Fatal(err)
	}
	if a != b {
		t.Errorf("cache hit should return same *Document pointer")
	}
}

func TestCache_invalidatesOnMtimeChange(t *testing.T) {
	dir := t.TempDir()
	p := filepath.Join(dir, "x.md")
	if err := os.WriteFile(p, []byte("# v1"), 0o644); err != nil {
		t.Fatal(err)
	}
	c := render.NewCache(1024 * 1024)
	a, err := c.Get(p)
	if err != nil {
		t.Fatal(err)
	}
	// Rewrite with different content; mtime advances.
	if err := os.WriteFile(p, []byte("# v2 more body so size differs"), 0o644); err != nil {
		t.Fatal(err)
	}
	b, err := c.Get(p)
	if err != nil {
		t.Fatal(err)
	}
	if a == b {
		t.Errorf("cache should re-render after content change")
	}
	if !strings.Contains(b.HTML, "v2") {
		t.Errorf("re-rendered document missing new content: %q", b.HTML)
	}
}

func TestCache_evictsByByteBudget(t *testing.T) {
	dir := t.TempDir()
	// Small budget, plus several files that each exceed half of it.
	budget := 4096
	c := render.NewCache(int64(budget))
	for i := 0; i < 6; i++ {
		p := filepath.Join(dir, "f"+string(rune('a'+i))+".md")
		body := strings.Repeat("hello ", 600) // ~3.6 KB rendered
		if err := os.WriteFile(p, []byte("# t\n\n"+body), 0o644); err != nil {
			t.Fatal(err)
		}
		if _, err := c.Get(p); err != nil {
			t.Fatal(err)
		}
	}
	if c.Bytes() > int64(budget) {
		t.Errorf("cache exceeded byte budget: %d > %d", c.Bytes(), budget)
	}
}
go test ./internal/render/... -v -run TestCache

Expected: undefined NewCache.

// internal/render/cache.go
package render

import (
	"container/list"
	"fmt"
	"os"
	"sync"

	"golang.org/x/sync/singleflight"
)

// Cache is a byte-bounded LRU around Render. Entries are pointers, safe to
// share concurrently because Documents are immutable after construction.
//
// Concurrency note: a singleflight.Group deduplicates concurrent renders of
// the same path. Without it, a burst of requests for an uncached file would
// each fork their own Render, wasting CPU and briefly multiplying memory.
type Cache struct {
	maxBytes int64

	mu      sync.Mutex
	bytes   int64
	order   *list.List // front = most recent
	entries map[string]*list.Element

	sf singleflight.Group
}

type cacheKey struct {
	path  string
	mtime int64 // unix seconds — must match index.upsertRow's resolution
	size  int64
}

type cacheEntry struct {
	key  cacheKey
	doc  *Document
	cost int64
}

// NewCache returns a cache with the given byte budget.
func NewCache(maxBytes int64) *Cache {
	return &Cache{
		maxBytes: maxBytes,
		order:    list.New(),
		entries:  make(map[string]*list.Element),
	}
}

// Get returns the cached Document for absPath, rendering on miss.
func (c *Cache) Get(absPath string) (*Document, error) {
	info, err := os.Stat(absPath)
	if err != nil {
		return nil, fmt.Errorf("stat %s: %w", absPath, err)
	}
	// Use unix seconds to keep the cache key in lock-step with the index's
	// stored mtime. UnixNano would give us ostensibly finer resolution but
	// the FS doesn't reliably preserve sub-second granularity across syncs,
	// and the two layers MUST agree on what counts as "changed" so a
	// reindex and a cache invalidation fire on the same edits.
	key := cacheKey{path: absPath, mtime: info.ModTime().Unix(), size: info.Size()}

	c.mu.Lock()
	if el, ok := c.entries[absPath]; ok {
		entry := el.Value.(*cacheEntry)
		if entry.key == key {
			c.order.MoveToFront(el)
			c.mu.Unlock()
			return entry.doc, nil
		}
		// Stale: drop and re-render under singleflight.
		c.evictElement(el)
	}
	c.mu.Unlock()

	// Coalesce concurrent renders of the same path.
	v, err, _ := c.sf.Do(absPath, func() (any, error) {
		// Re-check under singleflight: another concurrent caller may have
		// just populated the entry for this exact key.
		c.mu.Lock()
		if el, ok := c.entries[absPath]; ok {
			if entry := el.Value.(*cacheEntry); entry.key == key {
				c.order.MoveToFront(el)
				c.mu.Unlock()
				return entry.doc, nil
			}
		}
		c.mu.Unlock()

		doc, err := Render(absPath)
		if err != nil {
			return nil, err
		}
		cost := int64(len(doc.HTML) + len(doc.PlainText))

		c.mu.Lock()
		defer c.mu.Unlock()
		for c.bytes+cost > c.maxBytes && c.order.Len() > 0 {
			c.evictElement(c.order.Back())
		}
		entry := &cacheEntry{key: key, doc: doc, cost: cost}
		el := c.order.PushFront(entry)
		c.entries[absPath] = el
		c.bytes += cost
		return doc, nil
	})
	if err != nil {
		return nil, err
	}
	return v.(*Document), nil
}

// Bytes returns the current cache occupancy in bytes (test/inspection use).
func (c *Cache) Bytes() int64 {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.bytes
}

func (c *Cache) evictElement(el *list.Element) {
	entry := el.Value.(*cacheEntry)
	c.order.Remove(el)
	delete(c.entries, entry.key.path)
	c.bytes -= entry.cost
}
go test ./internal/render/... -v

Expected: all render tests PASS.

git add internal/render
git commit -m "wiki-browser: render — byte-bounded LRU cache"

Task 11: Index — schema + open

Open or create the SQLite FTS5 database, manage a schema-version pragma, surface startup-state errors.

Files:

// internal/index/index_test.go
package index_test

import (
	"path/filepath"
	"testing"

	"github.com/getorcha/wiki-browser/internal/index"
)

func TestOpen_createsSchemaOnFreshDB(t *testing.T) {
	db := filepath.Join(t.TempDir(), "fresh.db")
	idx, err := index.Open(db)
	if err != nil {
		t.Fatal(err)
	}
	defer idx.Close()
	if got := idx.SchemaVersion(); got != index.SchemaVersion {
		t.Errorf("SchemaVersion = %d, want %d", got, index.SchemaVersion)
	}
}

func TestOpen_failsOnSchemaMismatch(t *testing.T) {
	db := filepath.Join(t.TempDir(), "mismatch.db")
	// First open: writes the current schema.
	idx, err := index.Open(db)
	if err != nil {
		t.Fatal(err)
	}
	if err := idx.SetSchemaVersionForTest(0); err != nil {
		t.Fatal(err)
	}
	idx.Close()

	if _, err := index.Open(db); err == nil {
		t.Error("expected schema-mismatch error, got nil")
	}
}
go test ./internal/index/... -v -run TestOpen

Expected: undefined index.Open, index.SchemaVersion.

// internal/index/schema.go
package index

// SchemaVersion is the current expected user_version. Bump on any breaking
// change to the FTS5 schema.
const SchemaVersion = 1

const schemaSQL = `
CREATE VIRTUAL TABLE IF NOT EXISTS docs USING fts5(
    path,
    title,
    body,
    mtime UNINDEXED,
    size UNINDEXED,
    tokenize = 'unicode61 remove_diacritics 2'
);
PRAGMA user_version = 1;
`
// internal/index/index.go
package index

import (
	"context"
	"database/sql"
	"fmt"
	"sync"

	_ "modernc.org/sqlite"

	"github.com/getorcha/wiki-browser/internal/render"
)

// Index owns the FTS5 database. All public methods are safe to call from
// many goroutines; mutations are funneled through a single goroutine that
// is started by Open and stopped by Close.
//
// cache is the shared render cache. When set via SetCache, the funnel
// goroutine renders documents through it during Reindex so the same
// rendered output is reused by /content/{path} reads (and vice versa).
// SetCache must be called before the first Reindex; main.go does this
// during startup.
type Index struct {
	db     *sql.DB
	in     chan mutation
	root   string
	cache  *render.Cache
	cancel context.CancelFunc
	wg     sync.WaitGroup
}

// Open opens or creates the index DB at path. Returns an error if the file
// has a schema version other than the current one. The returned Index has
// already started its mutation funnel goroutine; Close() stops it.
func Open(path string) (*Index, error) {
	db, err := sql.Open("sqlite", path)
	if err != nil {
		return nil, fmt.Errorf("open %s: %w", path, err)
	}

	// SQLite + database/sql: pin to a single physical connection so the
	// funnel goroutine's writes never collide with concurrent /search reads
	// (SQLITE_BUSY in default rollback-journal mode). WAL + busy_timeout
	// are belt-and-braces in case MaxOpenConns is ever raised by mistake.
	db.SetMaxOpenConns(1)
	for _, pragma := range []string{
		`PRAGMA journal_mode=WAL`,
		`PRAGMA synchronous=NORMAL`,
		`PRAGMA busy_timeout=5000`,
	} {
		if _, err := db.Exec(pragma); err != nil {
			_ = db.Close()
			return nil, fmt.Errorf("set %s: %w", pragma, err)
		}
	}

	// Detect existing version.
	var ver int
	if err := db.QueryRow("PRAGMA user_version").Scan(&ver); err != nil {
		_ = db.Close()
		return nil, fmt.Errorf("read user_version: %w", err)
	}
	switch ver {
	case 0:
		if _, err := db.Exec(schemaSQL); err != nil {
			_ = db.Close()
			return nil, fmt.Errorf("create schema: %w", err)
		}
	case SchemaVersion:
		// fine
	default:
		_ = db.Close()
		return nil, fmt.Errorf("schema mismatch: file has version %d, expected %d (delete the index DB and restart)", ver, SchemaVersion)
	}

	// Sentinel SELECT to detect corruption beyond mere version mismatch.
	if _, err := db.Exec("SELECT count(*) FROM docs"); err != nil {
		_ = db.Close()
		return nil, fmt.Errorf("sentinel SELECT failed (corrupt DB?): %w", err)
	}

	ctx, cancel := context.WithCancel(context.Background())
	idx := &Index{
		db:     db,
		in:     make(chan mutation, 256),
		cancel: cancel,
	}
	idx.wg.Add(1)
	go idx.runFunnel(ctx)
	return idx, nil
}

// Close stops the funnel goroutine and releases the DB connection.
func (i *Index) Close() error {
	if i.cancel != nil {
		i.cancel()
		i.wg.Wait()
	}
	return i.db.Close()
}

// SchemaVersion reports the user_version pragma.
func (i *Index) SchemaVersion() int {
	var v int
	_ = i.db.QueryRow("PRAGMA user_version").Scan(&v)
	return v
}

// SetSchemaVersionForTest is exposed only for tests that need to simulate a
// downgraded DB. Don't call from production code.
func (i *Index) SetSchemaVersionForTest(v int) error {
	_, err := i.db.Exec(fmt.Sprintf("PRAGMA user_version = %d", v))
	return err
}
go test ./internal/index/... -v

Expected: 2 tests PASS.

git add internal/index
git commit -m "wiki-browser: index — SQLite FTS5 schema and Open"

Task 12: Index — Reindex / Remove via funnel goroutine

All mutations go through one goroutine, ordered by arrival. The funnel is started by Open (Task 11) and stopped by Close so callers can never forget to start it. The reindex worker re-stats just before insert; missing-on-disk demotes to Remove. A race test interleaves Reindex and Remove on the same path through the funnel — required by the spec's testing section.

Files:

// internal/index/mutator_test.go
package index_test

import (
	"os"
	"path/filepath"
	"sync"
	"testing"

	"github.com/getorcha/wiki-browser/internal/index"
	"github.com/getorcha/wiki-browser/internal/render"
)

func openTestIndex(t *testing.T) (*index.Index, string) {
	t.Helper()
	dir := t.TempDir()
	idx, err := index.Open(filepath.Join(dir, "i.db"))
	if err != nil {
		t.Fatal(err)
	}
	idx.SetRoot(dir)
	idx.SetCache(render.NewCache(4 << 20)) // Reindex now renders through the cache
	t.Cleanup(func() { idx.Close() })
	return idx, dir
}

func writeMD(t *testing.T, dir, name, body string) string {
	t.Helper()
	p := filepath.Join(dir, name)
	if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
		t.Fatal(err)
	}
	return p
}

func TestMutator_indexAndRemove(t *testing.T) {
	idx, dir := openTestIndex(t)
	p := writeMD(t, dir, "a.md", "# A\n\nhello")
	if err := idx.Reindex(p); err != nil {
		t.Fatal(err)
	}
	hits, err := idx.Search("hello", 10)
	if err != nil {
		t.Fatal(err)
	}
	if len(hits) == 0 {
		t.Errorf("expected a hit after Reindex")
	}
	if err := idx.Remove(p); err != nil {
		t.Fatal(err)
	}
	hits2, _ := idx.Search("hello", 10)
	if len(hits2) != 0 {
		t.Errorf("expected zero hits after Remove, got %d", len(hits2))
	}
}

func TestMutator_demotesMissingFileToRemove(t *testing.T) {
	idx, dir := openTestIndex(t)
	p := writeMD(t, dir, "b.md", "# B")
	if err := idx.Reindex(p); err != nil {
		t.Fatal(err)
	}
	// Delete the file then Reindex — the worker should re-stat and demote.
	if err := os.Remove(p); err != nil {
		t.Fatal(err)
	}
	if err := idx.Reindex(p); err != nil {
		t.Fatal(err)
	}
	// We cannot tell from outside; assert via search.
	hits, _ := idx.Search("B", 10)
	if len(hits) != 0 {
		t.Errorf("expected zero hits after Reindex on missing file, got %d", len(hits))
	}
}

// Spec testing section: "interleave Reindex and Remove for the same path
// through the funnel goroutine; assert no zombie rows."
func TestMutator_raceReindexRemoveSamePath(t *testing.T) {
	idx, dir := openTestIndex(t)
	p := writeMD(t, dir, "race.md", "# Race\n\nzombie tests")

	const N = 200
	var wg sync.WaitGroup
	wg.Add(2)
	go func() {
		defer wg.Done()
		for i := 0; i < N; i++ {
			_ = idx.Reindex(p)
		}
	}()
	go func() {
		defer wg.Done()
		for i := 0; i < N; i++ {
			_ = idx.Remove(p)
		}
	}()
	wg.Wait()
	// Final state: do one deterministic Remove and assert zero rows.
	if err := idx.Remove(p); err != nil {
		t.Fatal(err)
	}
	hits, _ := idx.Search("zombie", 10)
	if len(hits) != 0 {
		t.Errorf("expected zero hits after final Remove, got %d (zombie rows in FTS index)", len(hits))
	}
}

// Search must keep working under concurrent index churn (B2 regression test).
func TestSearch_concurrentWithReindex(t *testing.T) {
	idx, dir := openTestIndex(t)
	for i := 0; i < 20; i++ {
		writeMD(t, dir, "f"+string(rune('a'+i))+".md", "# Doc\n\nbody alpha bravo")
	}
	for i := 0; i < 20; i++ {
		p := filepath.Join(dir, "f"+string(rune('a'+i))+".md")
		if err := idx.Reindex(p); err != nil {
			t.Fatal(err)
		}
	}
	var wg sync.WaitGroup
	stop := make(chan struct{})
	wg.Add(1)
	go func() {
		defer wg.Done()
		for {
			select {
			case <-stop:
				return
			default:
				p := filepath.Join(dir, "fa.md")
				_ = idx.Reindex(p)
				_ = idx.Remove(p)
			}
		}
	}()
	wg.Add(4)
	for i := 0; i < 4; i++ {
		go func() {
			defer wg.Done()
			for j := 0; j < 200; j++ {
				if _, err := idx.Search("alpha", 10); err != nil {
					t.Errorf("Search failed under contention: %v", err)
					return
				}
			}
		}()
	}
	close(stop)
	wg.Wait()
}
go test ./internal/index/... -v -run TestMutator

Expected: undefined Reindex, Remove, RunMutator, Search.

// internal/index/mutator.go
package index

import (
	"context"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"strings"

	"github.com/getorcha/wiki-browser/internal/render"
)

type mutKind int

const (
	mutReindex mutKind = iota + 1
	mutRemove
)

type mutation struct {
	kind mutKind
	path string // absolute
	resp chan error
}

// SetRoot fixes the absolute root used to compute repo-relative paths in
// mutations. Call once at startup, before enqueuing mutations.
func (i *Index) SetRoot(root string) { i.root = root }

// SetCache wires the shared render cache. Reindex calls go through the
// cache so the funnel goroutine and the /content handler share rendered
// output (and the cache's singleflight deduplicates concurrent misses).
// Call once at startup, before the first Reindex. A nil cache is rejected
// by panic — production code always wires one and tests should too.
func (i *Index) SetCache(c *render.Cache) {
	if c == nil {
		panic("index: SetCache(nil)")
	}
	i.cache = c
}

// runFunnel is the single goroutine that drains i.in. Started by Open.
func (i *Index) runFunnel(ctx context.Context) {
	defer i.wg.Done()
	for {
		select {
		case m := <-i.in:
			i.applyMutation(m)
		case <-ctx.Done():
			// Drain pending mutations so callers blocked on resp don't leak.
			for {
				select {
				case m := <-i.in:
					m.resp <- context.Canceled
				default:
					return
				}
			}
		}
	}
}

// Reindex enqueues a reindex of absPath. Blocks until the funnel processes it.
func (i *Index) Reindex(absPath string) error {
	resp := make(chan error, 1)
	i.in <- mutation{kind: mutReindex, path: absPath, resp: resp}
	return <-resp
}

// Remove enqueues removal of absPath from the index.
func (i *Index) Remove(absPath string) error {
	resp := make(chan error, 1)
	i.in <- mutation{kind: mutRemove, path: absPath, resp: resp}
	return <-resp
}

func (i *Index) applyMutation(m mutation) {
	rel := i.relPath(m.path)
	switch m.kind {
	case mutReindex:
		info, err := os.Stat(m.path)
		if err != nil {
			if errors.Is(err, os.ErrNotExist) {
				m.resp <- i.deleteRow(rel)
				return
			}
			m.resp <- fmt.Errorf("stat %s: %w", m.path, err)
			return
		}
		// Render through the shared cache so subsequent /content reads of
		// the same (path, mtime, size) hit the cache, and concurrent misses
		// from the funnel + a request are coalesced by singleflight.
		doc, err := i.cache.Get(m.path)
		if err != nil {
			m.resp <- fmt.Errorf("render %s: %w", m.path, err)
			return
		}
		m.resp <- i.upsertRow(rel, doc.Title, doc.PlainText, info.ModTime().Unix(), info.Size())
	case mutRemove:
		m.resp <- i.deleteRow(rel)
	}
}

func (i *Index) relPath(absPath string) string {
	if i.root == "" {
		return absPath
	}
	r, err := filepath.Rel(i.root, absPath)
	if err != nil {
		return absPath
	}
	return strings.ReplaceAll(r, string(filepath.Separator), "/")
}

func (i *Index) upsertRow(path, title, body string, mtime, size int64) error {
	tx, err := i.db.Begin()
	if err != nil {
		return err
	}
	if _, err := tx.Exec(`DELETE FROM docs WHERE path = ?`, path); err != nil {
		_ = tx.Rollback()
		return err
	}
	if _, err := tx.Exec(
		`INSERT INTO docs (path, title, body, mtime, size) VALUES (?, ?, ?, ?, ?)`,
		path, title, body, mtime, size,
	); err != nil {
		_ = tx.Rollback()
		return err
	}
	return tx.Commit()
}

func (i *Index) deleteRow(path string) error {
	_, err := i.db.Exec(`DELETE FROM docs WHERE path = ?`, path)
	return err
}

The Index struct fields and the funnel-goroutine launch are already in index.go from Task 11. Nothing to modify here — the funnel is started by Open and stopped by Close. There is no RunMutator method by design; tests and main.go simply call Open/SetRoot/Reindex/Remove/Close.

go test ./internal/index/... -v

Expected: 4 index tests PASS.

git add internal/index
git commit -m "wiki-browser: index — funnel goroutine for Reindex/Remove"

Task 13: Index — Search with column-weighted bm25 + snippet

Two flavors of search: filename matches against path and title; content matches against body with a snippet helper.

Files:

// internal/index/search_test.go
package index_test

import (
	"path/filepath"
	"strings"
	"testing"

	"github.com/getorcha/wiki-browser/internal/index"
)

func TestSearch_filenameAndContent(t *testing.T) {
	idx, dir := openTestIndex(t)
	for _, f := range []struct{ name, body string }{
		{"docs/payroll.md", "# Payroll\n\nThis covers payroll automation."},
		{"docs/orders.md", "# Orders\n\nProcess sales orders end to end."},
		{"docs/payroll-history.md", "# History\n\nRevisions of the payroll module."},
	} {
		p := writeMD(t, dir, f.name, f.body)
		if err := idx.Reindex(p); err != nil {
			t.Fatal(err)
		}
	}

	hits, err := idx.Search("payroll", 10)
	if err != nil {
		t.Fatal(err)
	}
	if len(hits) == 0 {
		t.Fatal("expected hits for 'payroll'")
	}
	// Filename matches outrank content-only matches.
	if hits[0].Kind != index.HitFilename {
		t.Errorf("first hit Kind = %v, want HitFilename", hits[0].Kind)
	}
	// Content match returns a snippet.
	var contentHit *index.Hit
	for i := range hits {
		if hits[i].Kind == index.HitContent {
			contentHit = &hits[i]
			break
		}
	}
	if contentHit == nil {
		t.Fatal("expected at least one content hit")
	}
	if !strings.Contains(strings.ToLower(contentHit.Snippet), "payroll") {
		t.Errorf("snippet missing query term: %q", contentHit.Snippet)
	}
}

// FTS5 has special operators (-, ", AND, OR, NOT, *) and stop-words. A user
// typing a hyphenated filename or an apostrophe must NOT produce a SQL error.
func TestSearch_handlesFTSSpecialChars(t *testing.T) {
	idx, dir := openTestIndex(t)
	files := map[string]string{
		"payroll-history.md": "# History\n\nyear-end revisions",
		"users-guide.md":     "# Guide\n\nuser's manual",
		"and.md":             "# And\n\nlogical operators are tricky",
	}
	for name, body := range files {
		p := writeMD(t, dir, name, body)
		if err := idx.Reindex(p); err != nil {
			t.Fatal(err)
		}
	}
	for _, q := range []string{
		"payroll-history",  // hyphen → would otherwise parse as NOT
		"user's",           // apostrophe → would otherwise terminate string
		"year-end",         // hyphen in body
		"and",              // FTS5 keyword
		`"quoted phrase"`,  // user's literal quote chars
	} {
		t.Run(q, func(t *testing.T) {
			if _, err := idx.Search(q, 10); err != nil {
				t.Errorf("Search(%q) errored: %v — query construction must escape FTS5 specials", q, err)
			}
		})
	}
}

// Confirm hyphenated filename queries actually return the matching file.
func TestSearch_hyphenatedFilenameMatches(t *testing.T) {
	idx, dir := openTestIndex(t)
	p := writeMD(t, dir, "payroll-history.md", "# History\n\nbody")
	if err := idx.Reindex(p); err != nil {
		t.Fatal(err)
	}
	hits, err := idx.Search("payroll-history", 10)
	if err != nil {
		t.Fatal(err)
	}
	found := false
	for _, h := range hits {
		if h.Path == filepath.ToSlash("payroll-history.md") {
			found = true
		}
	}
	if !found {
		t.Errorf("expected payroll-history.md in hits; got %+v", hits)
	}
}
go test ./internal/index/... -v -run TestSearch

Expected: undefined Search, Hit, HitFilename, HitContent.

// internal/index/search.go
package index

import (
	"fmt"
	"strings"
)

// HitKind discriminates filename vs content matches.
type HitKind int

const (
	HitFilename HitKind = iota + 1
	HitContent
)

// Hit is one search result.
type Hit struct {
	Kind    HitKind
	Path    string
	Title   string
	Snippet string  // empty for filename hits
	Score   float64 // bm25; lower is better in SQLite
}

// quoteFTS wraps a user term as an FTS5 phrase so SQLite treats hyphens,
// apostrophes, "AND"/"OR" stop-words, and stray quotes as literal tokens
// rather than query operators. Without this, common queries like
// "payroll-history" or "user's" produce SQL errors.
//
// FTS5 phrase syntax: "..."; literal " inside is escaped as "".
func quoteFTS(s string) string {
	s = strings.TrimSpace(s)
	if s == "" {
		return `""`
	}
	return `"` + strings.ReplaceAll(s, `"`, `""`) + `"`
}

// Search returns up to limit hits. Filename matches are returned first,
// followed by content matches. The 8/4/1 weights on (path, title, body) are
// a starting point — tune during the Pi smoke test.
func (i *Index) Search(q string, limit int) ([]Hit, error) {
	q = strings.TrimSpace(q)
	if q == "" {
		return nil, nil
	}
	if limit <= 0 {
		limit = 30
	}
	out := make([]Hit, 0, limit)
	phrase := quoteFTS(q)

	// Filename matches: query path or title.
	rows, err := i.db.Query(
		`SELECT path, title, bm25(docs, 8.0, 4.0, 1.0) AS score
		 FROM docs
		 WHERE docs MATCH ?
		 ORDER BY score
		 LIMIT ?`,
		fmt.Sprintf("path:%s OR title:%s", phrase, phrase), limit/3+1,
	)
	if err != nil {
		return nil, fmt.Errorf("search filename: %w", err)
	}
	for rows.Next() {
		var h Hit
		h.Kind = HitFilename
		if err := rows.Scan(&h.Path, &h.Title, &h.Score); err != nil {
			rows.Close()
			return nil, err
		}
		out = append(out, h)
	}
	rows.Close()

	seen := make(map[string]struct{}, len(out))
	for _, h := range out {
		seen[h.Path] = struct{}{}
	}

	// Content matches.
	rows, err = i.db.Query(
		`SELECT path, title, snippet(docs, 2, '<mark>', '</mark>', '…', 12) AS sn,
		        bm25(docs, 8.0, 4.0, 1.0) AS score
		 FROM docs
		 WHERE docs MATCH ?
		 ORDER BY score
		 LIMIT ?`,
		fmt.Sprintf("body:%s", phrase), limit,
	)
	if err != nil {
		return nil, fmt.Errorf("search content: %w", err)
	}
	defer rows.Close()
	for rows.Next() {
		var h Hit
		h.Kind = HitContent
		if err := rows.Scan(&h.Path, &h.Title, &h.Snippet, &h.Score); err != nil {
			return nil, err
		}
		if _, dup := seen[h.Path]; dup {
			continue
		}
		out = append(out, h)
	}
	if len(out) > limit {
		out = out[:limit]
	}
	return out, nil
}
go test ./internal/index/... -v

Expected: all index tests PASS.

git add internal/index
git commit -m "wiki-browser: index — column-weighted bm25 search with snippets"

Task 14: Nav — sidebar tree builder

Group repo-relative paths by their top-level directory, returning structured data the chrome shell template can iterate.

Files:

// internal/nav/nav_test.go
package nav_test

import (
	"testing"

	"github.com/getorcha/wiki-browser/internal/nav"
	"github.com/google/go-cmp/cmp"
)

func TestBuild_groupsByTopLevel(t *testing.T) {
	files := []string{
		"PRODUCT_ROADMAP.md",
		"README.md",
		"docs/orcha-controlling.html",
		"docs/proposals/sap.html",
		"feature-specs/Approval.md",
	}
	got := nav.Build(files)
	want := []nav.Group{
		{Label: "(root)", Children: []nav.Item{
			{Path: "PRODUCT_ROADMAP.md", Title: "PRODUCT_ROADMAP"},
			{Path: "README.md", Title: "README"},
		}},
		{Label: "docs", Children: []nav.Item{
			{Path: "docs/orcha-controlling.html", Title: "orcha-controlling"},
			{Path: "docs/proposals/sap.html", Title: "proposals/sap"},
		}},
		{Label: "feature-specs", Children: []nav.Item{
			{Path: "feature-specs/Approval.md", Title: "Approval"},
		}},
	}
	if diff := cmp.Diff(want, got); diff != "" {
		t.Errorf("Build() mismatch (-want +got):\n%s", diff)
	}
}
go test ./internal/nav/... -v

Expected: undefined Build, Group, Item.

// internal/nav/nav.go
package nav

import (
	"path"
	"sort"
	"strings"
)

// Group is a top-level directory in the sidebar.
type Group struct {
	Label    string
	Children []Item
}

// Item is one document link.
type Item struct {
	Path  string // repo-relative slash path; URL becomes /doc/<Path>
	Title string // display label
}

// Build groups files by top-level directory. Files at the repo root land in
// a "(root)" group. Children within a group are sorted by Path.
func Build(files []string) []Group {
	byGroup := make(map[string][]Item)
	for _, p := range files {
		p = path.Clean(p)
		idx := strings.IndexByte(p, '/')
		var label, sub string
		if idx == -1 {
			label = "(root)"
			sub = p
		} else {
			label = p[:idx]
			sub = p[idx+1:]
		}
		byGroup[label] = append(byGroup[label], Item{
			Path:  p,
			Title: titleFromSubPath(sub),
		})
	}
	labels := make([]string, 0, len(byGroup))
	for l := range byGroup {
		labels = append(labels, l)
	}
	sort.Slice(labels, func(i, j int) bool {
		// "(root)" first; everything else alphabetical.
		if labels[i] == "(root)" {
			return true
		}
		if labels[j] == "(root)" {
			return false
		}
		return labels[i] < labels[j]
	})
	out := make([]Group, 0, len(labels))
	for _, l := range labels {
		items := byGroup[l]
		sort.Slice(items, func(i, j int) bool { return items[i].Path < items[j].Path })
		out = append(out, Group{Label: l, Children: items})
	}
	return out
}

func titleFromSubPath(sub string) string {
	dot := strings.LastIndexByte(sub, '.')
	if dot > 0 {
		sub = sub[:dot]
	}
	return sub
}
go test ./internal/nav/... -v

Expected: PASS.

git add internal/nav
git commit -m "wiki-browser: nav — sidebar tree grouped by top-level dir"

Phase 2 — Server

Task 15: Server — embed.FS root and templates

Set up the embed.FS, parse templates, expose typed helpers.

Files:

// internal/server/templates_test.go
package server

import (
	"strings"
	"testing"

	"github.com/getorcha/wiki-browser/internal/nav"
)

func TestRenderShell_emitsIframe(t *testing.T) {
	tpl := mustTemplates()
	var b strings.Builder
	err := tpl.ExecuteTemplate(&b, "shell.html", ShellData{
		Title:       "Test",
		Groups:      []nav.Group{{Label: "docs", Children: []nav.Item{{Path: "docs/a.md", Title: "a"}}}},
		ContentPath: "/content/_welcome",
	})
	if err != nil {
		t.Fatal(err)
	}
	out := b.String()
	for _, want := range []string{
		`<iframe`,
		`sandbox="allow-same-origin allow-scripts allow-popups"`,
		`/content/_welcome`,
		`docs/a.md`,
	} {
		if !strings.Contains(out, want) {
			t.Errorf("shell missing %q", want)
		}
	}
}

func TestRenderContentMD_emitsProseAndContent(t *testing.T) {
	tpl := mustTemplates()
	var b strings.Builder
	err := tpl.ExecuteTemplate(&b, "content_md.html", ContentMDData{
		Title:      "X",
		BodyHTML:   "<h1>X</h1><p>body</p>",
		HasMermaid: false,
	})
	if err != nil {
		t.Fatal(err)
	}
	out := b.String()
	if !strings.Contains(out, "/static/prose.css") {
		t.Errorf("missing prose stylesheet")
	}
	if strings.Contains(out, "mermaid.esm") {
		t.Errorf("HasMermaid=false but mermaid script was injected")
	}
}

func TestRenderContentMD_injectsMermaidWhenFlagSet(t *testing.T) {
	tpl := mustTemplates()
	var b strings.Builder
	err := tpl.ExecuteTemplate(&b, "content_md.html", ContentMDData{
		Title:      "X",
		BodyHTML:   "<pre class=\"mermaid\">graph TD; A-->B</pre>",
		HasMermaid: true,
	})
	if err != nil {
		t.Fatal(err)
	}
	if !strings.Contains(b.String(), "mermaid.esm.min.mjs") {
		t.Errorf("HasMermaid=true but mermaid script was NOT injected")
	}
}
go test ./internal/server/... -v -run TestRender

Expected: undefined mustTemplates, ShellData, ContentMDData.

// internal/server/embed.go
package server

import (
	"embed"
	"html/template"
	"io/fs"
	"sync"
)

//go:embed templates static content
var assets embed.FS

// Static returns the embedded /static subtree (CSS, JS, vendored libs).
func Static() fs.FS {
	sub, err := fs.Sub(assets, "static")
	if err != nil {
		panic(err) // structural: the directory must exist
	}
	return sub
}

// Content returns the embedded /content subtree (baked-in pages).
func Content() fs.FS {
	sub, err := fs.Sub(assets, "content")
	if err != nil {
		panic(err)
	}
	return sub
}

var (
	tplOnce sync.Once
	tpl     *template.Template
	tplErr  error
)

// mustTemplates parses every embedded template; cached.
//
// IMPORTANT: template.ParseFS registers templates by their BASENAME (via
// path.Base), not the full path. So "templates/partials/nav.html" becomes
// the template named "nav.html". Every template basename in this list must
// be unique, and {{ template "..." }} includes must reference the basename.
// If two templates ever shared a basename, ParseFS would silently overwrite
// the first with the second.
func mustTemplates() *template.Template {
	tplOnce.Do(func() {
		t, err := template.ParseFS(assets,
			"templates/shell.html",
			"templates/content_md.html",
			"templates/search_results.html",
			"templates/partials/nav.html",
		)
		tpl, tplErr = t, err
	})
	if tplErr != nil {
		panic(tplErr)
	}
	return tpl
}

// ShellData drives templates/shell.html.
type ShellData struct {
	Title       string
	Groups      []navGroup
	ContentPath string
	CurrentPath string // empty if none (welcome / 404)
}

// navGroup mirrors nav.Group; aliased here so the template doesn't need to
// import an internal package.
type navGroup = struct {
	Label    string
	Children []navItem
}

type navItem = struct {
	Path  string
	Title string
}

// ContentMDData drives templates/content_md.html.
type ContentMDData struct {
	Title      string
	BodyHTML   template.HTML
	HasMermaid bool
}

// SearchResultsData drives templates/search_results.html.
// Offline=true renders the "search is offline" fallback fragment instead
// of result lists. The search handler sets it when index.Search fails.
type SearchResultsData struct {
	Query           string
	Offline         bool
	FilenameMatches []searchHit
	ContentMatches  []searchHit
}

type searchHit = struct {
	Path    string
	Title   string
	Snippet template.HTML
}

(The navGroup/navItem aliases let the templates be portable. The shell handler converts from []nav.Group to []navGroup via a small adapter — implemented in Task 17.)

<!-- internal/server/templates/shell.html -->
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>{{ .Title }}</title>
  <link rel="stylesheet" href="/static/chrome.css">
  <script src="/static/htmx.min.js" defer></script>
  <script src="/static/chrome.js" type="module" defer></script>
</head>
<body class="wb-shell">
  <header class="wb-topbar">
    <span class="wb-title">{{ .Title }}</span>
    <input
      id="wb-search"
      class="wb-search"
      type="search"
      placeholder="Search ( / )"
      hx-get="/search"
      hx-trigger="keyup changed delay:200ms, search"
      hx-target="#wb-search-results"
      name="q"
      autocomplete="off">
    <button id="wb-theme" class="wb-theme" aria-label="Toggle theme">◐</button>
  </header>

  <aside class="wb-sidebar">
    {{/* template names from ParseFS are basenames, not paths */}}
    {{ template "nav.html" . }}
  </aside>

  <main class="wb-main">
    <div id="wb-search-results" class="wb-search-results"></div>
    <iframe
      id="wb-content"
      name="content"
      title="content"
      sandbox="allow-same-origin allow-scripts allow-popups"
      src="{{ .ContentPath }}"></iframe>
  </main>
</body>
</html>
<!-- internal/server/templates/partials/nav.html -->
<nav class="wb-nav" aria-label="Documents">
  {{ range .Groups }}
    <details {{ if not (eq .Label "(root)") }}open{{ end }} class="wb-group">
      <summary>{{ .Label }}</summary>
      <ul>
        {{ range .Children }}
          <li>
            <a href="/doc/{{ .Path }}"
               data-path="{{ .Path }}"
               {{ if eq .Path $.CurrentPath }}aria-current="page"{{ end }}>
              {{ .Title }}
            </a>
          </li>
        {{ end }}
      </ul>
    </details>
  {{ end }}
</nav>
<!-- internal/server/templates/content_md.html -->
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>{{ .Title }}</title>
  <link rel="stylesheet" href="/static/prose.css">
  {{ if .HasMermaid }}
    <script type="module">
      import mermaid from '/static/mermaid.esm.min.mjs';
      mermaid.initialize({ startOnLoad: false });
      window.addEventListener('DOMContentLoaded', () => mermaid.run({ querySelector: 'pre.mermaid' }));
    </script>
  {{ end }}
</head>
<body class="wb-prose" data-title="{{ .Title }}">
{{ .BodyHTML }}
<script src="/static/content.js" defer></script>
</body>
</html>
<!-- internal/server/templates/search_results.html -->
{{ if .Offline }}
  <p class="wb-empty wb-offline">Search is offline. Navigation still works — pick a document from the sidebar.</p>
{{ else if and (not .FilenameMatches) (not .ContentMatches) }}
  <p class="wb-empty">No matches.</p>
{{ else }}
  {{ if .FilenameMatches }}
    <section>
      <h3>Filename matches</h3>
      <ul>
        {{ range .FilenameMatches }}
          <li><a href="/doc/{{ .Path }}" data-path="{{ .Path }}">{{ .Title }} <small>{{ .Path }}</small></a></li>
        {{ end }}
      </ul>
    </section>
  {{ end }}
  {{ if .ContentMatches }}
    <section>
      <h3>Content matches</h3>
      <ul>
        {{ range .ContentMatches }}
          <li>
            <a href="/doc/{{ .Path }}" data-path="{{ .Path }}">{{ .Title }} <small>{{ .Path }}</small></a>
            <p class="wb-snippet">{{ .Snippet }}</p>
          </li>
        {{ end }}
      </ul>
    </section>
  {{ end }}
{{ end }}
mkdir -p internal/server/static internal/server/content
touch internal/server/static/.gitkeep internal/server/content/.gitkeep
// internal/render/markdown.go (replace wrapMarkdownDocument)

// Removed: html-string concatenation. The server templates the iframe document
// in Task 21 using the html/template package; renderMarkdown now returns the
// raw <body> innerHTML instead of a complete document. Adjust the test
// expectations accordingly.
//
// Document.HTML now contains only the body fragment for MD; renderHTML still
// returns a complete document (pass-through). Document users (server.handler_content)
// pick the right template based on extension.

Replace the Document.HTML for MD with just the body fragment, and delete wrapMarkdownDocument and htmlEscape. The server's content_md template handles wrapping. Update markdown_test.go golden files: regenerate with -update. The new goldens contain only the body fragment.

go test ./internal/render/... -run TestRenderMarkdown_goldens -update
go test ./...

Expected: all tests PASS.

git add internal/server internal/render
git commit -m "wiki-browser: server — embed.FS, templates, sidebar/content/search markup"

Task 16: Server — safe path resolution

Resolve checks walker membership first (cheap map lookup), then filepath.Clean and an EvalSymlinks invariant as defense-in-depth.

Files:

// internal/server/safe_path_test.go
package server_test

import (
	"os"
	"path/filepath"
	"testing"

	"github.com/getorcha/wiki-browser/internal/server"
	"github.com/getorcha/wiki-browser/internal/walker"
)

func TestResolve_validPath(t *testing.T) {
	root := t.TempDir()
	if err := os.WriteFile(filepath.Join(root, "x.md"), []byte("# x"), 0o644); err != nil {
		t.Fatal(err)
	}
	w, _ := walker.New(walker.Options{Root: root, Extensions: []string{".md"}})
	abs, ok := server.Resolve(w, root, "x.md")
	if !ok {
		t.Fatal("expected resolve to succeed")
	}
	if abs != filepath.Join(root, "x.md") {
		t.Errorf("abs = %q, want %q", abs, filepath.Join(root, "x.md"))
	}
}

func TestResolve_traversalRejected(t *testing.T) {
	root := t.TempDir()
	w, _ := walker.New(walker.Options{Root: root, Extensions: []string{".md"}})
	if _, ok := server.Resolve(w, root, "../etc/passwd"); ok {
		t.Error("traversal must be rejected")
	}
	if _, ok := server.Resolve(w, root, "x.md/../../passwd"); ok {
		t.Error("embedded traversal must be rejected")
	}
}

func TestResolve_unknownPathRejected(t *testing.T) {
	root := t.TempDir()
	w, _ := walker.New(walker.Options{Root: root, Extensions: []string{".md"}})
	if _, ok := server.Resolve(w, root, "ghost.md"); ok {
		t.Error("unknown path must be rejected")
	}
}
go test ./internal/server/... -v -run TestResolve
// internal/server/safe_path.go
package server

import (
	"path/filepath"
	"strings"

	"github.com/getorcha/wiki-browser/internal/walker"
)

// Resolve maps a URL path component (e.g. "docs/a.md") to an absolute path
// under root, refusing anything that's not a known file in the walker.
func Resolve(w *walker.Walker, root, urlPath string) (string, bool) {
	clean := filepath.ToSlash(filepath.Clean(urlPath))
	if clean == "." || strings.HasPrefix(clean, "../") || strings.Contains(clean, "/../") {
		return "", false
	}
	if !w.Has(clean) {
		return "", false
	}
	abs := filepath.Join(root, filepath.FromSlash(clean))
	// Defense in depth: ensure the absolute path is still inside root.
	rel, err := filepath.Rel(root, abs)
	if err != nil || strings.HasPrefix(rel, "..") {
		return "", false
	}
	return abs, true
}
go test ./internal/server/... -v -run TestResolve
git add internal/server/safe_path.go internal/server/safe_path_test.go
git commit -m "wiki-browser: server — safe path resolution (walker membership + traversal)"

Task 17: Server — / and /doc/{path...} (chrome shell handler)

Files:

// internal/server/handler_doc_test.go
package server_test

import (
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"strings"
	"testing"

	"github.com/getorcha/wiki-browser/internal/index"
	"github.com/getorcha/wiki-browser/internal/render"
	"github.com/getorcha/wiki-browser/internal/server"
	"github.com/getorcha/wiki-browser/internal/walker"
)

func newTestServer(t *testing.T) (*httptest.Server, string) {
	t.Helper()
	root := t.TempDir()
	if err := os.WriteFile(filepath.Join(root, "a.md"), []byte("# A"), 0o644); err != nil {
		t.Fatal(err)
	}
	w, err := walker.New(walker.Options{Root: root, Extensions: []string{".md", ".html"}})
	if err != nil {
		t.Fatal(err)
	}
	idx, err := index.Open(filepath.Join(t.TempDir(), "i.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { idx.Close() })
	cache := render.NewCache(4 << 20) // 4 MB is plenty for the test fixtures
	idx.SetCache(cache)
	mux := server.Mux(server.Deps{
		Title:   "Test",
		Root:    root,
		Walker:  w,
		Index:   idx,
		Cache:   cache,
	})
	ts := httptest.NewServer(mux)
	t.Cleanup(ts.Close)
	return ts, root
}

func TestRoot_servesShellPointingAtWelcome(t *testing.T) {
	ts, _ := newTestServer(t)
	resp, err := http.Get(ts.URL + "/")
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != 200 {
		t.Errorf("status = %d, want 200", resp.StatusCode)
	}
	body := readAll(t, resp)
	if !strings.Contains(body, "/content/_welcome") {
		t.Errorf("shell did not point iframe at welcome content; body=%s", body)
	}
}

func TestDoc_servesShellPointingAtContent(t *testing.T) {
	ts, _ := newTestServer(t)
	resp, err := http.Get(ts.URL + "/doc/a.md")
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != 200 {
		t.Errorf("status = %d, want 200", resp.StatusCode)
	}
	body := readAll(t, resp)
	if !strings.Contains(body, `src="/content/a.md"`) {
		t.Errorf("iframe src not set to /content/a.md; body=%s", body[:min(500, len(body))])
	}
}

func TestDoc_unknownReturns404Shell(t *testing.T) {
	ts, _ := newTestServer(t)
	resp, err := http.Get(ts.URL + "/doc/missing.md")
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != 404 {
		t.Errorf("status = %d, want 404", resp.StatusCode)
	}
	body := readAll(t, resp)
	if !strings.Contains(body, "/content/_404") {
		t.Errorf("404 shell did not reference /content/_404; body=%s", body)
	}
}

(Add a small readAll test helper somewhere shared — a helpers_test.go:)

// internal/server/helpers_test.go
package server_test

import (
	"io"
	"net/http"
	"testing"
)

func readAll(t *testing.T, resp *http.Response) string {
	t.Helper()
	b, err := io.ReadAll(resp.Body)
	if err != nil {
		t.Fatal(err)
	}
	return string(b)
}

func min(a, b int) int { if a < b { return a }; return b }
go test ./internal/server/... -v -run TestRoot

Expected: undefined Mux, Deps.

// internal/server/server.go
package server

import (
	"net/http"

	"github.com/getorcha/wiki-browser/internal/index"
	"github.com/getorcha/wiki-browser/internal/render"
	"github.com/getorcha/wiki-browser/internal/walker"
)

// Deps is the dependency injection bundle for the server.
//
// Cache is required (handlers panic if it's nil). The same *render.Cache is
// shared with the index funnel goroutine via Index.SetCache so a reindex
// warms the cache for subsequent /content reads (and vice versa) — the
// singleflight inside the cache deduplicates either source.
type Deps struct {
	Title  string
	Root   string
	Walker *walker.Walker
	Index  *index.Index
	Cache  *render.Cache
}

// Mux returns the fully wired router.
func Mux(d Deps) *http.ServeMux {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "text/plain; charset=utf-8")
		w.Write([]byte("ok"))
	})
	mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(Static())))
	mux.HandleFunc("GET /partials/nav", d.handleNavPartial)
	mux.HandleFunc("GET /search", d.handleSearch)

	// Reserved content paths take precedence over generic content paths.
	mux.HandleFunc("GET /content/_welcome", d.handleBakedContent("_welcome"))
	mux.HandleFunc("GET /content/_404", d.handleBakedContent("_404"))
	mux.HandleFunc("GET /content/_search-offline", d.handleBakedContent("_search-offline"))
	mux.HandleFunc("GET /content/", d.handleContent)

	mux.HandleFunc("GET /doc/", d.handleDoc)
	mux.HandleFunc("GET /{$}", d.handleRoot)
	return mux
}
// internal/server/handler_doc.go
package server

import (
	"net/http"
	"strings"

	"github.com/getorcha/wiki-browser/internal/nav"
)

func (d Deps) handleRoot(w http.ResponseWriter, r *http.Request) {
	d.writeShell(w, http.StatusOK, "", "/content/_welcome")
}

func (d Deps) handleDoc(w http.ResponseWriter, r *http.Request) {
	urlPath := strings.TrimPrefix(r.URL.Path, "/doc/")
	if urlPath == "" {
		d.writeShell(w, http.StatusNotFound, "", "/content/_404")
		return
	}
	if _, ok := Resolve(d.Walker, d.Root, urlPath); !ok {
		d.writeShell(w, http.StatusNotFound, "", "/content/_404")
		return
	}
	d.writeShell(w, http.StatusOK, urlPath, "/content/"+urlPath)
}

func (d Deps) writeShell(w http.ResponseWriter, status int, currentPath, contentPath string) {
	groups := toNavGroups(nav.Build(d.Walker.Files()))
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	w.WriteHeader(status)
	tpl := mustTemplates()
	_ = tpl.ExecuteTemplate(w, "shell.html", ShellData{
		Title:       d.Title,
		Groups:      groups,
		ContentPath: contentPath,
		CurrentPath: currentPath,
	})
}

func toNavGroups(in []nav.Group) []navGroup {
	out := make([]navGroup, 0, len(in))
	for _, g := range in {
		children := make([]navItem, 0, len(g.Children))
		for _, it := range g.Children {
			children = append(children, navItem{Path: it.Path, Title: it.Title})
		}
		out = append(out, navGroup{Label: g.Label, Children: children})
	}
	return out
}
// internal/server/handlers_stubs.go
package server

import "net/http"

func (d Deps) handleNavPartial(w http.ResponseWriter, r *http.Request) {
	http.Error(w, "not implemented", http.StatusNotImplemented)
}
func (d Deps) handleContent(w http.ResponseWriter, r *http.Request) {
	http.Error(w, "not implemented", http.StatusNotImplemented)
}
func (d Deps) handleBakedContent(name string) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		http.Error(w, "not implemented", http.StatusNotImplemented)
	}
}
func (d Deps) handleSearch(w http.ResponseWriter, r *http.Request) {
	http.Error(w, "not implemented", http.StatusNotImplemented)
}
go test ./internal/server/... -v -run TestRoot
go test ./internal/server/... -v -run TestDoc

Expected: 3 PASS.

git add internal/server
git commit -m "wiki-browser: server — / and /doc/{path...} chrome-shell handler"

Task 18: Server — /content/{path...} (iframe content document)

Renders MD via the content_md template; returns authored HTML byte-identical; supports ?raw=1 to return source bytes as text/plain.

Files:

// internal/server/handler_content_test.go
package server_test

import (
	"net/http"
	"os"
	"path/filepath"
	"strings"
	"testing"
)

func TestContentMD_servesProseDocument(t *testing.T) {
	ts, root := newTestServer(t)
	if err := os.WriteFile(filepath.Join(root, "m.md"), []byte("# M\n\nhello"), 0o644); err != nil {
		t.Fatal(err)
	}
	// Force walker to see the new file.
	resp, err := http.Get(ts.URL + "/content/a.md") // existing fixture
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != 200 {
		t.Errorf("status = %d", resp.StatusCode)
	}
	if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
		t.Errorf("Content-Type = %q", ct)
	}
	body := readAll(t, resp)
	if !strings.Contains(body, "/static/prose.css") {
		t.Errorf("no prose stylesheet; body=%s", body)
	}
	if !strings.Contains(body, "<h1") {
		t.Errorf("rendered MD body missing; body=%s", body)
	}
}

func TestContentHTML_servesByteIdentical(t *testing.T) {
	ts, root := newTestServer(t)
	authored := "<!doctype html><html><body><h1>hi</h1></body></html>"
	p := filepath.Join(root, "raw.html")
	if err := os.WriteFile(p, []byte(authored), 0o644); err != nil {
		t.Fatal(err)
	}
	// Re-create test server so the walker sees the new file.
	resp, err := http.Get(ts.URL + "/content/raw.html")
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	body := readAll(t, resp)
	if body != authored {
		t.Errorf("authored HTML must round-trip byte-identical")
	}
}

func TestContent_rawQueryReturnsPlainText(t *testing.T) {
	ts, _ := newTestServer(t)
	resp, err := http.Get(ts.URL + "/content/a.md?raw=1")
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") {
		t.Errorf("Content-Type = %q, want text/plain", ct)
	}
}

func TestContent_unknownReturns404(t *testing.T) {
	ts, _ := newTestServer(t)
	resp, err := http.Get(ts.URL + "/content/missing.md")
	if err != nil {
		t.Fatal(err)
	}
	if resp.StatusCode != 404 {
		t.Errorf("status = %d, want 404", resp.StatusCode)
	}
}

(Note: the byte-identical and "force walker re-scan" tests need the walker to be rebuildable in-test. Refactor newTestServer to optionally accept a list of files or use walker.New after writing files. Adjust the helper to write all desired files before constructing the server.)

Updated newTestServer:

func newTestServer(t *testing.T) (*httptest.Server, string) {
	t.Helper()
	root := t.TempDir()
	mustWrite := func(name, body string) {
		if err := os.WriteFile(filepath.Join(root, name), []byte(body), 0o644); err != nil {
			t.Fatal(err)
		}
	}
	mustWrite("a.md", "# A")
	mustWrite("raw.html", "<!doctype html><html><body><h1>hi</h1></body></html>")
	w, err := walker.New(walker.Options{Root: root, Extensions: []string{".md", ".html"}})
	if err != nil {
		t.Fatal(err)
	}
	idx, err := index.Open(filepath.Join(t.TempDir(), "i.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { idx.Close() })
	cache := render.NewCache(4 << 20)
	idx.SetCache(cache)
	mux := server.Mux(server.Deps{Title: "Test", Root: root, Walker: w, Index: idx, Cache: cache})
	ts := httptest.NewServer(mux)
	t.Cleanup(ts.Close)
	return ts, root
}

(Remove the per-test file writes from earlier tests.)

go test ./internal/server/... -v -run TestContent
// internal/server/handler_content.go
package server

import (
	"html/template"
	"net/http"
	"os"
	"path/filepath"
	"strings"
)

func (d Deps) handleContent(w http.ResponseWriter, r *http.Request) {
	urlPath := strings.TrimPrefix(r.URL.Path, "/content/")
	abs, ok := Resolve(d.Walker, d.Root, urlPath)
	if !ok {
		http.NotFound(w, r)
		return
	}

	if r.URL.Query().Get("raw") == "1" {
		raw, err := os.ReadFile(abs)
		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
		w.Header().Set("Content-Type", "text/plain; charset=utf-8")
		w.Write(raw)
		return
	}

	switch strings.ToLower(filepath.Ext(abs)) {
	case ".md":
		// Cache.Get coalesces concurrent renders of the same path via
		// singleflight and reuses cached output when (mtime, size) match.
		doc, err := d.Cache.Get(abs)
		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
		w.Header().Set("Content-Type", "text/html; charset=utf-8")
		_ = mustTemplates().ExecuteTemplate(w, "content_md.html", ContentMDData{
			Title:      doc.Title,
			BodyHTML:   template.HTML(doc.HTML),
			HasMermaid: doc.HasMermaid,
		})
	case ".html":
		raw, err := os.ReadFile(abs)
		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
		w.Header().Set("Content-Type", "text/html; charset=utf-8")
		w.Write(raw)
	default:
		http.NotFound(w, r)
	}
}

(Remove handleContent from handlers_stubs.go.)

go test ./internal/server/... -v -run TestContent
git add internal/server
git commit -m "wiki-browser: server — /content/{path} (md, html, raw)"

Task 19: Server — /search handler

Files:

// internal/server/handler_search_test.go
package server_test

import (
	"net/http"
	"net/url"
	"strings"
	"testing"
)

func TestSearch_emptyQueryEmptyFragment(t *testing.T) {
	ts, _ := newTestServer(t)
	resp, _ := http.Get(ts.URL + "/search?q=")
	defer resp.Body.Close()
	body := readAll(t, resp)
	if strings.Contains(body, "Filename matches") || strings.Contains(body, "Content matches") {
		t.Errorf("empty query should return empty fragment; body=%s", body)
	}
}

func TestSearch_returnsResults(t *testing.T) {
	ts, _ := newTestServer(t)
	// Reindex the fixture: a.md contains "A".
	_, _ = http.Get(ts.URL + "/content/a.md") // touches walker; sufficient for this test
	resp, _ := http.Get(ts.URL + "/search?q=" + url.QueryEscape("A"))
	defer resp.Body.Close()
	body := readAll(t, resp)
	if !strings.Contains(body, "/doc/a.md") {
		t.Errorf("expected a result link to /doc/a.md; body=%s", body)
	}
}

// Spec § Error handling: an index that errors mid-flight must not 500;
// /search returns the "search is offline" fragment and navigation keeps
// working. Forcing the failure: open an index, close it immediately, then
// hit /search — the underlying *sql.DB returns an error on every query.
func TestSearch_returnsOfflineFragmentOnIndexError(t *testing.T) {
	root := t.TempDir()
	w, err := walker.New(walker.Options{Root: root, Extensions: []string{".md"}})
	if err != nil {
		t.Fatal(err)
	}
	idx, err := index.Open(filepath.Join(t.TempDir(), "i.db"))
	if err != nil {
		t.Fatal(err)
	}
	cache := render.NewCache(1 << 20)
	idx.SetCache(cache)
	idx.Close() // every subsequent Search call now errors

	mux := server.Mux(server.Deps{Title: "T", Root: root, Walker: w, Index: idx, Cache: cache})
	ts := httptest.NewServer(mux)
	defer ts.Close()

	resp, err := http.Get(ts.URL + "/search?q=" + url.QueryEscape("payroll"))
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		t.Errorf("status = %d, want 200 (offline fragment, not 500)", resp.StatusCode)
	}
	body := readAll(t, resp)
	if !strings.Contains(body, "Search is offline") {
		t.Errorf("expected offline fragment; body=%s", body)
	}
}

(The offline test imports os, net/http/httptest, path/filepath, plus walker, index, render, and server. Add them to the test file's import block.)

(Note: this test requires newTestServer to seed the index before queries. Update the helper to call idx.SetRoot(root), idx.RunMutator(ctx), and idx.Reindex(...) for each fixture file after walker.New.)

Updated newTestServer (final form):

func newTestServer(t *testing.T) (*httptest.Server, string) {
	t.Helper()
	root := t.TempDir()
	files := map[string]string{
		"a.md":     "# A\n\nalpha bravo charlie",
		"raw.html": "<!doctype html><html><body><h1>hi</h1></body></html>",
	}
	for name, body := range files {
		if err := os.WriteFile(filepath.Join(root, name), []byte(body), 0o644); err != nil {
			t.Fatal(err)
		}
	}
	w, err := walker.New(walker.Options{Root: root, Extensions: []string{".md", ".html"}})
	if err != nil {
		t.Fatal(err)
	}
	idx, err := index.Open(filepath.Join(t.TempDir(), "i.db"))
	if err != nil {
		t.Fatal(err)
	}
	idx.SetRoot(root)
	cache := render.NewCache(4 << 20)
	idx.SetCache(cache)
	t.Cleanup(func() { idx.Close() })
	for name := range files {
		if err := idx.Reindex(filepath.Join(root, name)); err != nil {
			t.Fatal(err)
		}
	}
	mux := server.Mux(server.Deps{Title: "Test", Root: root, Walker: w, Index: idx, Cache: cache})
	ts := httptest.NewServer(mux)
	t.Cleanup(ts.Close)
	return ts, root
}

(Add "github.com/getorcha/wiki-browser/internal/render" to the helpers' import block — it's referenced from newTestServer but earlier task drafts didn't import it.)

The funnel goroutine starts inside index.Open and stops on Close — tests don't need to manage it explicitly.

// internal/server/handler_search.go
package server

import (
	"html/template"
	"log/slog"
	"net/http"
	"strings"

	"github.com/getorcha/wiki-browser/internal/index"
)

func (d Deps) handleSearch(w http.ResponseWriter, r *http.Request) {
	q := strings.TrimSpace(r.URL.Query().Get("q"))
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	if q == "" {
		// Empty fragment.
		return
	}
	hits, err := d.Index.Search(q, 30)
	if err != nil {
		// Spec § Error handling: index unavailable mid-flight returns the
		// "search is offline" fragment, not 500. Navigation keeps working.
		slog.Warn("search failed; rendering offline fragment", "q", q, "err", err)
		_ = mustTemplates().ExecuteTemplate(w, "search_results.html", SearchResultsData{
			Query:   q,
			Offline: true,
		})
		return
	}
	data := SearchResultsData{Query: q}
	for _, h := range hits {
		row := searchHit{Path: h.Path, Title: h.Title, Snippet: template.HTML(h.Snippet)}
		switch h.Kind {
		case index.HitFilename:
			data.FilenameMatches = append(data.FilenameMatches, row)
		case index.HitContent:
			data.ContentMatches = append(data.ContentMatches, row)
		}
	}
	_ = mustTemplates().ExecuteTemplate(w, "search_results.html", data)
}

(Remove handleSearch stub.)

go test ./internal/server/... -v -run TestSearch
git add internal/server
git commit -m "wiki-browser: server — /search HTMX fragment"

Task 20: Server — /partials/nav and baked-in content pages

Files:

// internal/server/handler_baked_test.go
package server_test

import (
	"net/http"
	"strings"
	"testing"
)

func TestBakedContent_welcome(t *testing.T) {
	ts, _ := newTestServer(t)
	resp, _ := http.Get(ts.URL + "/content/_welcome")
	defer resp.Body.Close()
	if resp.StatusCode != 200 {
		t.Errorf("status = %d", resp.StatusCode)
	}
	if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
		t.Errorf("Content-Type = %q", ct)
	}
}

func TestBakedContent_404(t *testing.T) {
	ts, _ := newTestServer(t)
	resp, _ := http.Get(ts.URL + "/content/_404")
	defer resp.Body.Close()
	if resp.StatusCode != 200 {
		t.Errorf("status = %d", resp.StatusCode)
	}
}

func TestBakedContent_realFileCannotShadow(t *testing.T) {
	// Even if a file named "_welcome.md" existed in root, the baked page
	// must take precedence. The test fixture doesn't write one; this is
	// guaranteed by route registration order in Mux().
	ts, _ := newTestServer(t)
	resp, _ := http.Get(ts.URL + "/content/_welcome")
	defer resp.Body.Close()
	body := readAll(t, resp)
	if !strings.Contains(body, "Welcome") {
		t.Errorf("baked welcome content missing; body=%s", body)
	}
}

func TestPartialsNav(t *testing.T) {
	ts, _ := newTestServer(t)
	resp, _ := http.Get(ts.URL + "/partials/nav")
	defer resp.Body.Close()
	body := readAll(t, resp)
	if !strings.Contains(body, "wb-nav") {
		t.Errorf("nav partial missing; body=%s", body)
	}
}
// internal/server/handler_baked.go
package server

import (
	"html/template"
	"io/fs"
	"net/http"
	"strings"

	"github.com/yuin/goldmark"
	"github.com/yuin/goldmark/extension"
)

// bakedRender turns one of the embedded content/_*.md files into a content
// document. We use a minimal goldmark setup here so baked pages don't need
// the full render package's caching path.
func bakedRender(name string) (string, error) {
	src, err := fs.ReadFile(Content(), name+".md")
	if err != nil {
		return "", err
	}
	md := goldmark.New(goldmark.WithExtensions(extension.GFM))
	var b strings.Builder // see imports below
	if err := md.Convert(src, &b); err != nil {
		return "", err
	}
	return b.String(), nil
}

func (d Deps) handleBakedContent(name string) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		body, err := bakedRender(name)
		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
		w.Header().Set("Content-Type", "text/html; charset=utf-8")
		_ = mustTemplates().ExecuteTemplate(w, "content_md.html", ContentMDData{
			Title:      titleForBaked(name),
			BodyHTML:   template.HTML(body),
			HasMermaid: false,
		})
	}
}

func titleForBaked(name string) string {
	switch name {
	case "_welcome":
		return "Welcome"
	case "_404":
		return "Not found"
	case "_search-offline":
		return "Search offline"
	default:
		return name
	}
}

Remove the stub from handlers_stubs.go.

// internal/server/handler_partials.go
package server

import (
	"net/http"

	"github.com/getorcha/wiki-browser/internal/nav"
)

func (d Deps) handleNavPartial(w http.ResponseWriter, r *http.Request) {
	groups := toNavGroups(nav.Build(d.Walker.Files()))
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	// template.ParseFS registers templates by basename, so "partials/nav.html"
	// is registered as "nav.html". See the comment in embed.go.
	_ = mustTemplates().ExecuteTemplate(w, "nav.html", ShellData{
		Groups: groups,
	})
}

(Remove the stub.)

<!-- internal/server/content/_welcome.md -->
# Welcome to wiki-browser

This is the landing view. The sidebar lists every `.md` and `.html` file under your configured `root`.

If the sidebar is empty, the index is empty: check the `root` and `exclude` settings in `wiki-browser.yaml`.

Press **/** to focus the search box. Press **Esc** to clear it.
<!-- internal/server/content/_404.md -->
# Not found

The document you requested isn't in the index. Either:

- the path doesn't exist under `root`,
- the file's extension isn't in the configured list, or
- the file matches an exclude pattern.

Use the sidebar to pick a document, or try the search box.
<!-- internal/server/content/_search-offline.md -->
# Search is offline

The search index is unavailable right now (file locked or corrupt). Navigation still works — pick a document from the sidebar.

If this persists, check the server logs (`journalctl -u wiki-browser` on a Pi).
go test ./internal/server/... -v
git add internal/server
git commit -m "wiki-browser: server — partials/nav + baked-in welcome / 404 / offline pages"

Phase 3 — Frontend assets

Task 21: Static — chrome.css

Files:

The chrome CSS must scope under .wb-shell/.wb-topbar/.wb-sidebar/.wb-main per the spec. Never style body, html, *, or bare element selectors.

/* internal/server/static/chrome.css */
/* Chrome-only styles. Scoped under .wb-shell so iframe content (rendered MD,
   authored HTML) is never affected. */

:root {
  --wb-bg:        #fafaf9;
  --wb-surface:   #ffffff;
  --wb-text:      #1c1917;
  --wb-muted:     #78716c;
  --wb-rule:      #e7e5e4;
  --wb-accent:    #b45309;
  --wb-accent-bg: #fef3c7;
  --wb-sans:      'Source Sans 3', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}

.wb-shell {
  margin: 0;
  background: var(--wb-bg);
  color: var(--wb-text);
  font-family: var(--wb-sans);
  font-size: 14px;
  display: grid;
  grid-template-columns: 280px 1fr;
  grid-template-rows: 48px 1fr;
  grid-template-areas:
    "topbar  topbar"
    "sidebar main";
  height: 100vh;
  overflow: hidden;
}

.wb-topbar {
  grid-area: topbar;
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 0 16px;
  background: var(--wb-surface);
  border-bottom: 1px solid var(--wb-rule);
}

.wb-title { font-weight: 600; }

.wb-search {
  flex: 1;
  max-width: 480px;
  padding: 6px 10px;
  border: 1px solid var(--wb-rule);
  border-radius: 4px;
  background: var(--wb-bg);
  font: inherit;
}

.wb-theme {
  background: transparent;
  border: 1px solid var(--wb-rule);
  border-radius: 4px;
  padding: 4px 10px;
  cursor: pointer;
}

.wb-sidebar {
  grid-area: sidebar;
  border-right: 1px solid var(--wb-rule);
  background: var(--wb-surface);
  overflow: auto;
  padding: 12px 8px;
}

.wb-nav details { margin-bottom: 6px; }
.wb-nav summary {
  cursor: pointer;
  font-weight: 600;
  font-size: 12px;
  text-transform: uppercase;
  letter-spacing: .05em;
  color: var(--wb-muted);
  padding: 4px 6px;
}
.wb-nav ul { list-style: none; margin: 0; padding: 0 0 0 8px; }
.wb-nav li a {
  display: block;
  padding: 4px 8px;
  border-radius: 3px;
  color: var(--wb-text);
  text-decoration: none;
  font-size: 13.5px;
}
.wb-nav li a:hover { background: var(--wb-accent-bg); }
.wb-nav li a[aria-current="page"] {
  background: var(--wb-accent-bg);
  color: var(--wb-accent);
  font-weight: 600;
}

.wb-main {
  grid-area: main;
  position: relative;
  overflow: hidden;
}

#wb-content {
  width: 100%;
  height: 100%;
  border: 0;
}

.wb-search-results {
  position: absolute;
  top: 4px;
  left: 8px;
  right: 8px;
  max-height: 60%;
  overflow: auto;
  background: var(--wb-surface);
  border: 1px solid var(--wb-rule);
  border-radius: 4px;
  z-index: 10;
}
.wb-search-results:empty { display: none; }
.wb-search-results h3 {
  margin: 0;
  padding: 8px 12px;
  font-size: 11px;
  text-transform: uppercase;
  letter-spacing: .06em;
  color: var(--wb-muted);
  background: var(--wb-bg);
  border-bottom: 1px solid var(--wb-rule);
}
.wb-search-results ul { list-style: none; margin: 0; padding: 4px 0; }
.wb-search-results li { padding: 6px 12px; }
.wb-search-results li small { color: var(--wb-muted); margin-left: 6px; }
.wb-search-results .wb-snippet { margin: 4px 0 0; color: var(--wb-muted); font-size: 12px; }
.wb-search-results .wb-snippet mark { background: var(--wb-accent-bg); padding: 0 2px; }
.wb-search-results .wb-empty { padding: 10px 12px; color: var(--wb-muted); font-size: 13px; margin: 0; }
.wb-search-results .wb-offline { color: var(--wb-accent); }
git add internal/server/static/chrome.css
git commit -m "wiki-browser: static — chrome.css (sidebar, topbar, search results)"

Task 22: Static — prose.css

The prose CSS styles rendered Markdown content inside the iframe. Selectors target .wb-prose and child elements only.

Files:

/* internal/server/static/prose.css
   Prose typography for rendered Markdown content documents. */

:root {
  --wb-bg:        #ffffff;
  --wb-text:      #1c1917;
  --wb-muted:     #78716c;
  --wb-rule:      #e7e5e4;
  --wb-accent:    #b45309;
  --wb-code-bg:   #292524;
  --wb-code-fg:   #e7e5e4;
  --wb-serif:     'Source Serif 4', Georgia, serif;
  --wb-sans:      'Source Sans 3', -apple-system, BlinkMacSystemFont, sans-serif;
  --wb-mono:      'JetBrains Mono', Menlo, Consolas, monospace;
}

body.wb-prose {
  background: var(--wb-bg);
  color: var(--wb-text);
  font-family: var(--wb-sans);
  font-size: 15px;
  line-height: 1.65;
  padding: 48px 32px 96px;
  max-width: 760px;
  margin: 0 auto;
}

.wb-prose h1, .wb-prose h2, .wb-prose h3, .wb-prose h4 {
  font-family: var(--wb-serif);
  letter-spacing: -.01em;
  margin: 28px 0 12px;
}
.wb-prose h1 { font-size: 30px; line-height: 1.18; margin-top: 8px; }
.wb-prose h2 { font-size: 22px; line-height: 1.3; }
.wb-prose h3 { font-size: 18px; line-height: 1.35; }

.wb-prose a { color: var(--wb-accent); text-decoration: underline; text-underline-offset: 2px; }
.wb-prose code {
  font-family: var(--wb-mono);
  font-size: .88em;
  background: var(--wb-rule);
  padding: 1px 6px;
  border-radius: 3px;
}
.wb-prose pre {
  background: var(--wb-code-bg);
  color: var(--wb-code-fg);
  padding: 14px 18px;
  border-radius: 4px;
  overflow-x: auto;
  font-family: var(--wb-mono);
  font-size: 13px;
  line-height: 1.6;
}
.wb-prose pre code { background: transparent; padding: 0; }
.wb-prose blockquote {
  border-left: 3px solid var(--wb-rule);
  padding: 0 12px;
  color: var(--wb-muted);
  margin: 12px 0;
}
.wb-prose table {
  width: 100%;
  border-collapse: collapse;
  margin: 14px 0;
  font-size: 14px;
}
.wb-prose th, .wb-prose td {
  text-align: left;
  padding: 8px 10px;
  border-bottom: 1px solid var(--wb-rule);
}
.wb-prose th {
  font-size: 11px;
  text-transform: uppercase;
  letter-spacing: .06em;
  color: var(--wb-muted);
  border-bottom: 2px solid var(--wb-text);
}
.wb-prose img { max-width: 100%; height: auto; }

/* Mermaid containers — let the library style the SVG; we just give it room. */
.wb-prose pre.mermaid {
  background: transparent;
  color: inherit;
  padding: 0;
  text-align: center;
}
git add internal/server/static/prose.css
git commit -m "wiki-browser: static — prose.css for rendered Markdown"

Task 23: Static — chrome.js (parent-frame JS)

URL sync from iframe load, popstate handler, key shortcuts, search-result click delegate.

Files:

// internal/server/static/chrome.js
// Parent-frame chrome for wiki-browser. Loaded by templates/shell.html.
// Responsibilities:
//   - sync the outer URL with iframe navigation
//   - keyboard shortcuts (/, Esc)
//   - search-result clicks → swap the iframe instead of navigating the parent
//   - postMessage handler for keys forwarded from in-iframe content.js

(function () {
  const iframe = document.getElementById('wb-content');
  const search = document.getElementById('wb-search');
  const results = document.getElementById('wb-search-results');
  const sidebar = document.querySelector('.wb-sidebar');

  // Track whether we initiated the iframe navigation so we don't double-push history.
  let suppressNextLoad = false;

  function pathFromIframeURL(url) {
    // url is like /content/<rest> ; produce /doc/<rest>.
    const u = new URL(url);
    if (u.pathname.startsWith('/content/')) {
      return '/doc/' + u.pathname.slice('/content/'.length);
    }
    return '/';
  }

  iframe.addEventListener('load', () => {
    const docPath = pathFromIframeURL(iframe.contentWindow.location.href);
    if (suppressNextLoad) {
      suppressNextLoad = false;
    } else if (location.pathname !== docPath) {
      history.pushState({}, '', docPath);
    }
    try {
      document.title = iframe.contentDocument.title || document.title;
    } catch (_) { /* cross-doc race during very fast nav */ }
    updateAriaCurrent(docPath.replace(/^\/doc\//, ''));
  });

  function updateAriaCurrent(path) {
    sidebar.querySelectorAll('a[data-path]').forEach(a => {
      if (a.getAttribute('data-path') === path) {
        a.setAttribute('aria-current', 'page');
      } else {
        a.removeAttribute('aria-current');
      }
    });
  }

  // popstate: outer back/forward → swap iframe accordingly.
  window.addEventListener('popstate', () => {
    if (!location.pathname.startsWith('/doc/') && location.pathname !== '/') return;
    const target = location.pathname === '/'
      ? '/content/_welcome'
      : '/content/' + location.pathname.slice('/doc/'.length);
    suppressNextLoad = true;
    iframe.contentWindow.location.replace(target);
  });

  // Search-result click delegate.
  results.addEventListener('click', (ev) => {
    const a = ev.target.closest('a[data-path]');
    if (!a) return;
    ev.preventDefault();
    const path = a.getAttribute('data-path');
    iframe.contentWindow.location.replace('/content/' + path);
    results.innerHTML = '';
    if (search) search.value = '';
  });

  // Sidebar click delegate (HTMX boost would also work but this keeps deps minimal).
  sidebar.addEventListener('click', (ev) => {
    const a = ev.target.closest('a[data-path]');
    if (!a) return;
    ev.preventDefault();
    const path = a.getAttribute('data-path');
    iframe.contentWindow.location.replace('/content/' + path);
  });

  // Keyboard shortcuts.
  function handleKey(e) {
    if (e.key === '/' && document.activeElement !== search) {
      e.preventDefault();
      if (search) search.focus();
    } else if (e.key === 'Escape') {
      if (results.innerHTML) {
        results.innerHTML = '';
        if (search) search.value = '';
      } else if (search && document.activeElement === search) {
        search.blur();
      }
    }
  }
  document.addEventListener('keydown', handleKey);

  // Receive forwarded keys from in-iframe content.js.
  window.addEventListener('message', (e) => {
    if (e.origin !== location.origin) return;
    const msg = e.data;
    if (!msg || typeof msg !== 'object') return;
    if (msg.kind === 'key') {
      handleKey({ key: msg.key, preventDefault: () => {} });
    }
  });

  // Theme toggle (minimal; persists to localStorage).
  const themeBtn = document.getElementById('wb-theme');
  if (themeBtn) {
    const apply = (mode) => document.documentElement.dataset.theme = mode;
    apply(localStorage.getItem('wb-theme') || 'light');
    themeBtn.addEventListener('click', () => {
      const next = (document.documentElement.dataset.theme === 'dark') ? 'light' : 'dark';
      apply(next);
      localStorage.setItem('wb-theme', next);
    });
  }
})();
git add internal/server/static/chrome.js
git commit -m "wiki-browser: static — chrome.js (URL sync, kbd, search delegate)"

Task 24: Static — content.js (in-iframe JS)

Files:

// internal/server/static/content.js
// Loaded inside every Markdown content document (NOT into authored HTML).
// Forwards keyboard shortcuts to the parent and provides a hook for the
// future v2 annotation client.

(function () {
  // Forward unhandled / and Esc to the parent so chrome shortcuts work
  // even when the iframe has focus.
  document.addEventListener('keydown', (e) => {
    if (e.key !== '/' && e.key !== 'Escape') return;
    // Don't intercept when the user is typing in an input/textarea.
    const t = e.target;
    if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
    e.preventDefault();
    parent.postMessage({ kind: 'key', key: e.key }, location.origin);
  });

  // v2 annotation hook — left intentionally empty in v1.
  window.wikiBrowser = window.wikiBrowser || {};
  window.wikiBrowser.onAnnotationsReady = (_client) => {};
})();
git add internal/server/static/content.js
git commit -m "wiki-browser: static — content.js (key forwarding via postMessage)"

Task 25: Static — vendor HTMX and mermaid

These are minified third-party scripts; commit them once and pin their versions in a comment file so future upgrades are explicit.

Files:

curl -fsSL https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js \
  -o internal/server/static/htmx.min.js
curl -fsSL https://cdn.jsdelivr.net/npm/mermaid@11.4.1/dist/mermaid.esm.min.mjs \
  -o internal/server/static/mermaid.esm.min.mjs

(If the major version has moved on by the time you build, pick the latest 11.x. The ESM mermaid.run({ querySelector }) API used in content_md.html is stable across mermaid 10–11.)

# Vendored static assets

Both files in this directory are vendored to keep the binary self-contained on a Pi with no internet access.

| File                   | Source                                                         | Version |
|------------------------|----------------------------------------------------------------|---------|
| htmx.min.js            | https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js              | 2.0.4   |
| mermaid.esm.min.mjs    | https://cdn.jsdelivr.net/npm/mermaid@11.4.1/dist/mermaid.esm.min.mjs | 11.4.1  |

## Updating

1. Re-download with the same `curl` commands at a newer pinned version.
2. Verify the `content_md.html` mermaid invocation still uses the public ESM API (`import mermaid from '...'; mermaid.run(...)`).
3. Re-run the iframe-boundary headless test (Task 28) — it's the cheapest way to catch a breaking change.
go build ./...

Expected: no errors.

git add internal/server/static/htmx.min.js internal/server/static/mermaid.esm.min.mjs internal/server/static/VENDOR.md
git commit -m "wiki-browser: static — vendor htmx 2.0.4 and mermaid 11.4.1"

Phase 4 — Wire-up and deploy

Task 26: cmd/wiki-browser/main.go

Tie everything together: parse -config, load config, build walker → index → server, install signal handler.

Files:

// cmd/wiki-browser/main.go
package main

import (
	"context"
	"errors"
	"flag"
	"log/slog"
	"net/http"
	"os"
	"os/signal"
	"path/filepath"
	"syscall"
	"time"

	"github.com/getorcha/wiki-browser/internal/config"
	"github.com/getorcha/wiki-browser/internal/index"
	"github.com/getorcha/wiki-browser/internal/render"
	"github.com/getorcha/wiki-browser/internal/server"
	"github.com/getorcha/wiki-browser/internal/walker"
)

// renderCacheBytes caps the in-memory render cache. 32 MB matches the
// resource-budget table in the design spec; tune via a flag if needed later.
const renderCacheBytes = 32 << 20

func main() {
	configPath := flag.String("config", "wiki-browser.yaml", "path to config file")
	flag.Parse()

	logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
	slog.SetDefault(logger)

	if err := run(*configPath); err != nil {
		slog.Error("fatal", "err", err)
		os.Exit(1)
	}
}

func run(configPath string) error {
	cfg, err := config.Load(configPath)
	if err != nil {
		return err
	}
	slog.Info("config loaded", "root", cfg.Root, "listen", cfg.Listen)

	w, err := walker.New(walker.Options{
		Root:       cfg.Root,
		Extensions: cfg.Extensions,
		Exclude:    cfg.Exclude,
	})
	if err != nil {
		return err
	}
	slog.Info("walker ready", "files", len(w.Files()))

	idx, err := index.Open(cfg.IndexDB)
	if err != nil {
		return err
	}
	defer idx.Close()
	idx.SetRoot(cfg.Root)

	// One render cache shared by the index funnel goroutine (Reindex) and the
	// /content handler. Wiring both sides avoids double-renders and lets the
	// cache's singleflight coalesce concurrent misses across the two paths.
	renderCache := render.NewCache(renderCacheBytes)
	idx.SetCache(renderCache)

	rootCtx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer cancel()

	// Initial reindex: diff DB against walker output is implicit — Reindex is
	// idempotent and the funnel goroutine serializes mutations.
	go func() {
		for _, rel := range w.Files() {
			abs := filepath.Join(cfg.Root, rel)
			if err := idx.Reindex(abs); err != nil {
				slog.Warn("initial reindex failed", "path", rel, "err", err)
			}
		}
	}()

	// Subscribe to walker events for live updates.
	go func() {
		for ev := range w.Subscribe(rootCtx) {
			abs := filepath.Join(cfg.Root, ev.Path)
			switch ev.Kind {
			case walker.EventChanged:
				if err := idx.Reindex(abs); err != nil {
					slog.Warn("reindex failed", "path", ev.Path, "err", err)
				}
			case walker.EventRemoved:
				if err := idx.Remove(abs); err != nil {
					slog.Warn("remove failed", "path", ev.Path, "err", err)
				}
			}
		}
	}()

	mux := server.Mux(server.Deps{
		Title:  cfg.Title,
		Root:   cfg.Root,
		Walker: w,
		Index:  idx,
		Cache:  renderCache,
	})

	srv := &http.Server{
		Addr:              cfg.Listen,
		Handler:           mux,
		ReadHeaderTimeout: 10 * time.Second,
	}

	errCh := make(chan error, 1)
	go func() {
		slog.Info("listening", "addr", cfg.Listen)
		errCh <- srv.ListenAndServe()
	}()

	select {
	case err := <-errCh:
		if errors.Is(err, http.ErrServerClosed) {
			return nil
		}
		return err
	case <-rootCtx.Done():
		shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
		defer shutdownCancel()
		return srv.Shutdown(shutdownCtx)
	}
}
cp wiki-browser.example.yaml wiki-browser.yaml
# ensure wiki-browser.yaml.root points at /home/volrath/code/orcha (or any small repo for now)
go run ./cmd/wiki-browser
# in another terminal:
curl -s http://localhost:8080/healthz
curl -s http://localhost:8080/ | head

Expected: healthz returns ok; / returns shell HTML with iframe.

git add cmd/wiki-browser/main.go
git commit -m "wiki-browser: cmd — main entrypoint wiring config/walker/index/server"

Task 27: Makefile and systemd unit

Files:

# Makefile — wiki-browser
.PHONY: build build-arm64 test run lint clean

build:
	go build -trimpath -ldflags="-s -w" -o dist/wiki-browser ./cmd/wiki-browser

# Cross-compile for a 64-bit Pi.
build-arm64:
	GOOS=linux GOARCH=arm64 CGO_ENABLED=0 \
	  go build -trimpath -ldflags="-s -w" -o dist/wiki-browser-arm64 ./cmd/wiki-browser

test:
	go test ./...

run:
	go run ./cmd/wiki-browser -config=wiki-browser.yaml

lint:
	go vet ./...

clean:
	rm -rf dist
make build-arm64
file dist/wiki-browser-arm64

Expected: ELF 64-bit LSB executable, ARM aarch64.

# deploy/wiki-browser.service
[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
git add Makefile deploy/wiki-browser.service
git commit -m "wiki-browser: Makefile + systemd unit"

Phase 5 — Integration tests + smoke

Task 28: Iframe-boundary headless test

End-to-end: start the server against a fixture root, drive a real Chromium with chromedp, verify the iframe loads and postMessage delivery works.

Files:

The test exercises the spec's "iframe boundary" requirement: the iframe loads, navigation inside the iframe pushes the outer URL via history.pushState, and postMessage round-trips between content.js and chrome.js. Reading the iframe's static src attribute is NOT enough — location.replace doesn't update the attribute, so an attribute-only assertion would silently pass even if the URL sync were broken. Use chromedp.Evaluate to read live runtime state instead.

// internal/e2e/iframe_test.go
package e2e_test

import (
	"context"
	"errors"
	"net/http/httptest"
	"os"
	"os/exec"
	"path/filepath"
	"testing"
	"time"

	"github.com/chromedp/chromedp"

	"github.com/getorcha/wiki-browser/internal/index"
	"github.com/getorcha/wiki-browser/internal/render"
	"github.com/getorcha/wiki-browser/internal/server"
	"github.com/getorcha/wiki-browser/internal/walker"
)

func TestIframe_loadAndURLSync(t *testing.T) {
	if _, err := chromeBinary(); err != nil {
		t.Skip("chrome/chromium not available; skipping headless test")
	}
	root := t.TempDir()
	for name, body := range map[string]string{
		"a.md": "# A\n\nbody",
		"b.md": "# B\n\nother body",
	} {
		if err := os.WriteFile(filepath.Join(root, name), []byte(body), 0o644); err != nil {
			t.Fatal(err)
		}
	}
	w, _ := walker.New(walker.Options{Root: root, Extensions: []string{".md", ".html"}})
	idx, err := index.Open(filepath.Join(t.TempDir(), "i.db"))
	if err != nil {
		t.Fatal(err)
	}
	defer idx.Close()
	idx.SetRoot(root)
	cache := render.NewCache(4 << 20)
	idx.SetCache(cache)
	for _, name := range []string{"a.md", "b.md"} {
		if err := idx.Reindex(filepath.Join(root, name)); err != nil {
			t.Fatal(err)
		}
	}

	mux := server.Mux(server.Deps{Title: "T", Root: root, Walker: w, Index: idx, Cache: cache})
	ts := httptest.NewServer(mux)
	defer ts.Close()

	allocCtx, allocCancel := chromedp.NewExecAllocator(context.Background(),
		append(chromedp.DefaultExecAllocatorOptions[:], chromedp.Flag("headless", true))...)
	defer allocCancel()
	ctx, cancel := chromedp.NewContext(allocCtx)
	defer cancel()
	tctx, tcancel := context.WithTimeout(ctx, 30*time.Second)
	defer tcancel()

	var initialIframePath string
	var afterClickOuterPath string
	var afterClickIframePath string
	var keyForwardingWorked bool

	err = chromedp.Run(tctx,
		chromedp.Navigate(ts.URL+"/doc/a.md"),
		// Wait for the iframe element + its first load event to fire.
		chromedp.WaitVisible("#wb-content", chromedp.ByID),
		chromedp.Poll(`document.getElementById('wb-content').contentDocument && document.getElementById('wb-content').contentDocument.readyState === 'complete'`, nil),
		chromedp.Evaluate(`document.getElementById('wb-content').contentWindow.location.pathname`, &initialIframePath),

		// Click a sidebar link to trigger an iframe.location.replace + parent.history.pushState.
		chromedp.Evaluate(`document.querySelector('a[data-path="b.md"]').click()`, nil),
		chromedp.Sleep(400*time.Millisecond),
		chromedp.Evaluate(`location.pathname`, &afterClickOuterPath),
		chromedp.Evaluate(`document.getElementById('wb-content').contentWindow.location.pathname`, &afterClickIframePath),

		// postMessage round-trip: dispatch a "/" keydown inside the iframe; chrome.js
		// should focus the search input. We check focus moves to #wb-search.
		chromedp.Evaluate(`(function () {
			const iframe = document.getElementById('wb-content');
			iframe.contentWindow.postMessage({kind:'key', key:'/'}, location.origin);
		})()`, nil),
		chromedp.Sleep(150*time.Millisecond),
		chromedp.Evaluate(`document.activeElement && document.activeElement.id === 'wb-search'`, &keyForwardingWorked),
	)
	if err != nil {
		t.Fatalf("chromedp: %v", err)
	}
	if initialIframePath != "/content/a.md" {
		t.Errorf("initial iframe path = %q, want %q", initialIframePath, "/content/a.md")
	}
	if afterClickOuterPath != "/doc/b.md" {
		t.Errorf("after click, outer path = %q, want %q (history.pushState broken?)", afterClickOuterPath, "/doc/b.md")
	}
	if afterClickIframePath != "/content/b.md" {
		t.Errorf("after click, iframe path = %q, want %q", afterClickIframePath, "/content/b.md")
	}
	if !keyForwardingWorked {
		t.Errorf("postMessage 'key' from iframe → parent did not focus search input (chrome.js handler broken?)")
	}
}

func chromeBinary() (string, error) {
	for _, name := range []string{"google-chrome", "chromium", "chromium-browser"} {
		if p, err := exec.LookPath(name); err == nil {
			return p, nil
		}
	}
	return "", errors.New("no chrome found")
}
go test ./internal/e2e/... -v

Expected on a dev box with Chromium: PASS. On a system without Chromium: SKIP. Either is acceptable.

git add internal/e2e
git commit -m "wiki-browser: e2e — iframe load + URL sync via chromedp"

Task 29: Offline bm25 tuning corpus

The Pi smoke checklist asks you to tune bm25(8, 4, 1) against the real orcha corpus. That round-trip is slow if you have to re-deploy each time. This task ships a tiny corpus fixture and a printable test you can iterate against on your dev box; the Pi step then becomes a one-shot validation rather than the only place tuning happens.

Files:

Each file should be a few lines of representative prose. Sample:

<!-- testdata/tuning/payroll.md -->
# Payroll
Automation of monthly payroll runs across sites and currencies.
<!-- testdata/tuning/payroll-history.md -->
# Payroll history
Audit trail of revisions to the payroll module.
<!-- testdata/tuning/orders.md -->
# Orders
Sales-order ingestion and matching against bookings.
<!-- testdata/tuning/approval.md -->
# Approval
Multi-step approval workflow for AP documents.
<!-- testdata/tuning/approval-workflow.md -->
# Approval workflow
Implementation notes on the approval workflow ledger.
<!-- testdata/tuning/tax-vat.md -->
# Tax / VAT
Compliance details for German VAT and EU OSS reporting.
// internal/index/tuning_test.go
package index_test

import (
	"fmt"
	"path/filepath"
	"testing"

	"github.com/getorcha/wiki-browser/internal/index"
)

// TestBM25Tuning is verbose-only ("go test -v -run TestBM25Tuning"). It indexes
// the tuning corpus and prints the top results for a set of canonical queries
// so you can eyeball the ranking. It always passes — its job is human review.
func TestBM25Tuning(t *testing.T) {
	if !testing.Verbose() {
		t.Skip("verbose-only; run with -v to see rankings")
	}
	idx, dir := openTestIndex(t)
	corpus := []string{
		"payroll.md", "payroll-history.md", "orders.md",
		"approval.md", "approval-workflow.md", "tax-vat.md",
	}
	for _, name := range corpus {
		src := filepath.Join("testdata", "tuning", name)
		dst := filepath.Join(dir, name)
		if err := copyFile(src, dst); err != nil {
			t.Fatal(err)
		}
		if err := idx.Reindex(dst); err != nil {
			t.Fatal(err)
		}
	}

	queries := []string{
		"payroll", "payroll-history", "approval", "approval workflow",
		"VAT", "orders", "history",
	}
	for _, q := range queries {
		hits, err := idx.Search(q, 5)
		if err != nil {
			t.Errorf("Search(%q): %v", q, err)
			continue
		}
		fmt.Printf("\n=== query: %q ===\n", q)
		for i, h := range hits {
			kind := "FILE"
			if h.Kind == index.HitContent {
				kind = "BODY"
			}
			fmt.Printf("  %d. [%s] %-28s  bm25=%.3f\n", i+1, kind, h.Path, h.Score)
		}
	}
}

// (helper)
func copyFile(src, dst string) error {
	data, err := os.ReadFile(src)
	if err != nil {
		return err
	}
	return os.WriteFile(dst, data, 0o644)
}

(Add import "os" to the file.)

go test ./internal/index -v -run TestBM25Tuning

Expected: prints rankings for each query. For each, ask "is the right doc on top?" If payroll-history returns payroll.md ahead of payroll-history.md, raise the path weight.

Edit bm25(docs, 8.0, 4.0, 1.0) in internal/index/search.go, re-run, repeat until rankings feel right.

git add internal/index/testdata/tuning internal/index/tuning_test.go
git commit -m "wiki-browser: index — bm25 tuning corpus + printable ranking test"

Task 30: Pi smoke-test checklist

Document the manual checklist that must pass on the actual Pi before declaring v1 done. Commit it as a markdown file so future operators (and you in six months) know what to verify.

Files:

# Pi smoke-test checklist (v1)

Run on the actual Raspberry Pi against `/home/volrath/code/orcha` (or whatever you set `root` to). Every box must be green before declaring v1 done. Compare results against the **Resource budget on a Raspberry Pi** table in [the design spec](superpowers/specs/2026-05-09-wiki-browser-design.html).

## Build & install

- [ ] `make build-arm64` on the dev box succeeds.
- [ ] `scp dist/wiki-browser-arm64 pi:/home/pi/bin/wiki-browser`.
- [ ] `scp deploy/wiki-browser.service pi:/etc/systemd/system/`.
- [ ] `scp wiki-browser.example.yaml pi:/home/pi/.config/wiki-browser.yaml` and edit `root`.
- [ ] `ssh pi "sudo systemctl daemon-reload && sudo systemctl enable --now wiki-browser"`.

## Healthchecks

- [ ] `curl http://pi.local:8080/healthz` returns `ok`.
- [ ] `journalctl -u wiki-browser -n 50` shows a clean startup ("config loaded", "walker ready", "listening").

## Functional

- [ ] Open `http://pi.local:8080/` — sidebar populates with the expected top-level groups (no `www/`, `marketing/`, `.git/` etc.).
- [ ] Click through one document in each top-level group; the iframe loads, the outer URL updates to `/doc/<path>`.
- [ ] Browser back/forward buttons navigate between visited documents.
- [ ] Open a document containing a mermaid fence (e.g. one of the design specs) — diagram renders.
- [ ] Open an authored `.html` file (e.g. `docs/orcha-controlling.html`) — page renders with its own styling intact, no chrome interference.
- [ ] Press `/` while reading a Markdown doc — search box gains focus.
- [ ] **Documented v1 limitation:** press `/` while reading an authored HTML doc — search does NOT focus. This is expected (no `content.js` is injected into pass-through HTML, so the keystroke can't be forwarded). Verify the limitation rather than treating it as a bug.
- [ ] Type a query that matches a filename ("payroll", "approval") — Filename matches section appears at top.
- [ ] Type a query containing a hyphen (e.g. "payroll-history" or "feature-specs") — search returns matches without a server 500.
- [ ] Type a query containing an apostrophe (e.g. "user's") — search returns without a server 500.
- [ ] Type a query that matches content but not filenames ("invoice", a known body word) — Content matches section appears with a highlighted snippet.
- [ ] Click a search result — iframe swaps to the matched doc, search results clear.
- [ ] Press `Esc` — search box clears.

## Resource budget

Run `pgrep -x wiki-browser` to find the PID, then:

- [ ] `ps -o rss= -p <PID>` reports ≤ 50 MB (50000 KiB).
- [ ] `ls -lh /home/pi/bin/wiki-browser` reports ≤ 25 MB.
- [ ] `du -sh /home/pi/wiki-browser-index.db` (or wherever `index_db` points) — should be a few MB at most for the orcha corpus.
- [ ] On a `git pull` that touches dozens of files, the index converges within ~1 s after the debounce window.

## bm25 weights tuning

A corpus fixture under `internal/index/testdata/tuning/` lets you re-derive weights offline (no Pi loop needed). See `internal/index/tuning_test.go` (Task 29).

- [ ] Run `go test ./internal/index -run TestBM25Tuning -v` against the fixture corpus and review the printed ranking for each canonical query.
- [ ] If a filename match is buried below content matches, raise the path/title weights, re-run, iterate.
- [ ] On the Pi, repeat with the actual orcha corpus. Pick 5–10 queries (e.g. names of feature specs, prominent words from plans).
- [ ] Update `internal/index/search.go` with the chosen weights; update the spec's note line.

## Sign-off

- [ ] All boxes above checked.
- [ ] Commit the smoke results as `docs/pi-smoke-results-YYYY-MM-DD.md` with timestamps and the actual numbers measured.
git add docs/pi-smoke-checklist.md
git commit -m "wiki-browser: docs — Pi smoke-test checklist"

Wrap-up: full test suite

go test ./... -race -count=1

Expected: all unit and integration tests PASS. The chromedp test SKIPs unless Chromium is on PATH.

go vet ./...
make build
make build-arm64

Expected: no warnings; both binaries exist under dist/.

git status
# if anything's uncommitted: commit with a descriptive message.

Self-review notes (left for the executing engineer)

The plan author ran a self-review against the spec before handoff:

  1. Spec coverage: every section of the design has at least one task — config (Task 2), walker scan/watch/debounce (Tasks 3–6), render markdown/html/cache (Tasks 7–10), index schema/funnel/search/startup (Tasks 11–13), nav (Task 14), server templates / safe-path / route handlers (Tasks 15–20), static assets (Tasks 21–25), main + Makefile + systemd (Tasks 26–27), iframe headless test (Task 28), bm25 offline tuning corpus (Task 29), Pi smoke (Task 30).
  2. Type consistency: Document (HTML, PlainText, Title, HasMermaid) is consistent across render and index. Hit.Kind enum used by both index and server. nav.Group/Item re-aliased into navGroup/navItem for templates — same field names.
  3. Placeholder check: no TBD/TODO/"similar to". Where templates are stubbed (Task 7's wrapMarkdownDocument) the plan explicitly calls out that Task 15 replaces them with the real templates.
  4. Library notes: the front-matter dependency (go.abhg.dev/goldmark/frontmatter v0.3+) was confirmed real and stable. The mermaid CDN URL versions are pinned; if a future curl 404s, bump the version in Task 25.

Post-review revisions (independent reviewer pass)

After an independent reviewer audit, the following load-bearing fixes were folded into the plan. Anyone executing the plan should know these are deliberate (do not "simplify" them back):

Second-pass revisions (review round 2)

A second reviewer pass caught three production bugs that were silent until runtime. These are all fixed; the notes below explain why the fix is what it is so nobody "simplifies" it back: