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: "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 { // Subdirectories become subcategories: device setup gets its // own group, the admin-facing guides another. if dir == "devices" { return navPlacement{section: "User Guide", group: "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} } if strings.HasPrefix(rest, "admin-") { return navPlacement{section: "User Guide", group: "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 (with Getting Started as the // fallback when nothing matches). 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 bool // has a pinned index page } type sectionBuild struct { items []templates.NavItem urls []string pinned map[string]bool // landing pages lead their section groups map[string]*groupBuild overview bool } builds := make(map[string]*sectionBuild, len(sectionOrder)) for _, spec := range sectionOrder { builds[spec.Title] = §ionBuild{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 } if place.pin == -100 { // Only the root Overview forces its section open. build.overview = 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 } } } 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 } } if !anyOpen && len(nav.Sections) > 0 { nav.Sections[0].Collapsed = false } 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 }