Two structural changes, one owner-approved plan: - Move the admin guides out of docs/user/ into their own docs/admin/ area — admin-guide.md becomes the Administration section landing page (pinned first), and the 32-line admin-library-management.md stub becomes admin/libraries.md, which the admin-docs build-out rebuilds into the real Libraries page next. Retired URLs 301 via a new docRedirects map in the docs HTTP handler (checked before .md canonicalization so even .md spellings one-hop to the new clean URL); the two inbound links (docs index, user-guide front door) updated. - Sidebar direct items now sort by usabilityOrder — an explicit, per-section display order — instead of alphabetically: User Guide mirrors the 'Where to go' table on user-guide.md, Devices orders Android → KOReader → Kobo (alphabetically Kobo used to sort ahead of KOReader), and Administration pre-lists its planned pages so each lands in place as the admin build writes it. Unlisted docs fall back to alphabetical after the listed ones. Display-order only by construction: the URL-driven expand/highlight machinery keys on containsURL membership and never sees sort order — nav tests updated to pin the new orders plus a real-tree test (TestBuildNavigationUsabilityOrder) guarding all three sections.
404 lines
13 KiB
Go
404 lines
13 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: "Administration", Icon: "shield"},
|
|
{Title: "Devices", Icon: "device"},
|
|
{Title: "API Reference", Icon: "list"},
|
|
{Title: "Developer", Icon: "gear"},
|
|
{Title: "Operations", Icon: "database"},
|
|
{Title: "Contributing", Icon: "heart"},
|
|
}
|
|
|
|
// usabilityOrder pins an explicit, usability-first display order for a
|
|
// section's direct nav items, overriding the alphabetical fallback (owner
|
|
// request 2026-11-02: order the menu by how people work, not by letter —
|
|
// the User Guide list mirrors the "Where to go" table on user-guide.md).
|
|
// Values are doc paths without the .md suffix; anything in the section not
|
|
// listed sorts alphabetically AFTER the listed items. Display-only: the
|
|
// open/highlight machinery keys on URL membership (containsURL) and is
|
|
// order-blind, so URL-driven expansion is unaffected. Administration lists
|
|
// its planned pages up front so each lands in place as it is written.
|
|
var usabilityOrder = map[string][]string{
|
|
"User Guide": {
|
|
"user/dashboard",
|
|
"user/library-browsing",
|
|
"user/collections",
|
|
"user/searching-and-filtering",
|
|
"user/podcasts",
|
|
"user/reader-guide",
|
|
"user/themes",
|
|
"user/profile-guide",
|
|
"user/sync-guide",
|
|
"user/calibre-integration",
|
|
},
|
|
"Devices": {
|
|
"user/devices/android-app",
|
|
"user/devices/koreader-setup",
|
|
"user/devices/kobo-setup",
|
|
},
|
|
"Administration": {
|
|
"admin/admin-guide",
|
|
"admin/dashboard",
|
|
"admin/libraries",
|
|
"admin/library-processing-issues",
|
|
"admin/archived-items",
|
|
"admin/podcasts",
|
|
"admin/hash-conflicts",
|
|
"admin/users",
|
|
"admin/settings",
|
|
"admin/scanning",
|
|
},
|
|
}
|
|
|
|
// 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/searching-and-filtering.md": "Searching and Filtering",
|
|
"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}
|
|
}
|
|
return navPlacement{section: "User Guide"}
|
|
|
|
case strings.HasPrefix(docPath, "admin/"):
|
|
// Admin docs live in their own area (docs/admin/); the guide is
|
|
// the section landing page.
|
|
if docPath == "admin/admin-guide.md" {
|
|
return navPlacement{section: "Administration", pin: -50}
|
|
}
|
|
return navPlacement{section: "Administration"}
|
|
|
|
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] = §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
|
|
}
|
|
} 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 usability order
|
|
// (see usabilityOrder), then alphabetical among anything unlisted.
|
|
order := usabilityOrder[spec.Title]
|
|
rankOf := func(url string) int {
|
|
for i, p := range order {
|
|
if docURL(p) == url {
|
|
return i
|
|
}
|
|
}
|
|
return len(order)
|
|
}
|
|
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
|
|
}
|
|
ri, rj := rankOf(items[i].URL), rankOf(items[j].URL)
|
|
if ri != rj {
|
|
return ri < rj
|
|
}
|
|
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
|
|
}
|
|
|
|
// 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 current == normalizeDocURL(u) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|