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.
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).
github.com/getorcha/wiki-browser. (Adjust in Task 1 if the user wants a different path.)log/slog with the JSON handler in production, text handler in tests. Always wrap returned errors with fmt.Errorf("...: %w", err).testing. For complex value comparisons use github.com/google/go-cmp/cmp. For golden-file tests, accept a -update flag that rewrites the golden output. Use t.TempDir() for fixture trees — Go cleans them up automatically.wiki-browser: <imperative summary>./home/volrath/code/orcha/wiki-browser.Files:
Create: go.mod
Create: README.md
Create: .gitignore
Create: wiki-browser.example.yaml
Step 1: Initialize the Go module
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
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"
Files:
Create: internal/config/config.go
Test: internal/config/config_test.go
Create: internal/config/testdata/valid.yaml
Create: internal/config/testdata/missing-root.yaml
Step 1: Write the failing tests
// 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.
config.go// 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"
The walker needs to know which paths to skip. Default excludes are baked into the binary; user excludes from config layer on top.
Files:
Create: internal/walker/exclude.go
Test: internal/walker/exclude_test.go
Step 1: Write the failing tests
// 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.
exclude.go// 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"
Walk the tree, apply excludes and extension filters, return a sorted list of repo-relative paths.
Files:
Create: internal/walker/walker.go
Test: internal/walker/walker_test.go
Step 1: Write the failing test
// 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.
walker.go// 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"
Add live updates: per-directory fsnotify subscriptions, recursive subscribe on dir-CREATE, ENOSPC handling, swap-file filtering.
Files:
Create: internal/walker/watch.go
Test: internal/walker/watch_test.go
Step 1: Write the failing tests
// 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.
watch.go// 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"
Coalesce bursty events through a 300 ms quiet window per path. The public output is Subscribe(ctx) <-chan Event.
Files:
Modify: internal/walker/walker.go (add Subscribe)
Create: internal/walker/debounce.go
Test: internal/walker/debounce_test.go
Step 1: Write the failing test
// 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.
debounce.go// 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():
}
})
}
Subscribe on WalkerAppend 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"
Set up the Render entry point and dispatch by file extension.
Files:
Create: internal/render/render.go
Test: internal/render/render_test.go
Step 1: Write the failing tests
// 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.
render.go// 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"
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:
Modify: internal/render/markdown.go
Create: internal/render/mermaid_extension.go
Test: internal/render/markdown_test.go
Create: internal/render/testdata/basic.md, testdata/basic.golden.html
Create: internal/render/testdata/gfm.md, testdata/gfm.golden.html
Create: internal/render/testdata/code.md, testdata/code.golden.html
Create: internal/render/testdata/mermaid.md, testdata/mermaid.golden.html
Create: internal/render/testdata/frontmatter.md, testdata/frontmatter.golden.html
Step 1: Write the failing test (golden-file)
// 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),
))
}
markdown.go// 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.
wrapMarkdownDocument and plaintextFromAST to compile (full implementations land in Tasks 11 and 18)// 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("&", "&", "<", "<", ">", ">", "\"", """)
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.
-update to verify replaygo 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"
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:
Modify: internal/render/html.go
Test: internal/render/html_test.go
Create: internal/render/testdata/sample.html
Step 1: Write the failing test
// 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.
html.go// 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"
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:
Create: internal/render/cache.go
Test: internal/render/cache_test.go
Step 1: Write the failing tests
// 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.
cache.go// 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"
Open or create the SQLite FTS5 database, manage a schema-version pragma, surface startup-state errors.
Files:
Create: internal/index/index.go
Create: internal/index/schema.go
Test: internal/index/index_test.go
Step 1: Write the failing tests
// 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.
schema.go// 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;
`
index.go// 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"
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:
Create: internal/index/mutator.go
Test: internal/index/mutator_test.go
Step 1: Write the failing tests
// 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.
mutator.go// 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"
Two flavors of search: filename matches against path and title; content matches against body with a snippet helper.
Files:
Modify: internal/index/index.go (add Search)
Create: internal/index/search.go
Test: internal/index/search_test.go
Step 1: Write the failing tests
// 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.
search.go// 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"
Group repo-relative paths by their top-level directory, returning structured data the chrome shell template can iterate.
Files:
Create: internal/nav/nav.go
Test: internal/nav/nav_test.go
Step 1: Write the failing test
// 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.
nav.go// 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"
Set up the embed.FS, parse templates, expose typed helpers.
Files:
Create: internal/server/embed.go
Create: internal/server/templates/shell.html
Create: internal/server/templates/content_md.html
Create: internal/server/templates/search_results.html
Create: internal/server/templates/partials/nav.html
Test: internal/server/templates_test.go
Step 1: Write the failing test
// 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.
embed.go// 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 }}
static/ and content/ directories so embed succeedsmkdir -p internal/server/static internal/server/content
touch internal/server/static/.gitkeep internal/server/content/.gitkeep
wrapMarkdownDocument to use the template (replaces the stub from Task 8)// 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"
Resolve checks walker membership first (cheap map lookup), then filepath.Clean and an EvalSymlinks invariant as defense-in-depth.
Files:
Create: internal/server/safe_path.go
Test: internal/server/safe_path_test.go
Step 1: Write the failing test
// 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
safe_path.go// 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)"
/ and /doc/{path...} (chrome shell handler)Files:
Create: internal/server/server.go
Create: internal/server/handler_doc.go
Test: internal/server/handler_doc_test.go
Step 1: Write the failing test
// 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.
server.go// 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
}
handler_doc.go// 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
}
handleNavPartial, handleContent, handleBakedContent, handleSearch stubs so the server compiles (they fail until later tasks):// 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"
/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:
Modify: internal/server/handlers_stubs.go (or create handler_content.go; remove the stub)
Test: internal/server/handler_content_test.go
Step 1: Write the failing test
// 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
handler_content.go// 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)"
/search handlerFiles:
Modify: internal/server/handlers_stubs.go (remove handleSearch stub)
Create: internal/server/handler_search.go
Test: internal/server/handler_search_test.go
Step 1: Write the failing test
// 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.
Step 2: Run, expect failure
Step 3: Implement handler_search.go
// 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"
/partials/nav and baked-in content pagesFiles:
Create: internal/server/handler_partials.go
Create: internal/server/handler_baked.go
Create: internal/server/content/_welcome.md
Create: internal/server/content/_404.md
Create: internal/server/content/_search-offline.md
Test: internal/server/handler_baked_test.go
Step 1: Write the failing tests
// 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)
}
}
handler_baked.go// 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.
handler_partials.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"
Files:
internal/server/static/chrome.cssThe chrome CSS must scope under .wb-shell/.wb-topbar/.wb-sidebar/.wb-main per the spec. Never style body, html, *, or bare element selectors.
chrome.css/* 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)"
The prose CSS styles rendered Markdown content inside the iframe. Selectors target .wb-prose and child elements only.
Files:
Create: internal/server/static/prose.css
Step 1: Write prose.css
/* 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"
URL sync from iframe load, popstate handler, key shortcuts, search-result click delegate.
Files:
Create: internal/server/static/chrome.js
Step 1: Write chrome.js
// 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)"
Files:
Create: internal/server/static/content.js
Step 1: Write content.js
// 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)"
These are minified third-party scripts; commit them once and pin their versions in a comment file so future upgrades are explicit.
Files:
Create: internal/server/static/htmx.min.js
Create: internal/server/static/mermaid.esm.min.mjs
Create: internal/server/static/VENDOR.md
Step 1: Download HTMX
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.)
VENDOR.md# 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"
Tie everything together: parse -config, load config, build walker → index → server, install signal handler.
Files:
Create: cmd/wiki-browser/main.go
Step 1: Write main.go
// 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"
Files:
Create: Makefile
Create: deploy/wiki-browser.service
Step 1: Write Makefile
# 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# 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"
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:
Create: internal/e2e/iframe_test.go
Step 1: Write the test
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"
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:
Create: internal/index/testdata/tuning/payroll.md
Create: internal/index/testdata/tuning/payroll-history.md
Create: internal/index/testdata/tuning/orders.md
Create: internal/index/testdata/tuning/approval.md
Create: internal/index/testdata/tuning/approval-workflow.md
Create: internal/index/testdata/tuning/tax-vat.md
Test: internal/index/tuning_test.go
Step 1: Write the fixture corpus
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"
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:
Create: docs/pi-smoke-checklist.md
Step 1: Write the checklist
# 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"
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.
The plan author ran a self-review against the spec before handoff:
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.wrapMarkdownDocument) the plan explicitly calls out that Task 15 replaces them with the real templates.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.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):
KindFencedCodeBlock renderer at the same priority as goldmark-highlighting. The naive approach silently breaks chroma syntax highlighting because goldmark's renderer registry is single-slot per kind and the lower-priority registration wins. A positive structural assertion in markdown_test.go catches regressions.MaxOpenConns(1), journal_mode=WAL, synchronous=NORMAL, and busy_timeout=5000. Without these, /search reads collide with funnel-goroutine writes under any churn (intermittent SQLITE_BUSY 500s). A regression test (TestSearch_concurrentWithReindex in Task 12) covers the contract.Open and stops in Close (Task 11/12). The previous RunMutator(ctx) API was racy on first use and panic-prone if forgotten; tests and main.go simply rely on Open/Close. There is no RunMutator method."...", with " → ""). Without this, hyphens parse as NOT, apostrophes terminate the string, and stop-words like and/or silently match nothing — every common query in the orcha corpus would 500. TestSearch_handlesFTSSpecialChars covers it.singleflight per absPath so concurrent misses on the same file don't fork redundant renders. Cache key uses Unix() (seconds) — same resolution the index stores — so cache invalidation and reindex agree on what counts as "changed".chromedp.Evaluate (parent location.pathname, iframe contentWindow.location.pathname, document.activeElement.id). Reading the iframe's static src attribute is meaningless after location.replace — it never updates.Reindex/Remove on the same path and asserts no zombie rows, per the spec's testing section.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:
template.ParseFS registers each template under path.Base(filename), not the full path — so templates/partials/nav.html becomes the template named "nav.html". The shell's {{ template "..." }} include and handler_partials.go's ExecuteTemplate(...) both use "nav.html" to match. The comment in embed.go documents this; a verbatim test program confirmed the behavior. Don't change includes back to path-style names without restructuring mustTemplates to register templates by full path.*render.Cache on server.Deps AND on *index.Index (via SetCache). handler_content.go calls d.Cache.Get; the index funnel goroutine calls i.cache.Get. Both share the same singleflight, so a request that arrives while the funnel is rendering the same file gets the funnel's output, not a duplicate render. main.go instantiates one render.NewCache(32 << 20) and passes it to both. Tests do the same in newTestServer and openTestIndex. Calling Reindex without SetCache will panic — by design, so misconfiguration surfaces immediately rather than silently bypassing the cache./search offline fragment (Task 19). When index.Search errors, the handler renders search_results.html with Offline: true (a new field on SearchResultsData) instead of returning 500. The template gained an {{ if .Offline }} branch that emits the same fragment shape so HTMX swaps it into #wb-search-results cleanly. TestSearch_returnsOfflineFragmentOnIndexError forces the failure by closing the index before the request — *sql.DB.Query on a closed handle returns an error, which is the same surface a corrupt or truly unavailable DB would expose. We deliberately did NOT add startup retry-with-backoff for a locked DB; that's still v2 territory. The mid-flight degraded response is what the spec asked for in v1.