docs(ui): cookie-rendered sidebar state, data-collapsed CSS, working overview links + link-checker gate
Three problems, one root cause each: 1. Sidebar jump: the server rendered its own default (current section open, rest collapsed) and restoreDocsNav corrected it after paint — open-then-close. Fix: persistence moved server-side. toggleCollapsible now writes a docs_nav cookie (strictly-necessary UI state, no identifiers, SameSite=Lax); ApplyNavOverrides merges it into the nav before render, so first paint IS the visitor's state. The post-paint restore is deleted. 2. Arrows out of sync / double-click: two visibility mechanisms disagreed. There was NO data-collapsed CSS at all — collapsed sections were only ever hidden by JS after paint, and the arrow rotation came from a different (server) state. Now: style.css hides on [data-collapsed=true] (single source of truth, server-rendered), applyCollapsed sets only the attribute, arrows rotate from the same merged value. Live check: 37/37 collapsibles arrow-consistent. 3. api-reference.md links: all 20+ quick links were directory-style (authentication/ etc.) that 404 on the docs site, plus stale targets (books/, ../collections-api.md, websocket 'coming soon'). Rewritten to real per-file links — all 9 unique targets return 200 live. Same rot fixed in admin/hash-conflicts.md, development.md, android-app.md, create_media_item.md, koreader-protocol.md; docs/index.md redirected from the deleted collections-api monolith (stale 530-line duplicate of the gated api/collections docs — deleted, per the WO2 doctrine). New gate: TestDocsRelativeLinks walks docs/developer/** and fails make test on any relative link that is directory-style or unresolvable — overview links can never silently rot again.
This commit is contained in:
1 parent
f1a6de7cd7
commit
a8f8c4def9
17 files changed
+241
-674
No files matched your search
@@ -2,8 +2,10 @@ package docs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"bookhoard/internal/database"
|
||||
@@ -46,10 +48,37 @@ 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 {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package docs
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestDocsRelativeLinks keeps the developer docs tree internally navigable:
|
||||
// every relative markdown link must point at a file that exists. The
|
||||
// api-reference overview once shipped 20+ directory-style links
|
||||
// (authentication/ etc.) that 404'd — this gate makes that class of rot
|
||||
// fail make test. (docs/user is excluded: end-user prose has its own pass.)
|
||||
//
|
||||
// Rules:
|
||||
// - http(s):, mailto:, and pure #anchors are skipped
|
||||
// - every other target must end in .md and resolve (relative to the
|
||||
// linking file) to an existing file inside the tree
|
||||
// - directory-style targets ("area/") are the exact bug this test exists
|
||||
// for and always fail
|
||||
var docsLinkRe = regexp.MustCompile(`\[[^\]]*\]\(([^)\s]+)\)`)
|
||||
|
||||
func TestDocsRelativeLinks(t *testing.T) {
|
||||
// Walk developer docs; escape-check against the whole docs tree —
|
||||
// cross-area links into docs/user are legitimate (same site).
|
||||
devRoot, err := filepath.Abs("../../docs/developer")
|
||||
if err != nil {
|
||||
t.Fatalf("abs developer root: %v", err)
|
||||
}
|
||||
treeRoot, err := filepath.Abs("../../docs")
|
||||
if err != nil {
|
||||
t.Fatalf("abs docs root: %v", err)
|
||||
}
|
||||
err = filepath.WalkDir(devRoot, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() || !strings.HasSuffix(path, ".md") {
|
||||
return err
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, _ := filepath.Rel(docsRoot, path)
|
||||
absPath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, m := range docsLinkRe.FindAllStringSubmatch(string(raw), -1) {
|
||||
target := m[1]
|
||||
|
||||
if strings.HasPrefix(target, "http://") ||
|
||||
strings.HasPrefix(target, "https://") ||
|
||||
strings.HasPrefix(target, "mailto:") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(target, "#") {
|
||||
continue // in-page anchor
|
||||
}
|
||||
|
||||
// strip a trailing fragment for file resolution
|
||||
fileTarget := target
|
||||
if i := strings.Index(fileTarget, "#"); i >= 0 {
|
||||
fileTarget = fileTarget[:i]
|
||||
}
|
||||
if fileTarget == "" {
|
||||
continue // pure-anchor form already handled; empty = odd, skip
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(fileTarget, ".md") {
|
||||
t.Errorf("%s: link target %q is not a .md file — directory-style links 404 on the docs site; link the file (e.g. %q)",
|
||||
rel, target, strings.TrimSuffix(fileTarget, "/")+".md")
|
||||
continue
|
||||
}
|
||||
|
||||
resolved := filepath.Clean(filepath.Join(filepath.Dir(absPath), fileTarget))
|
||||
if !strings.HasPrefix(resolved, treeRoot+string(filepath.Separator)) {
|
||||
t.Errorf("%s: link target %q escapes the docs tree", rel, target)
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(resolved); err != nil {
|
||||
t.Errorf("%s: link target %q does not resolve to an existing file (%s)", rel, target, resolved)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("walk docs: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -323,6 +323,30 @@ func (h *DocsHandler) BuildNavigation(currentPath string) *templates.Navigation
|
||||
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 {
|
||||
|
||||
Reference in new issue
Block a user