Files
bookhoard/internal/docs/navigation.go
T
John O'Keefe 27f42bf703 docs nav: promote Devices and Administration out of User Guide
Follows the developer/api → API Reference carve-out: placement-only, no
file paths or URLs change. placeDoc routes user/devices/* to a new
top-level "Devices" section (icon: device) and user/admin-* to a new
top-level "Administration" section (icon: shield); User Guide keeps its
direct guides with no groups. Sidebar order: Getting Started, User
Guide, Devices, Administration, API Reference, Developer, Operations,
Contributing.

Verified live: on kobo-setup the Devices section opens with the three
device guides; on admin-guide Administration opens with both admin
guides; on user/dashboard User Guide opens as before. Tests
(TestPlaceDoc, TestBuildNavigation) updated to pin the new placement.
2026-10-25 21:10:53 -04:00

332 lines
10 KiB
Go

package docs
import (
"sort"
"strings"
"bookhoard/templates"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
// sectionOrder defines the sidebar's top-level categories, top to bottom.
// The docs directory layout IS the navigation structure: top-level
// directories map to sections, nested directories map to subcategory
// groups (see placeDoc for the routing rules).
var sectionOrder = []struct {
Title string
Icon string
}{
{Title: "Getting Started", Icon: "book-open"},
{Title: "User Guide", Icon: "book"},
{Title: "Devices", Icon: "device"},
{Title: "Administration", Icon: "shield"},
{Title: "API Reference", Icon: "list"},
{Title: "Developer", Icon: "gear"},
{Title: "Operations", Icon: "database"},
{Title: "Contributing", Icon: "heart"},
}
// groupTitleOverrides fixes casing that naive title-casing gets wrong.
var groupTitleOverrides = map[string]string{
"admin": "Admin",
"api": "API",
"kobo": "Kobo",
"koreader": "KOReader",
"opds": "OPDS",
"websocket": "WebSocket",
}
// docTitleOverrides pins exact nav titles for files whose filename reads
// poorly (or which serve as a landing page). Keyed by doc path; "index.md"
// covers every subdirectory index page.
var docTitleOverrides = map[string]string{
"index.md": "Overview",
"contract_pins.md": "Contract Pins",
"FRONTEND_INTEGRATION.md": "Frontend Integration",
"user/user-guide.md": "User Guide",
"user/sync-guide.md": "Sync Guide",
"user/devices/kobo-setup.md": "Kobo Setup",
"user/devices/koreader-setup.md": "KOReader Setup",
"user/devices/android-app.md": "Android App",
"developer/development.md": "Developer Portal",
"developer/api/api-reference.md": "Overview",
"operations/operations.md": "Operations Guide",
"operations/troubleshooting.md": "Troubleshooting",
"contributing/contributing.md": "Contributing",
"contributing/development.md": "Development Guide",
"development/calibre-opf-implementation.md": "Calibre OPF Implementation",
}
// navPlacement routes one document into (section, group). An empty group
// means the doc sits directly in the section; a negative pin forces the doc
// to the front of its list (landing pages like the root Overview).
type navPlacement struct {
section string
group string
pin int
}
// placeDoc maps a doc path to its sidebar location.
func placeDoc(docPath string) navPlacement {
// Root landing page.
if docPath == "index.md" {
return navPlacement{section: "Getting Started", pin: -100}
}
// Stray root-level dev notes → Developer → Reference group.
if !strings.Contains(docPath, "/") {
return navPlacement{section: "Developer", group: "Reference"}
}
if strings.HasPrefix(docPath, "development/") {
return navPlacement{section: "Developer", group: "Reference"}
}
// Subdirectory index pages pin to the front of their group as "Overview".
pin := 0
if strings.HasSuffix(docPath, "/index.md") {
pin = -50
}
switch {
case strings.HasPrefix(docPath, "user/"):
rest := strings.TrimPrefix(docPath, "user/")
if dir, _, ok := strings.Cut(rest, "/"); ok {
// Device setup guides get their own top-level section (the
// directory stays where it is — placement is nav-only, same
// carve-out as developer/api → API Reference).
if dir == "devices" {
return navPlacement{section: "Devices", pin: pin}
}
return navPlacement{section: "User Guide", group: titleCase(dir), pin: pin}
}
if rest == "user-guide.md" {
return navPlacement{section: "User Guide", pin: -50}
}
// Admin-facing guides are their own top-level section too.
if strings.HasPrefix(rest, "admin-") {
return navPlacement{section: "Administration"}
}
return navPlacement{section: "User Guide"}
case strings.HasPrefix(docPath, "developer/api/"):
rest := strings.TrimPrefix(docPath, "developer/api/")
if dir, _, ok := strings.Cut(rest, "/"); ok {
return navPlacement{section: "API Reference", group: groupTitle(dir), pin: pin}
}
if rest == "api-reference.md" {
return navPlacement{section: "API Reference", pin: -50}
}
return navPlacement{section: "API Reference"}
case strings.HasPrefix(docPath, "developer/"):
rest := strings.TrimPrefix(docPath, "developer/")
if dir, _, ok := strings.Cut(rest, "/"); ok {
return navPlacement{section: "Developer", group: groupTitle(dir), pin: pin}
}
if rest == "development.md" {
return navPlacement{section: "Developer", pin: -50}
}
return navPlacement{section: "Developer"}
case strings.HasPrefix(docPath, "operations/"):
if docPath == "operations/operations.md" {
return navPlacement{section: "Operations", pin: -50}
}
return navPlacement{section: "Operations"}
case strings.HasPrefix(docPath, "contributing/"):
if docPath == "contributing/contributing.md" {
return navPlacement{section: "Contributing", pin: -50}
}
return navPlacement{section: "Contributing"}
default:
return navPlacement{section: "Developer", group: "Reference"}
}
}
// groupTitle turns a directory name into a subcategory title.
func groupTitle(dir string) string {
if title, ok := groupTitleOverrides[dir]; ok {
return title
}
return titleCase(dir)
}
// titleCase converts "book-matching"/"media_items" to "Book Matching".
func titleCase(s string) string {
s = strings.NewReplacer("-", " ", "_", " ").Replace(s)
return cases.Title(language.English).String(s)
}
// docTitle returns the nav label for a document.
func docTitle(docPath string) string {
if title, ok := docTitleOverrides[docPath]; ok {
return title
}
filename := docPath[strings.LastIndex(docPath, "/")+1:]
if filename == "index.md" {
return "Overview"
}
return titleCase(strings.TrimSuffix(filename, ".md"))
}
// docURL converts a doc path to its site URL. The root index lives at /docs.
func docURL(docPath string) string {
if docPath == "index.md" {
return "/docs"
}
return "/docs/" + strings.TrimSuffix(docPath, ".md")
}
// BuildNavigation creates the category → subcategory navigation from the
// docs filesystem. The section and group containing currentPath render
// 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 {
// Return minimal navigation on error
return &templates.Navigation{
Sections: []templates.NavSection{
{
Title: "Getting Started",
Icon: "book-open",
Items: []templates.NavItem{
{Title: "Overview", URL: "/docs"},
},
},
},
}
}
type groupBuild struct {
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
}
builds := make(map[string]*sectionBuild, len(sectionOrder))
for _, spec := range sectionOrder {
builds[spec.Title] = &sectionBuild{groups: make(map[string]*groupBuild), pinned: make(map[string]bool)}
}
for _, doc := range docs {
// ListDocuments returns paths without the .md extension; the
// routing/title helpers key on full doc paths.
docPath := doc + ".md"
place := placeDoc(docPath)
build, ok := builds[place.section]
if !ok {
continue
}
item := templates.NavItem{Title: docTitle(docPath), URL: docURL(docPath)}
if place.group == "" {
build.items = append(build.items, item)
build.urls = append(build.urls, item.URL)
if place.pin < 0 {
build.pinned[item.URL] = true
}
} else {
group, ok := build.groups[place.group]
if !ok {
group = &groupBuild{}
build.groups[place.group] = group
}
group.items = append(group.items, item)
group.urls = append(group.urls, item.URL)
if place.pin < 0 {
group.overview = true
}
}
}
nav := &templates.Navigation{}
for _, spec := range sectionOrder {
build := builds[spec.Title]
section := templates.NavSection{Title: spec.Title, Icon: spec.Icon}
// Direct items: pinned landing pages lead, then alphabetical
// (case-insensitive — KOReader must not sort before Kobo).
items := append([]templates.NavItem(nil), build.items...)
sort.SliceStable(items, func(i, j int) bool {
pi, pj := build.pinned[items[i].URL], build.pinned[items[j].URL]
if pi != pj {
return pi
}
return strings.ToLower(items[i].Title) < strings.ToLower(items[j].Title)
})
section.Items = items
// Groups: alphabetical, each group's items alphabetical with the
// group's "Overview" (index page) first.
groupTitles := make([]string, 0, len(build.groups))
for title := range build.groups {
groupTitles = append(groupTitles, title)
}
sort.Strings(groupTitles)
for _, title := range groupTitles {
group := build.groups[title]
gItems := append([]templates.NavItem(nil), group.items...)
sort.SliceStable(gItems, func(i, j int) bool {
return strings.ToLower(gItems[i].Title) < strings.ToLower(gItems[j].Title)
})
if group.overview {
sort.SliceStable(gItems, func(i, j int) bool { return gItems[i].Title == "Overview" })
}
section.Groups = append(section.Groups, templates.NavGroup{
Title: title,
Items: gItems,
Collapsed: !containsURL(currentPath, group.urls),
})
}
if len(section.Items) == 0 && len(section.Groups) == 0 {
continue
}
section.Collapsed = !containsURL(currentPath, build.urls)
if section.Collapsed {
for _, g := range section.Groups {
if !g.Collapsed {
section.Collapsed = false
break
}
}
}
nav.Sections = append(nav.Sections, section)
}
// 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
}
return nav
}
return nav
}
// containsURL reports whether currentPath is one of the given doc URLs.
func containsURL(currentPath string, urls []string) bool {
for _, u := range urls {
if currentPath == u {
return true
}
}
return false
}