feat(docs): standalone docs site chrome + category/subcategory navigation
Docs pages no longer embed the app Header (whose hx-post login form silently degraded to a native GET, leaking credentials into the URL — this page never loaded htmx). New docs-owned topbar: search, theme picker, Open App; anonymous visitors get a plain /login link. Optional-auth middleware (hand-rolled — echojwt's ErrorHandler return ends the chain, producing empty 200s) populates the user for signed-in visitors. Navigation rebuilt from the docs directory tree: 6 sections, subcategory groups, server-rendered expansion and active states. Search results are a templ partial (/docs/search/partial); code highlighting is server-side chroma (previously dead hljs CSS); prose is theme-aware via typography DEFAULT vars (prose-invert painted unreadable text under light themes). /docs renders the index directly; standalone 404 without the tailwind CDN. Tests: navigation routing/expansion (internal/docs), render pins asserting no login form/password field/app sidebar ever return on docs pages. lint-rendering: exempt *.test.ts fixtures (jsdom DOM building); docs.ts baseline 5 -> 1.
This commit is contained in:
1 parent
9d0ae2bcc4
commit
84cd6328f4
19 files changed
+2030
-1097
No files matched your search
+293
-208
@@ -10,8 +10,177 @@ import (
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
// BuildNavigation creates the navigation structure from the docs filesystem
|
||||
func (h *DocsHandler) BuildNavigation() *templates.Navigation {
|
||||
// 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
|
||||
@@ -19,6 +188,7 @@ func (h *DocsHandler) BuildNavigation() *templates.Navigation {
|
||||
Sections: []templates.NavSection{
|
||||
{
|
||||
Title: "Getting Started",
|
||||
Icon: "book-open",
|
||||
Items: []templates.NavItem{
|
||||
{Title: "Overview", URL: "/docs"},
|
||||
},
|
||||
@@ -27,223 +197,138 @@ func (h *DocsHandler) BuildNavigation() *templates.Navigation {
|
||||
}
|
||||
}
|
||||
|
||||
// Group documents by category
|
||||
categories := make(map[string][]templates.NavItem)
|
||||
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 {
|
||||
// Skip INDEX.md files in subdirectories (they're section landing pages, not nav items)
|
||||
// Only show root INDEX.md, skip all others like user/INDEX.md, api/INDEX.md, etc.
|
||||
if strings.HasSuffix(doc, "index.md") && doc != "index.md" {
|
||||
// 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 := h.createNavItem(doc)
|
||||
category := h.getCategoryForNav(doc)
|
||||
categories[category] = append(categories[category], item)
|
||||
}
|
||||
|
||||
// Build navigation sections
|
||||
var sections []templates.NavSection
|
||||
|
||||
// Define section order
|
||||
sectionOrder := []string{
|
||||
"Getting Started",
|
||||
"User Guide",
|
||||
"Device Setup",
|
||||
"API Reference",
|
||||
"Contributing",
|
||||
}
|
||||
|
||||
// Add sections in order
|
||||
for _, sectionTitle := range sectionOrder {
|
||||
if items, exists := categories[sectionTitle]; exists {
|
||||
sections = append(sections, templates.NavSection{
|
||||
Title: sectionTitle,
|
||||
Items: items,
|
||||
Collapsed: sectionTitle != "Getting Started", // Expand first section
|
||||
})
|
||||
delete(categories, sectionTitle)
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add any remaining sections
|
||||
for title, items := range categories {
|
||||
sections = append(sections, templates.NavSection{
|
||||
Title: title,
|
||||
Items: items,
|
||||
Collapsed: 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
|
||||
|
||||
return &templates.Navigation{Sections: sections}
|
||||
}
|
||||
|
||||
// createNavItem creates a navigation item from a document path
|
||||
func (h *DocsHandler) createNavItem(docPath string) templates.NavItem {
|
||||
title := h.getDocTitle(docPath)
|
||||
url := "/docs/" + strings.TrimSuffix(docPath, ".md")
|
||||
icon := h.getDocIcon(docPath)
|
||||
|
||||
return templates.NavItem{
|
||||
Title: title,
|
||||
URL: url,
|
||||
Icon: icon,
|
||||
}
|
||||
}
|
||||
|
||||
// getDocTitle extracts a readable title from a document path
|
||||
func (h *DocsHandler) getDocTitle(docPath string) string {
|
||||
// Remove .md extension
|
||||
title := strings.TrimSuffix(docPath, ".md")
|
||||
|
||||
// Split by /
|
||||
parts := strings.Split(title, "/")
|
||||
|
||||
// Get the last part (filename)
|
||||
filename := parts[len(parts)-1]
|
||||
|
||||
// Convert to title case
|
||||
title = cases.Title(language.English).String(strings.ReplaceAll(filename, "-", " "))
|
||||
|
||||
// Handle special cases
|
||||
switch filename {
|
||||
case "index.md":
|
||||
return "Index"
|
||||
case "user/user-guide.md":
|
||||
return "User Guide"
|
||||
case "developer/development.md":
|
||||
return "Developer Portal"
|
||||
case "operations/operations.md":
|
||||
return "Operations Guide"
|
||||
case "contributing/contributing.md":
|
||||
return "Contributing"
|
||||
case "developer/api-reference.md":
|
||||
return "API Reference"
|
||||
case "developer/api/api-reference.md":
|
||||
return "API Reference"
|
||||
case "developer/api/collections-api.md":
|
||||
return "Collections API"
|
||||
case "developer/websocket-api.md":
|
||||
return "WebSocket API"
|
||||
case "operations/troubleshooting.md":
|
||||
return "Troubleshooting"
|
||||
case "user/sync-guide.md":
|
||||
return "Sync Guide"
|
||||
case "user/devices/kobo-setup.md":
|
||||
return "Kobo Setup"
|
||||
case "user/devices/koreader-setup.md":
|
||||
return "KOReader Setup"
|
||||
case "DEVELOPMENT.md":
|
||||
return "Development Guide"
|
||||
}
|
||||
|
||||
return title
|
||||
}
|
||||
|
||||
// getDocIcon returns an appropriate icon for a document
|
||||
func (h *DocsHandler) getDocIcon(docPath string) string {
|
||||
if strings.Contains(docPath, "api") || strings.Contains(docPath, "API") {
|
||||
return "🔌"
|
||||
}
|
||||
if strings.Contains(docPath, "device") {
|
||||
return "📱"
|
||||
}
|
||||
if strings.Contains(docPath, "sync") || strings.Contains(docPath, "SYNC") {
|
||||
return "🔄"
|
||||
}
|
||||
if strings.Contains(docPath, "contributing") || strings.Contains(docPath, "DEVELOPMENT") {
|
||||
return "🛠️"
|
||||
}
|
||||
if strings.Contains(docPath, "index") {
|
||||
return "📚"
|
||||
}
|
||||
if strings.Contains(docPath, "troubleshooting") {
|
||||
return "🔧"
|
||||
}
|
||||
return "📄"
|
||||
}
|
||||
|
||||
// getCategoryForNav determines the navigation category for a document
|
||||
func (h *DocsHandler) getCategoryForNav(docPath string) string {
|
||||
// API documentation
|
||||
if strings.Contains(docPath, "api") || strings.Contains(docPath, "API") {
|
||||
return "API Reference"
|
||||
}
|
||||
|
||||
// Device setup
|
||||
if strings.HasPrefix(docPath, "devices/") {
|
||||
return "Device Setup"
|
||||
}
|
||||
|
||||
// Contributing
|
||||
if strings.HasPrefix(docPath, "contributing/") {
|
||||
return "Contributing"
|
||||
}
|
||||
|
||||
// Core user documentation
|
||||
docName := strings.ToLower(docPath)
|
||||
switch {
|
||||
case strings.Contains(docName, "sync"):
|
||||
return "User Guide"
|
||||
case strings.Contains(docName, "troubleshoot"):
|
||||
return "Getting Started"
|
||||
case strings.Contains(docName, "index"):
|
||||
return "Getting Started"
|
||||
default:
|
||||
return "Getting Started"
|
||||
}
|
||||
}
|
||||
|
||||
// GetAPIEndpoints returns a list of API endpoints for the API documentation
|
||||
func (h *DocsHandler) GetAPIEndpoints() []APIEndpoint {
|
||||
// This would be better auto-generated from Go code comments
|
||||
// For now, return a static list based on your API
|
||||
endpoints := []APIEndpoint{
|
||||
// Authentication
|
||||
{Method: "POST", Path: "/api/auth/register", Category: "Authentication", Title: "Register User"},
|
||||
{Method: "POST", Path: "/api/auth/login", Category: "Authentication", Title: "Login"},
|
||||
{Method: "POST", Path: "/api/auth/refresh", Category: "Authentication", Title: "Refresh Token"},
|
||||
{Method: "POST", Path: "/api/auth/logout", Category: "Authentication", Title: "Logout"},
|
||||
|
||||
// Users
|
||||
{Method: "GET", Path: "/api/users/me", Category: "Users", Title: "Get Current User"},
|
||||
{Method: "PUT", Path: "/api/users/me/profile", Category: "Users", Title: "Update Profile"},
|
||||
{Method: "PUT", Path: "/api/users/me/theme", Category: "Users", Title: "Update Theme"},
|
||||
{Method: "PUT", Path: "/api/users/me/password", Category: "Users", Title: "Change Password"},
|
||||
|
||||
// Libraries
|
||||
{Method: "GET", Path: "/api/libraries/visible", Category: "Libraries", Title: "Get Visible Libraries"},
|
||||
{Method: "GET", Path: "/api/libraries/{id}", Category: "Libraries", Title: "Get Library Details"},
|
||||
{Method: "POST", Path: "/api/libraries", Category: "Libraries", Title: "Create Library"},
|
||||
|
||||
// Media Items
|
||||
{Method: "GET", Path: "/api/media-items", Category: "Media Items", Title: "List Media Items"},
|
||||
{Method: "GET", Path: "/api/media-items/{id}", Category: "Media Items", Title: "Get Media Item"},
|
||||
{Method: "POST", Path: "/api/media-items/search", Category: "Media Items", Title: "Search Media Items"},
|
||||
|
||||
// Progress
|
||||
{Method: "GET", Path: "/api/media-items/{id}/progress", Category: "Progress", Title: "Get Reading Progress"},
|
||||
{Method: "PUT", Path: "/api/media-items/{id}/progress", Category: "Progress", Title: "Update Reading Progress"},
|
||||
|
||||
// Devices
|
||||
{Method: "POST", Path: "/api/devices/register", Category: "Devices", Title: "Register Device"},
|
||||
{Method: "GET", Path: "/api/devices", Category: "Devices", Title: "List User Devices"},
|
||||
{Method: "DELETE", Path: "/api/devices/{id}", Category: "Devices", Title: "Revoke Device"},
|
||||
}
|
||||
|
||||
sort.Slice(endpoints, func(i, j int) bool {
|
||||
if endpoints[i].Category == endpoints[j].Category {
|
||||
return endpoints[i].Title < endpoints[j].Title
|
||||
// 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),
|
||||
})
|
||||
}
|
||||
return endpoints[i].Category < endpoints[j].Category
|
||||
})
|
||||
|
||||
return endpoints
|
||||
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
|
||||
}
|
||||
|
||||
type APIEndpoint struct {
|
||||
Method string
|
||||
Path string
|
||||
Category string
|
||||
Title string
|
||||
// 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
|
||||
}
|
||||
Reference in new issue
Block a user