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:
1 parent
9d0ae2bcc4
commit
84cd6328f4
19 files changed
+2030
-1097
No files matched your search
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user