feat(docs): standalone docs site chrome + category/subcategory navigation

Docs pages no longer embed the app Header (whose hx-post login form silently
degraded to a native GET, leaking credentials into the URL — this page never
loaded htmx). New docs-owned topbar: search, theme picker, Open App; anonymous
visitors get a plain /login link. Optional-auth middleware (hand-rolled —
echojwt's ErrorHandler return ends the chain, producing empty 200s) populates
the user for signed-in visitors.

Navigation rebuilt from the docs directory tree: 6 sections, subcategory
groups, server-rendered expansion and active states. Search results are a
templ partial (/docs/search/partial); code highlighting is server-side chroma
(previously dead hljs CSS); prose is theme-aware via typography DEFAULT vars
(prose-invert painted unreadable text under light themes). /docs renders the
index directly; standalone 404 without the tailwind CDN.

Tests: navigation routing/expansion (internal/docs), render pins asserting no
login form/password field/app sidebar ever return on docs pages.
lint-rendering: exempt *.test.ts fixtures (jsdom DOM building); docs.ts
baseline 5 -> 1.
This commit is contained in:
John O'Keefe committed 2026-10-24 14:31:36 -04:00
1 parent 9d0ae2bcc4
commit 84cd6328f4
19 files changed
+2030 -1097

No files matched your search

+205
View File
@@ -0,0 +1,205 @@
package docs
import (
"os"
"path/filepath"
"testing"
"bookhoard/templates"
)
func TestPlaceDoc(t *testing.T) {
tests := []struct {
path string
section string
group string
pinned bool
}{
{"index.md", "Getting Started", "", true},
{"user/user-guide.md", "User Guide", "", true},
{"user/dashboard.md", "User Guide", "", false},
{"user/admin-guide.md", "User Guide", "Administration", false},
{"user/devices/kobo-setup.md", "User Guide", "Devices", false},
{"developer/api/api-reference.md", "API Reference", "", true},
{"developer/api/dashboard.md", "API Reference", "", false},
{"developer/api/authentication/login.md", "API Reference", "Authentication", false},
{"developer/api/saved-filters/index.md", "API Reference", "Saved Filters", true},
{"developer/testing.md", "Developer", "", false},
{"developer/development.md", "Developer", "", true},
{"developer/scanner/audio-metadata.md", "Developer", "Scanner", false},
{"contract_pins.md", "Developer", "Reference", false},
{"FRONTEND_INTEGRATION.md", "Developer", "Reference", false},
{"development/calibre-opf-implementation.md", "Developer", "Reference", false},
{"operations/operations.md", "Operations", "", true},
{"operations/troubleshooting.md", "Operations", "", false},
{"contributing/contributing.md", "Contributing", "", true},
{"contributing/development.md", "Contributing", "", false},
}
for _, tt := range tests {
got := placeDoc(tt.path)
if got.section != tt.section || got.group != tt.group {
t.Errorf("placeDoc(%q) = (%q, %q), want (%q, %q)",
tt.path, got.section, got.group, tt.section, tt.group)
}
if (got.pin < 0) != tt.pinned {
t.Errorf("placeDoc(%q) pin = %d, pinned want %v", tt.path, got.pin, tt.pinned)
}
}
}
func TestDocTitle(t *testing.T) {
tests := map[string]string{
"index.md": "Overview",
"user/user-guide.md": "User Guide",
"user/devices/koreader-setup.md": "KOReader Setup",
"developer/development.md": "Developer Portal",
"developer/api/api-reference.md": "Overview",
"developer/api/users/update_profile.md": "Update Profile",
"developer/api/book-matching/link_book.md": "Link Book",
"operations/troubleshooting.md": "Troubleshooting",
"contract_pins.md": "Contract Pins",
}
for path, want := range tests {
if got := docTitle(path); got != want {
t.Errorf("docTitle(%q) = %q, want %q", path, got, want)
}
}
}
func TestDocURL(t *testing.T) {
if got := docURL("index.md"); got != "/docs" {
t.Errorf("docURL(index.md) = %q, want /docs", got)
}
if got := docURL("user/sync-guide.md"); got != "/docs/user/sync-guide" {
t.Errorf("docURL = %q, want /docs/user/sync-guide", got)
}
}
// buildTestDocs creates a representative docs tree in a temp dir and returns
// a handler over it.
func buildTestDocs(t *testing.T) *DocsHandler {
t.Helper()
dir := t.TempDir()
files := []string{
"index.md",
"user/user-guide.md",
"user/dashboard.md",
"user/admin-guide.md",
"user/devices/kobo-setup.md",
"user/devices/koreader-setup.md",
"developer/api/api-reference.md",
"developer/api/authentication/login.md",
"developer/api/authentication/overview.md",
"developer/api/saved-filters/index.md",
"developer/testing.md",
"operations/troubleshooting.md",
"contributing/development.md",
"contract_pins.md",
}
for _, f := range files {
path := filepath.Join(dir, f)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("# Test\n"), 0o644); err != nil {
t.Fatal(err)
}
}
return &DocsHandler{docsFS: os.DirFS(dir), docsPath: dir}
}
func TestBuildNavigation(t *testing.T) {
h := buildTestDocs(t)
// Current page: a device setup guide → User Guide section and its
// Devices group must render expanded; everything else collapsed.
nav := h.BuildNavigation("/docs/user/devices/kobo-setup")
if len(nav.Sections) == 0 {
t.Fatal("no sections built")
}
wantOrder := []string{"Getting Started", "User Guide", "API Reference", "Developer", "Operations", "Contributing"}
for i, want := range wantOrder {
if nav.Sections[i].Title != want {
t.Fatalf("section %d = %q, want %q", i, nav.Sections[i].Title, want)
}
}
getting := nav.Sections[0]
if getting.Collapsed {
t.Error("Getting Started should be expanded (root overview pinned)")
}
if len(getting.Items) != 1 || getting.Items[0].Title != "Overview" || getting.Items[0].URL != "/docs" {
t.Errorf("Getting Started items = %+v, want single Overview → /docs", getting.Items)
}
userGuide := nav.Sections[1]
if userGuide.Collapsed {
t.Error("User Guide should be expanded: it contains the current page")
}
if len(userGuide.Items) != 2 || userGuide.Items[0].Title != "User Guide" {
t.Errorf("User Guide direct items = %+v, want User Guide pinned first", userGuide.Items)
}
var devices *templates.NavGroup
for i := range userGuide.Groups {
if userGuide.Groups[i].Title == "Devices" {
devices = &userGuide.Groups[i]
}
}
if devices == nil {
t.Fatal("User Guide has no Devices group")
}
if devices.Collapsed {
t.Error("Devices group should be expanded: it contains the current page")
}
if len(devices.Items) != 2 || devices.Items[0].Title != "Kobo Setup" {
t.Errorf("Devices items = %+v, want Kobo Setup + KOReader Setup", devices.Items)
}
// Everything else starts collapsed.
for _, s := range nav.Sections[2:] {
if !s.Collapsed {
t.Errorf("section %q should be collapsed (current page is elsewhere)", s.Title)
}
}
// API Reference: subdirectory groups with the index page as Overview.
api := nav.Sections[2]
if len(api.Groups) != 2 {
t.Fatalf("API Reference groups = %d, want 2 (Authentication, Saved Filters)", len(api.Groups))
}
if api.Groups[0].Title != "Authentication" || len(api.Groups[0].Items) != 2 {
t.Errorf("Authentication group = %+v", api.Groups[0])
}
saved := api.Groups[1]
if saved.Title != "Saved Filters" || len(saved.Items) != 1 || saved.Items[0].Title != "Overview" {
t.Errorf("Saved Filters group = %+v, want single Overview item", saved)
}
if !saved.Collapsed {
t.Error("Saved Filters group should be collapsed on an unrelated page")
}
// Stray root files land in Developer → Reference.
dev := nav.Sections[3]
if len(dev.Groups) != 1 || dev.Groups[0].Title != "Reference" {
t.Errorf("Developer groups = %+v, want Reference", dev.Groups)
}
}
func TestBuildNavigationNothingCurrent(t *testing.T) {
h := buildTestDocs(t)
nav := h.BuildNavigation("/docs/nonexistent")
open := 0
for _, s := range nav.Sections {
if !s.Collapsed {
open++
}
}
if open != 1 {
t.Errorf("expected exactly one section open on a 404 path, got %d", open)
}
if nav.Sections[0].Collapsed {
t.Error("fallback should open the first section")
}
}