The filter manual is the docs tree's first FOLDER-index page: it serves at /docs/user/filters while its nav entry carries /index (/docs/user/filters/index). The sidebar's two checks — group expansion (containsURL) and active-link styling (docsNavClass) — compared URLs EXACTLY, so the folder URL matched neither: no submenu expansion, no highlight (2026-11-01 user report: "the left sidebar should open to the submenu and highlight the page"). Fix is structural, not per-page: both checks normalize folder-index URLs (a trailing /index is the same page as its directory), so EVERY folder-index page — current and future — expands and highlights like a file page. No per-page registration exists anywhere in the docs system to redo: the sidebar derives from the docs tree, and this was the one URL shape its equality rule missed. Pins: navigation (section + group expand for the folder URL, group present) and docsNavClass (folder URL highlights the Overview item, normalization symmetric, file pages keep their highlight, no cross-highlighting).
167 lines
5.7 KiB
Go
167 lines
5.7 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")
|
|
}
|
|
}
|
|
|
|
// docsNavClass folder-index normalization (2026-11-01): folder-index
|
|
// pages serve at their directory URL while nav entries carry /index —
|
|
// the Overview item must highlight for the folder URL, file pages keep
|
|
// their own highlight, and cross-highlighting stays impossible.
|
|
func TestDocsNavClassFolderIndex(t *testing.T) {
|
|
active := docsNavClass("/docs/user/filters", "/docs/user/filters/index", "pl-9")
|
|
if !strings.Contains(active, "bg-brand/15") {
|
|
t.Errorf("folder-index item must highlight as active, got %q", active)
|
|
}
|
|
if !strings.Contains(docsNavClass("/docs/user/filters/index", "/docs/user/filters", "pl-9"), "bg-brand/15") {
|
|
t.Errorf("normalization is symmetric (either URL order highlights)")
|
|
}
|
|
if !strings.Contains(docsNavClass("/docs/user/filters/cookbook", "/docs/user/filters/cookbook", "pl-9"), "bg-brand/15") {
|
|
t.Error("file-page highlight regression")
|
|
}
|
|
if strings.Contains(docsNavClass("/docs/user/filters/cookbook", "/docs/user/filters/index", "pl-9"), "bg-brand/15") {
|
|
t.Error("cookbook URL wrongly highlights the Overview item")
|
|
}
|
|
}
|