diff --git a/internal/docs/navigation.go b/internal/docs/navigation.go index 62a0565a..8a16c36d 100644 --- a/internal/docs/navigation.go +++ b/internal/docs/navigation.go @@ -321,10 +321,23 @@ func (h *DocsHandler) BuildNavigation(currentPath string) *templates.Navigation return nav } -// containsURL reports whether currentPath is one of the given doc URLs. +// normalizeDocURL — folder-index pages SERVE at their directory URL +// (/docs/user/filters) while their nav entry carries the /index suffix +// (/docs/user/filters/index): same page, two spellings. Both sidebar +// checks (expansion + highlight) compare normalized forms, so folder +// pages expand and highlight like file pages — no per-page special +// cases (2026-11-01, the filter manual's folder). +func normalizeDocURL(u string) string { + u = strings.TrimRight(u, "/") + return strings.TrimSuffix(u, "/index") +} + +// containsURL reports whether currentPath is one of the given doc URLs +// (folder-index URLs normalized — see normalizeDocURL). func containsURL(currentPath string, urls []string) bool { + current := normalizeDocURL(currentPath) for _, u := range urls { - if currentPath == u { + if current == normalizeDocURL(u) { return true } } diff --git a/internal/docs/navigation_test.go b/internal/docs/navigation_test.go index b6170a16..03b2a533 100644 --- a/internal/docs/navigation_test.go +++ b/internal/docs/navigation_test.go @@ -4,6 +4,8 @@ import ( "os" "path/filepath" "testing" + + "bookhoard/templates" ) func TestPlaceDoc(t *testing.T) { @@ -205,3 +207,41 @@ func TestBuildNavigationNothingCurrent(t *testing.T) { t.Errorf("expected zero sections open when nothing matches (docs home / 404 renders fully closed), got %d", open) } } + +// TestBuildNavigationFolderIndex (2026-11-01): folder-index pages serve +// at their directory URL (/docs/user/filters) while their nav entries +// carry /index (/docs/user/filters/index) — the sidebar must EXPAND the +// containing section/group and HIGHLIGHT the index item for the folder +// URL, exactly like a file page. The filter manual's folder is the +// first page of this shape. +func TestBuildNavigationFolderIndex(t *testing.T) { + // The REAL docs tree: the Filters group only exists because the + // filter manual (guide/cookbook/fields) lives in user/filters/. + handler := NewDocsHandler("../../docs") + nav := handler.BuildNavigation("/docs/user/filters") + + var userGuide *templates.NavSection + for i := range nav.Sections { + if nav.Sections[i].Title == "User Guide" { + userGuide = &nav.Sections[i] + } + } + if userGuide == nil { + t.Fatal("User Guide section missing") + } + if userGuide.Collapsed { + t.Error("User Guide section must expand for /docs/user/filters") + } + var filters *templates.NavGroup + for i := range userGuide.Groups { + if userGuide.Groups[i].Title == "Filters" { + filters = &userGuide.Groups[i] + } + } + if filters == nil { + t.Fatal("Filters group missing from User Guide") + } + if filters.Collapsed { + t.Error("Filters group must expand for its folder URL") + } +} diff --git a/templates/docs_render_test.go b/templates/docs_render_test.go index 12bbdd27..21bab051 100644 --- a/templates/docs_render_test.go +++ b/templates/docs_render_test.go @@ -144,3 +144,23 @@ func TestDocsNotFoundRenders(t *testing.T) { 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") + } +} diff --git a/templates/utils.go b/templates/utils.go index 17de12db..72a8d8ea 100644 --- a/templates/utils.go +++ b/templates/utils.go @@ -26,10 +26,15 @@ func activeClass(current, target string) string { // docsNavClass returns classes for docs sidebar links (server-rendered active // state — there is no client-side highlighter). indent sets the left padding, -// deeper for subcategory group items. +// deeper for subcategory group items. Folder-index pages compare normalized +// (their serving URL lacks the /index suffix their nav entry carries). func docsNavClass(current, target, indent string) string { base := "nav-item flex items-center " + indent + " pr-4 mr-2 py-1.5 rounded-lg text-sm transition-colors " - if strings.TrimRight(current, "/") == strings.TrimRight(target, "/") { + strip := func(u string) string { + u = strings.TrimRight(u, "/") + return strings.TrimSuffix(u, "/index") + } + if strip(current) == strip(target) { return base + "bg-brand/15 text-brand font-medium" } return base + "text-content-muted hover:text-content hover:bg-surface-hover"