docs(ui): drop sidebar saved-state — server defaults already ARE the standard docs behavior
Simplification per spec: the menu tracks state through the URL alone. Land on /docs: fully closed, headers only (an explicit home rule now overrides the contains-URL rule, since the root overview doc lives in Getting Started and would otherwise auto-open it). Land on any doc page: its containing section + group render open at first paint, current link bold — no JS, no jump, arrows consistent. Navigate elsewhere: old closes, new opens. Deleted: docs_nav cookie const/parse, ApplyNavOverrides, cookie write + collect/persist helpers in web/src/docs.ts, data-key attributes in the sidebar templ, and the first-section-open fallback (docs home used to force-open the first section — spec'd as closed). toggleCollapsible remains for in-page peeking; purely ephemeral. The [data-collapsed=true] CSS from the previous round stays — it is what makes the server-rendered state actually hide sections at paint (the original bug was that this CSS never existed). Tests: TestBuildNavigationNothingCurrent now asserts zero sections open on unmatched paths; TestBuildNavigation asserts Getting Started closed on a device page (no site-wide pin). make test green; 146 vitests; live verified: home 0 expanded, doc pages exactly 2 (section+group).
This commit is contained in:
1 parent
a8f8c4def9
commit
73e8cdef4b
6 files changed
+155
-272
No files matched your search
@@ -2,10 +2,8 @@ package docs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"bookhoard/internal/database"
|
||||
@@ -48,37 +46,10 @@ func (h *HTTPHandler) currentUser(c *echo.Context) templates.User {
|
||||
return templates.User{Theme: "tokyo-night"}
|
||||
}
|
||||
|
||||
// docsNavCookie is the browser-saved collapse state of the docs sidebar
|
||||
// (set by web/src/docs.ts on every toggle). Strictly-necessary UI state:
|
||||
// first-party, no identifiers, lets the server render the sidebar in the
|
||||
// visitor's chosen state so there is no post-paint jump.
|
||||
const docsNavCookie = "docs_nav"
|
||||
|
||||
// docsNavOverrides reads the saved collapse state from the cookie.
|
||||
// Keys are the sidebar's data-key values ("Section", "Section / Group");
|
||||
// values are the collapsed flag. Malformed cookies are ignored — the
|
||||
// server-rendered defaults win.
|
||||
func docsNavOverrides(c *echo.Context) map[string]bool {
|
||||
cookie, err := c.Cookie(docsNavCookie)
|
||||
if err != nil || cookie.Value == "" {
|
||||
return nil
|
||||
}
|
||||
raw, err := url.QueryUnescape(cookie.Value)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var overrides map[string]bool
|
||||
if err := json.Unmarshal([]byte(raw), &overrides); err != nil {
|
||||
return nil
|
||||
}
|
||||
return overrides
|
||||
}
|
||||
|
||||
// renderDocPage renders a document inside the docs layout.
|
||||
func (h *HTTPHandler) renderDocPage(c *echo.Context, doc *templates.Document) error {
|
||||
currentPath := c.Request().URL.Path
|
||||
nav := h.docs.BuildNavigation(currentPath)
|
||||
ApplyNavOverrides(nav, docsNavOverrides(c))
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := templates.DocsLayout(*nav, *doc, h.currentUser(c), currentPath).Render(c.Request().Context(), &buf); err != nil {
|
||||
|
||||
+20
-51
@@ -178,8 +178,8 @@ func docURL(docPath string) string {
|
||||
|
||||
// BuildNavigation creates the category → subcategory navigation from the
|
||||
// docs filesystem. The section and group containing currentPath render
|
||||
// expanded; everything else starts collapsed (with Getting Started as the
|
||||
// fallback when nothing matches).
|
||||
// expanded; everything else starts collapsed. When nothing matches (docs
|
||||
// home, 404), the sidebar renders fully closed — headers only.
|
||||
func (h *DocsHandler) BuildNavigation(currentPath string) *templates.Navigation {
|
||||
docs, err := h.ListDocuments()
|
||||
if err != nil {
|
||||
@@ -198,16 +198,17 @@ func (h *DocsHandler) BuildNavigation(currentPath string) *templates.Navigation
|
||||
}
|
||||
|
||||
type groupBuild struct {
|
||||
items []templates.NavItem
|
||||
urls []string
|
||||
overview bool // has a pinned index page
|
||||
items []templates.NavItem
|
||||
urls []string
|
||||
// overview: the group leads with its "#"Overview landing doc (pinned
|
||||
// subdirectory index), so it sorts first.
|
||||
overview bool
|
||||
}
|
||||
type sectionBuild struct {
|
||||
items []templates.NavItem
|
||||
urls []string
|
||||
pinned map[string]bool // landing pages lead their section
|
||||
groups map[string]*groupBuild
|
||||
overview bool
|
||||
items []templates.NavItem
|
||||
urls []string
|
||||
pinned map[string]bool // landing pages lead their section
|
||||
groups map[string]*groupBuild
|
||||
}
|
||||
|
||||
builds := make(map[string]*sectionBuild, len(sectionOrder))
|
||||
@@ -231,10 +232,6 @@ func (h *DocsHandler) BuildNavigation(currentPath string) *templates.Navigation
|
||||
if place.pin < 0 {
|
||||
build.pinned[item.URL] = true
|
||||
}
|
||||
if place.pin == -100 {
|
||||
// Only the root Overview forces its section open.
|
||||
build.overview = true
|
||||
}
|
||||
} else {
|
||||
group, ok := build.groups[place.group]
|
||||
if !ok {
|
||||
@@ -301,52 +298,24 @@ func (h *DocsHandler) BuildNavigation(currentPath string) *templates.Navigation
|
||||
}
|
||||
}
|
||||
}
|
||||
if build.overview {
|
||||
section.Collapsed = false
|
||||
}
|
||||
nav.Sections = append(nav.Sections, section)
|
||||
}
|
||||
|
||||
// Nothing matches the current page (404, search landing): open the
|
||||
// first section so the sidebar doesn't render fully closed.
|
||||
anyOpen := false
|
||||
for _, s := range nav.Sections {
|
||||
if !s.Collapsed {
|
||||
anyOpen = true
|
||||
break
|
||||
// No default expansion when nothing matches the current page (404,
|
||||
// search landing): the sidebar renders fully closed. Same for the docs
|
||||
// HOME: although the root overview doc lives there (its contains-URL
|
||||
// rule would open "Getting Started"), the spec is "home = all closed,
|
||||
// headers only" — the standard docs-site behavior.
|
||||
if strings.TrimSuffix(currentPath, "/") == "/docs" {
|
||||
for si := range nav.Sections {
|
||||
nav.Sections[si].Collapsed = true
|
||||
}
|
||||
}
|
||||
if !anyOpen && len(nav.Sections) > 0 {
|
||||
nav.Sections[0].Collapsed = false
|
||||
return nav
|
||||
}
|
||||
|
||||
return nav
|
||||
}
|
||||
|
||||
// ApplyNavOverrides merges the browser-saved collapse state (docs_nav
|
||||
// cookie) into the nav so the sidebar first-paints in the visitor's chosen
|
||||
// state — no post-paint JS correction, no jumping. Keys mirror the
|
||||
// data-key attributes DocsSidebar renders: section titles, and
|
||||
// "Section / Group" for groups. Entries for unknown keys (renamed or
|
||||
// removed sections) are ignored; keys absent from the map keep the
|
||||
// path-derived default.
|
||||
func ApplyNavOverrides(nav *templates.Navigation, overrides map[string]bool) {
|
||||
if len(overrides) == 0 {
|
||||
return
|
||||
}
|
||||
for si := range nav.Sections {
|
||||
s := &nav.Sections[si]
|
||||
if v, ok := overrides[s.Title]; ok {
|
||||
s.Collapsed = v
|
||||
}
|
||||
for gi := range s.Groups {
|
||||
if v, ok := overrides[s.Title+" / "+s.Groups[gi].Title]; ok {
|
||||
s.Groups[gi].Collapsed = v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// containsURL reports whether currentPath is one of the given doc URLs.
|
||||
func containsURL(currentPath string, urls []string) bool {
|
||||
for _, u := range urls {
|
||||
|
||||
@@ -127,8 +127,8 @@ func TestBuildNavigation(t *testing.T) {
|
||||
}
|
||||
|
||||
getting := nav.Sections[0]
|
||||
if getting.Collapsed {
|
||||
t.Error("Getting Started should be expanded (root overview pinned)")
|
||||
if !getting.Collapsed {
|
||||
t.Error("Getting Started should be collapsed: only sections containing the current page open")
|
||||
}
|
||||
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)
|
||||
@@ -196,10 +196,7 @@ func TestBuildNavigationNothingCurrent(t *testing.T) {
|
||||
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")
|
||||
if open != 0 {
|
||||
t.Errorf("expected zero sections open when nothing matches (docs home / 404 renders fully closed), got %d", open)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user