Files
bookhoard/templates/docs_render_test.go
T
John O'Keefe 84cd6328f4 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.
2026-10-24 14:31:36 -04:00

147 lines
4.6 KiB
Go

package templates
import (
"context"
"strings"
"testing"
)
// The docs layout is a standalone documentation site: it must NOT embed the
// app chrome (its sidebar assumed a signed-in user) and must never contain a
// login form — the old layout posted credentials via htmx that was never
// loaded on this page, degrading to a native GET that put the username and
// password into the URL. These render tests pin that fix.
func TestDocsLayoutRendersStandalone(t *testing.T) {
nav := Navigation{
Sections: []NavSection{
{
Title: "Getting Started",
Icon: "book-open",
Items: []NavItem{{Title: "Overview", URL: "/docs"}},
},
{
Title: "User Guide",
Icon: "book",
Collapsed: true,
Items: []NavItem{{Title: "Dashboard", URL: "/docs/user/dashboard"}},
Groups: []NavGroup{
{
Title: "Devices",
Items: []NavItem{{Title: "Kobo Setup", URL: "/docs/user/devices/kobo-setup"}},
Collapsed: true,
},
},
},
},
}
doc := Document{
Title: "Test Doc",
Content: "<p>Hello docs</p>",
TOC: []TOCItem{{Level: 2, Title: "Section", Anchor: "section"}},
Breadcrumb: []BreadcrumbItem{
{Title: "Docs", URL: "/docs"},
{Title: "User Guide"},
},
SourceFile: "user/test.md",
}
var b strings.Builder
if err := DocsLayout(nav, doc, User{Theme: "tokyo-night"}, "/docs/user/test").Render(context.Background(), &b); err != nil {
t.Fatalf("DocsLayout: %v", err)
}
out := b.String()
for _, banned := range []string{
"app-sidebar", // app chrome
`hx-post="/api/auth/login"`, // no inline login form (the URL-leak bug)
`name="password"`, // credentials never render on docs pages
"prose-invert", // theme-aware prose, not hardcoded dark
"highlight-dark.min.css", // highlighting is server-side now
"cdn.tailwindcss.com", // no CDN dependencies
"lunr", // dead client-side search is gone
} {
if strings.Contains(out, banned) {
t.Errorf("docs layout must not contain %q", banned)
}
}
for _, want := range []string{
`href="/docs"`, // sidebar + logo links
`data-collapsed="true"`, // collapsed section state
`data-collapsed="false"`, // expanded section state
"Open App", // topbar action
"theme-select", // theme picker
"docs-search-results", // search dropdown target
"Hello docs", // content rendered
"page-docs",
} {
if !strings.Contains(out, want) {
t.Errorf("docs layout missing %q", want)
}
}
// Anonymous visitor: a sign-in LINK, never a form.
b.Reset()
if err := DocsLayout(nav, doc, User{Theme: "tokyo-night"}, "/docs/user/test").Render(context.Background(), &b); err != nil {
t.Fatalf("DocsLayout anonymous: %v", err)
}
if !strings.Contains(b.String(), `href="/login"`) {
t.Error("anonymous docs layout should link to /login")
}
}
func TestDocsTopbarSignedIn(t *testing.T) {
var b strings.Builder
if err := DocsTopbar(User{ID: "u1", Username: "reader", Theme: "tokyo-night"}).Render(context.Background(), &b); err != nil {
t.Fatalf("DocsTopbar: %v", err)
}
out := b.String()
if strings.Contains(out, `href="/login"`) {
t.Error("signed-in topbar must not offer /login")
}
if !strings.Contains(out, "reader") {
t.Error("signed-in topbar should surface the username")
}
}
func TestDocsSearchResultsPartial(t *testing.T) {
var b strings.Builder
results := []SearchResultItem{
{Title: "Kobo Setup", Category: "User Guide", URL: "/docs/user/devices/kobo-setup"},
}
if err := DocsSearchResults(results).Render(context.Background(), &b); err != nil {
t.Fatalf("DocsSearchResults: %v", err)
}
out := b.String()
for _, want := range []string{"Kobo Setup", "User Guide", `/docs/user/devices/kobo-setup`} {
if !strings.Contains(out, want) {
t.Errorf("search results missing %q", want)
}
}
// Empty state renders, not blank markup.
b.Reset()
if err := DocsSearchResults(nil).Render(context.Background(), &b); err != nil {
t.Fatalf("DocsSearchResults empty: %v", err)
}
if !strings.Contains(b.String(), "No matching pages.") {
t.Error("search results empty state missing")
}
}
func TestDocsNotFoundRenders(t *testing.T) {
var b strings.Builder
if err := DocsNotFound("Document Not Found", "no such page").Render(context.Background(), &b); err != nil {
t.Fatalf("DocsNotFound: %v", err)
}
out := b.String()
for _, want := range []string{"Document Not Found", "no such page", `href="/docs"`} {
if !strings.Contains(out, want) {
t.Errorf("404 page missing %q", want)
}
}
if strings.Contains(out, "cdn.tailwindcss.com") {
t.Error("404 page must not pull the tailwind CDN")
}
}