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:
John O'Keefe committed 2026-10-24 14:31:36 -04:00
1 parent 9d0ae2bcc4
commit 84cd6328f4
19 files changed
+2030 -1097

No files matched your search

+17 -156
View File
@@ -31,7 +31,11 @@ func NewDocsHandler(docsPath string) *DocsHandler {
extension.GFM,
extension.Table,
highlighting.NewHighlighting(
highlighting.WithStyle("github"),
// Chroma renders code blocks with inline styles, so the
// palette ships with the HTML — no client-side highlighter
// needed. The docs are read on dark themes most of the time;
// code blocks keep a dark surface under light themes too.
highlighting.WithStyle("github-dark"),
),
),
goldmark.WithParserOptions(
@@ -83,10 +87,6 @@ func (h *DocsHandler) LoadDocument(docPath string) (*templates.Document, error)
return nil, fmt.Errorf("failed to convert markdown: %w", err)
}
// Debug: Log the raw HTML output from goldmark
htmlOutput := buf.String()
fmt.Printf("DEBUG: Goldmark output length: %d, first 200 chars: %q\n", len(htmlOutput), htmlOutput[:min(200, len(htmlOutput))])
// Extract title from first heading
title := h.extractTitle(content)
@@ -155,46 +155,35 @@ func (h *DocsHandler) generateTOC(content []byte) []templates.TOCItem {
return toc
}
// generateBreadcrumb generates breadcrumb navigation
// generateBreadcrumb generates breadcrumb navigation. Only "Docs" links out:
// directory segments have no landing pages, so they render as plain text
// (URL left empty). The current page is omitted — the h1 below shows it.
func (h *DocsHandler) generateBreadcrumb(docPath string) []templates.BreadcrumbItem {
parts := strings.Split(strings.TrimSuffix(docPath, ".md"), "/")
breadcrumb := []templates.BreadcrumbItem{
{Title: "Docs", URL: "/docs"},
}
path := ""
for i, part := range parts {
if part == "" {
continue
}
path += "/" + part
// Don't add the last part (current page)
if i < len(parts)-1 {
breadcrumb = append(breadcrumb, templates.BreadcrumbItem{
Title: cases.Title(language.English).String(strings.ReplaceAll(part, "-", " ")),
URL: "/docs" + path,
})
if i == len(parts)-1 {
break // current page
}
breadcrumb = append(breadcrumb, templates.BreadcrumbItem{
Title: cases.Title(language.English).String(strings.ReplaceAll(part, "-", " ")),
})
}
return breadcrumb
}
// getCategory determines the category of a document
// getCategory determines the category of a document. It reuses the nav
// routing (placeDoc) so search results and breadcrumbs name sections exactly
// as the sidebar does.
func (h *DocsHandler) getCategory(docPath string) string {
if strings.Contains(docPath, "api") || strings.Contains(docPath, "API") {
return "API Reference"
}
if strings.Contains(docPath, "devices") {
return "Device Setup"
}
if strings.Contains(docPath, "contributing") || strings.Contains(docPath, "DEVELOPMENT") {
return "Contributing"
}
if strings.Contains(docPath, "SYNC") {
return "Sync Guide"
}
return "Documentation"
return placeDoc(docPath).section
}
// slugify converts a string to a URL-safe slug
@@ -234,134 +223,6 @@ func (h *DocsHandler) ListDocuments() ([]string, error) {
return docs, err
}
// GetAPIEndpointData returns endpoint data for the API explorer
func (h *DocsHandler) GetAPIEndpointData(endpointPath string) (*templates.EndpointInfo, error) {
// Map endpoint path to data
// For now, return static examples based on the endpoint path
// In a full implementation, this would parse the markdown files
// Clean the path
endpointPath = strings.TrimPrefix(endpointPath, "api/")
endpointPath = strings.TrimSuffix(endpointPath, ".md")
// Map paths to endpoints
endpoints := map[string]templates.EndpointInfo{
"authentication/register": {
Method: "POST",
Path: "/api/auth/register",
RequestBody: `{
"email": "user@example.com",
"username": "john",
"password": "SecureP@ss123!",
"first_name": "John",
"last_name": "Doe"
}`,
Response: `{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refresh_token": "d4f5g6h7...",
"user": {
"id": "uuid-here",
"email": "user@example.com",
"username": "john",
"role": "user",
"theme": "tokyo-night",
"created_at": "2026-01-31T10:00:00Z"
}
}`,
Description: "Create a new user account",
},
"authentication/login": {
Method: "POST",
Path: "/api/auth/login",
RequestBody: `{
"email": "user@example.com",
"password": "SecureP@ss123!"
}`,
Response: `{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refresh_token": "d4f5g6h7...",
"user": {
"id": "uuid-here",
"email": "user@example.com",
"username": "john",
"role": "user"
}
}`,
Description: "Authenticate with email and password",
},
"authentication/refresh_token": {
Method: "POST",
Path: "/api/auth/refresh",
RequestBody: `{
"refresh_token": "d4f5g6h7..."
}`,
Response: `{
"token": "new-jwt-token",
"refresh_token": "new-refresh-token"
}`,
Description: "Obtain a new JWT token using a refresh token",
},
"authentication/logout": {
Method: "POST",
Path: "/api/auth/logout",
RequestBody: `{}`,
Response: `{}`,
Description: "Invalidate the current JWT token",
},
"users/get_profile": {
Method: "GET",
Path: "/api/users/me",
RequestBody: `{}`,
Response: `{
"id": "uuid",
"email": "user@example.com",
"username": "john",
"first_name": "John",
"last_name": "Doe",
"theme": "tokyo-night",
"role": "user",
"max_devices": 10,
"created_at": "2026-01-31T10:00:00Z"
}`,
Description: "Retrieve the current authenticated user's profile",
},
"users/update_profile": {
Method: "PUT",
Path: "/api/users/me/profile",
RequestBody: `{
"first_name": "John",
"last_name": "Smith"
}`,
Response: `{
"id": "uuid",
"email": "user@example.com",
"username": "john",
"first_name": "John",
"last_name": "Smith",
"theme": "tokyo-night",
"role": "user"
}`,
Description: "Update the current user's profile information",
},
"users/change_password": {
Method: "PUT",
Path: "/api/users/me/password",
RequestBody: `{
"current_password": "oldPassword",
"new_password": "NewSecureP@ss123!"
}`,
Response: `{}`,
Description: "Change the current user's password",
},
}
if endpoint, ok := endpoints[endpointPath]; ok {
return &endpoint, nil
}
return nil, fmt.Errorf("endpoint not found: %s", endpointPath)
}
// SearchDoc represents a document for search indexing
type SearchDoc struct {
ID string `json:"id"`
+118 -329
View File
@@ -25,6 +25,49 @@ func NewHTTPHandler(docsPath string) *HTTPHandler {
}
}
// currentUser builds the template user from the optional auth middleware.
// Docs routes render for both anonymous and signed-in visitors, so an empty
// context user is a normal, expected state here.
func (h *HTTPHandler) currentUser(c *echo.Context) templates.User {
if userID := c.Get("user"); userID != nil {
dbUser := userID.(database.Users)
theme := "tokyo-night"
if dbUser.Theme.Valid {
theme = dbUser.Theme.String
}
return templates.User{
ID: uuid.UUID(dbUser.ID.Bytes).String(),
Username: dbUser.Username,
Email: dbUser.Email,
Role: dbUser.Role,
Theme: theme,
}
}
return templates.User{Theme: "tokyo-night"}
}
// 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)
var buf bytes.Buffer
if err := templates.DocsLayout(*nav, *doc, h.currentUser(c), currentPath).Render(c.Request().Context(), &buf); err != nil {
fmt.Printf("DOCS RENDER ERROR: %v\n", err)
return c.HTML(http.StatusInternalServerError, "Failed to render page: "+err.Error())
}
return c.HTML(http.StatusOK, buf.String())
}
// notFoundPage renders the standalone 404 page (no app chrome, no CDN).
func (h *HTTPHandler) notFoundPage(c *echo.Context, title, message string) error {
var buf bytes.Buffer
if err := templates.DocsNotFound(title, message).Render(c.Request().Context(), &buf); err != nil {
return c.HTML(http.StatusInternalServerError, "Failed to render page")
}
return c.HTML(http.StatusNotFound, buf.String())
}
// ShowDocumentation renders a documentation page
func (h *HTTPHandler) ShowDocumentation(c *echo.Context) error {
// Get the document path from URL parameter
@@ -38,312 +81,96 @@ func (h *HTTPHandler) ShowDocumentation(c *echo.Context) error {
return h.ShowAPIEndpoint(c, strings.TrimPrefix(docPath, "api/"))
}
// Return error page
errorHTML := fmt.Sprintf(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Document Not Found - Bookhoard</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-900 text-gray-100 min-h-screen flex items-center justify-center">
<div class="text-center">
<h1 class="text-2xl font-bold mb-4">Document Not Found</h1>
<p class="mb-4">%s</p>
<a href="/docs" class="text-blue-400 hover:underline">Return to Documentation</a>
</div>
</body>
</html>`, err.Error())
return c.HTML(http.StatusNotFound, errorHTML)
return h.notFoundPage(c, "Document Not Found", err.Error())
}
// Get navigation
nav := h.docs.BuildNavigation()
return h.renderDocPage(c, doc)
}
// Check if user is authenticated via middleware
var user templates.User
if userID := c.Get("user"); userID != nil {
dbUser := userID.(database.Users)
var theme string
if dbUser.Theme.Valid {
theme = dbUser.Theme.String
} else {
theme = "tokyo-night"
}
user = templates.User{
ID: uuid.UUID(dbUser.ID.Bytes).String(),
Username: dbUser.Username,
Email: dbUser.Email,
Role: dbUser.Role,
Theme: theme,
}
} else {
// Create empty user for unauthenticated users
user = templates.User{
ID: "",
Username: "",
Email: "",
Role: "",
Theme: "tokyo-night",
}
}
// Render documentation page
var buf bytes.Buffer
currentPath := c.Request().URL.Path
err = templates.DocsLayout(*nav, *doc, user, currentPath).Render(c.Request().Context(), &buf)
// DocsHome renders the documentation index at /docs.
func (h *HTTPHandler) DocsHome(c *echo.Context) error {
doc, err := h.docs.LoadDocument("index.md")
if err != nil {
fmt.Printf("DOCS RENDER ERROR: %v\n", err)
return c.HTML(http.StatusInternalServerError, "Failed to render page: "+err.Error())
return h.notFoundPage(c, "Documentation Not Found", err.Error())
}
fmt.Printf("DOCS: Successfully rendered %s (%d bytes)\n", docPath, buf.Len())
return c.HTML(http.StatusOK, buf.String())
return h.renderDocPage(c, doc)
}
// ShowAPIEndpoint shows a specific API endpoint documentation
func (h *HTTPHandler) ShowAPIEndpoint(c *echo.Context, endpointPath string) error {
// Check if user is logged in
isLoggedIn := false
if userID := c.Get("user_id"); userID != nil {
isLoggedIn = true
}
// Get endpoint data for the explorer
endpointInfo, err := h.docs.GetAPIEndpointData(endpointPath)
_ = endpointInfo
if err != nil {
// Endpoint not found in explorer data, fall back to old behavior
return h.showLegacyAPIEndpoint(c, endpointPath, isLoggedIn)
}
// Load the markdown documentation for this endpoint
docPath := "api/" + endpointPath + ".md"
doc, err := h.docs.LoadDocument(docPath)
if err != nil {
errorHTML := `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>API Endpoint Not Found - Bookhoard</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-900 text-gray-100 min-h-screen flex items-center justify-center">
<div class="text-center">
<h1 class="text-2xl font-bold mb-4">API Endpoint Not Found</h1>
<a href="/docs/api" class="text-blue-400 hover:underline">Return to API Documentation</a>
</div>
</body>
</html>`
return c.HTML(http.StatusNotFound, errorHTML)
return h.notFoundPage(c, "API Endpoint Not Found", "No documentation exists for this endpoint.")
}
// Get navigation
nav := h.docs.BuildNavigation()
return h.renderDocPage(c, doc)
}
// Check if user is authenticated via middleware
var user templates.User
if userID := c.Get("user"); userID != nil {
dbUser := userID.(database.Users)
var theme string
if dbUser.Theme.Valid {
theme = dbUser.Theme.String
} else {
theme = "tokyo-night"
}
user = templates.User{
ID: uuid.UUID(dbUser.ID.Bytes).String(),
Username: dbUser.Username,
Email: dbUser.Email,
Role: dbUser.Role,
Theme: theme,
}
} else {
// Create empty user for unauthenticated users
user = templates.User{
ID: "",
Username: "",
Email: "",
Role: "",
Theme: "tokyo-night",
}
// searchDocs runs the docs search and returns ranked, capped results.
// Title matches outrank content matches.
func (h *HTTPHandler) searchDocs(query string) []templates.SearchResultItem {
queryLower := strings.ToLower(strings.TrimSpace(query))
if queryLower == "" {
return nil
}
// Render API endpoint page
var buf bytes.Buffer
currentPath := c.Request().URL.Path
err = templates.DocsLayout(*nav, *doc, user, currentPath).Render(c.Request().Context(), &buf)
docs, err := h.docs.ListDocuments()
if err != nil {
return c.HTML(http.StatusInternalServerError, "Failed to render page")
return nil
}
var titleMatches, contentMatches []templates.SearchResultItem
for _, docPath := range docs {
doc, err := h.docs.LoadDocument(docPath)
if err != nil {
continue
}
item := templates.SearchResultItem{
Title: doc.Title,
Category: doc.Category,
URL: "/docs/" + strings.TrimSuffix(doc.SourceFile, ".md"),
}
if doc.SourceFile == "index.md" {
item.URL = "/docs"
}
if strings.Contains(strings.ToLower(doc.Title), queryLower) {
titleMatches = append(titleMatches, item)
continue
}
if strings.Contains(strings.ToLower(doc.Content), queryLower) {
contentMatches = append(contentMatches, item)
}
}
results := append(titleMatches, contentMatches...)
const maxResults = 30
if len(results) > maxResults {
results = results[:maxResults]
}
return results
}
// SearchPartial renders the search results HTML partial for the docs header
// search box. The client swaps this markup in verbatim (rendering ownership:
// the server renders the results, TypeScript only moves them).
func (h *HTTPHandler) SearchPartial(c *echo.Context) error {
results := h.searchDocs(c.QueryParam("q"))
var buf bytes.Buffer
if err := templates.DocsSearchResults(results).Render(c.Request().Context(), &buf); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": "failed to render search results",
})
}
c.Response().Header().Set("Content-Type", "text/html; charset=UTF-8")
return c.HTML(http.StatusOK, buf.String())
}
// showLegacyAPIEndpoint shows API endpoint documentation without explorer data
func (h *HTTPHandler) showLegacyAPIEndpoint(c *echo.Context, endpointPath string, isLoggedIn bool) error {
endpoints := h.docs.GetAPIEndpoints()
// Find the endpoint
var foundEndpoint *APIEndpoint
for _, ep := range endpoints {
if strings.HasSuffix(endpointPath, strings.TrimPrefix(ep.Path, "/api/")) {
foundEndpoint = &ep
break
}
}
if foundEndpoint == nil {
errorHTML := `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>API Endpoint Not Found - Bookhoard</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-900 text-gray-100 min-h-screen flex items-center justify-center">
<div class="text-center">
<h1 class="text-2xl font-bold mb-4">API Endpoint Not Found</h1>
<a href="/docs/api" class="text-blue-400 hover:underline">Return to API Documentation</a>
</div>
</body>
</html>`
return c.HTML(http.StatusNotFound, errorHTML)
}
// Get navigation
nav := h.docs.BuildNavigation()
// Check if user is authenticated via middleware
var user templates.User
currentPath := c.Request().URL.Path
if userID := c.Get("user"); userID != nil {
dbUser := userID.(database.Users)
var theme string
if dbUser.Theme.Valid {
theme = dbUser.Theme.String
} else {
theme = "tokyo-night"
}
user = templates.User{
ID: uuid.UUID(dbUser.ID.Bytes).String(),
Username: dbUser.Username,
Email: dbUser.Email,
Role: dbUser.Role,
Theme: theme,
}
} else {
// Create empty user for unauthenticated users
user = templates.User{
ID: "",
Username: "",
Email: "",
Role: "",
Theme: "tokyo-night",
}
}
// Render API endpoint page
var buf bytes.Buffer
err := templates.DocsLayout(*nav, templates.Document{
Title: foundEndpoint.Title,
Content: fmt.Sprintf("<p>Documentation for <code>%s %s</code></p>", foundEndpoint.Method, foundEndpoint.Path),
TOC: []templates.TOCItem{},
Breadcrumb: []templates.BreadcrumbItem{
{Title: "Docs", URL: "/docs"},
{Title: "API Reference", URL: "/docs/api"},
{Title: foundEndpoint.Category, URL: "/docs/api"},
{Title: foundEndpoint.Title, URL: ""},
},
Category: "API Reference",
SourceFile: "api",
}, user, currentPath).Render(c.Request().Context(), &buf)
if err != nil {
return c.HTML(http.StatusInternalServerError, "Failed to render page")
}
return c.HTML(http.StatusOK, buf.String())
}
// DocsHome redirects to the documentation index
func (h *HTTPHandler) DocsHome(c *echo.Context) error {
return c.Redirect(http.StatusFound, "/docs/index.md")
}
// APIHome shows the API documentation home page
func (h *HTTPHandler) APIHome(c *echo.Context) error {
endpoints := h.docs.GetAPIEndpoints()
nav := h.docs.BuildNavigation()
// Check if user is authenticated via middleware
var user templates.User
currentPath := c.Request().URL.Path
if userID := c.Get("user"); userID != nil {
dbUser := userID.(database.Users)
var theme string
if dbUser.Theme.Valid {
theme = dbUser.Theme.String
} else {
theme = "tokyo-night"
}
user = templates.User{
ID: uuid.UUID(dbUser.ID.Bytes).String(),
Username: dbUser.Username,
Email: dbUser.Email,
Role: dbUser.Role,
Theme: theme,
}
} else {
// Create empty user for unauthenticated users
user = templates.User{
ID: "",
Username: "",
Email: "",
Role: "",
Theme: "tokyo-night",
}
}
// Build API documentation content
content := "<h2>API Endpoints</h2><p>Complete API reference for Bookhoard v1.0.</p><ul>"
for _, ep := range endpoints {
content += fmt.Sprintf(`<li><a href="/docs/api%s"><strong>%s %s</strong></a> - %s</li>`,
strings.TrimPrefix(ep.Path, "/api"),
ep.Method,
ep.Path,
ep.Title,
)
}
content += "</ul>"
apiDoc := &templates.Document{
Title: "API Reference",
Content: content,
TOC: []templates.TOCItem{},
Breadcrumb: []templates.BreadcrumbItem{
{Title: "Docs", URL: "/docs"},
{Title: "Developer", URL: "/docs/developer"},
{Title: "API Reference", URL: ""},
},
Category: "API Reference",
SourceFile: "developer/api-reference.md",
}
var buf bytes.Buffer
err := templates.DocsLayout(*nav, *apiDoc, user, currentPath).Render(c.Request().Context(), &buf)
if err != nil {
return c.HTML(http.StatusInternalServerError, "Failed to render page")
}
return c.HTML(http.StatusOK, buf.String())
}
// Search searches through documentation
// Search searches through documentation (JSON API).
func (h *HTTPHandler) Search(c *echo.Context) error {
query := c.QueryParam("q")
if query == "" {
@@ -352,48 +179,19 @@ func (h *HTTPHandler) Search(c *echo.Context) error {
})
}
// Get all documents
docs, err := h.docs.ListDocuments()
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": "failed to list documents",
results := h.searchDocs(query)
jsonResults := make([]SearchResult, 0, len(results))
for _, r := range results {
jsonResults = append(jsonResults, SearchResult{
Title: r.Title,
URL: r.URL,
Type: r.Category,
})
}
// Simple search implementation
var results []SearchResult
queryLower := strings.ToLower(query)
for _, docPath := range docs {
doc, err := h.docs.LoadDocument(docPath)
if err != nil {
continue
}
// Check if title matches
if strings.Contains(strings.ToLower(doc.Title), queryLower) {
results = append(results, SearchResult{
Title: doc.Title,
URL: "/docs/" + strings.TrimSuffix(doc.SourceFile, ".md"),
Type: "Documentation",
})
continue
}
// Check if content matches (simple substring search)
contentLower := strings.ToLower(doc.Content)
if strings.Contains(contentLower, queryLower) {
results = append(results, SearchResult{
Title: doc.Title,
URL: "/docs/" + strings.TrimSuffix(doc.SourceFile, ".md"),
Type: "Documentation",
})
}
}
return c.JSON(http.StatusOK, map[string]interface{}{
"results": results,
"count": len(results),
"results": jsonResults,
"count": len(jsonResults),
})
}
@@ -404,15 +202,6 @@ type SearchResult struct {
Type string
}
// TryEndpoint executes an API endpoint (for the interactive explorer)
func (h *HTTPHandler) TryEndpoint(c *echo.Context) error {
// This would execute the actual API request
// For now, return a placeholder
return c.JSON(http.StatusOK, map[string]interface{}{
"message": "API explorer not yet implemented",
})
}
// ServeSearchIndex serves the Lunr.js search index
func (h *HTTPHandler) ServeSearchIndex(c *echo.Context) error {
index, err := h.docs.GenerateSearchIndex()
+293 -208
View File
@@ -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] = &sectionBuild{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
}
+205
View File
@@ -0,0 +1,205 @@
package docs
import (
"os"
"path/filepath"
"testing"
"bookhoard/templates"
)
func TestPlaceDoc(t *testing.T) {
tests := []struct {
path string
section string
group string
pinned bool
}{
{"index.md", "Getting Started", "", true},
{"user/user-guide.md", "User Guide", "", true},
{"user/dashboard.md", "User Guide", "", false},
{"user/admin-guide.md", "User Guide", "Administration", false},
{"user/devices/kobo-setup.md", "User Guide", "Devices", false},
{"developer/api/api-reference.md", "API Reference", "", true},
{"developer/api/dashboard.md", "API Reference", "", false},
{"developer/api/authentication/login.md", "API Reference", "Authentication", false},
{"developer/api/saved-filters/index.md", "API Reference", "Saved Filters", true},
{"developer/testing.md", "Developer", "", false},
{"developer/development.md", "Developer", "", true},
{"developer/scanner/audio-metadata.md", "Developer", "Scanner", false},
{"contract_pins.md", "Developer", "Reference", false},
{"FRONTEND_INTEGRATION.md", "Developer", "Reference", false},
{"development/calibre-opf-implementation.md", "Developer", "Reference", false},
{"operations/operations.md", "Operations", "", true},
{"operations/troubleshooting.md", "Operations", "", false},
{"contributing/contributing.md", "Contributing", "", true},
{"contributing/development.md", "Contributing", "", false},
}
for _, tt := range tests {
got := placeDoc(tt.path)
if got.section != tt.section || got.group != tt.group {
t.Errorf("placeDoc(%q) = (%q, %q), want (%q, %q)",
tt.path, got.section, got.group, tt.section, tt.group)
}
if (got.pin < 0) != tt.pinned {
t.Errorf("placeDoc(%q) pin = %d, pinned want %v", tt.path, got.pin, tt.pinned)
}
}
}
func TestDocTitle(t *testing.T) {
tests := map[string]string{
"index.md": "Overview",
"user/user-guide.md": "User Guide",
"user/devices/koreader-setup.md": "KOReader Setup",
"developer/development.md": "Developer Portal",
"developer/api/api-reference.md": "Overview",
"developer/api/users/update_profile.md": "Update Profile",
"developer/api/book-matching/link_book.md": "Link Book",
"operations/troubleshooting.md": "Troubleshooting",
"contract_pins.md": "Contract Pins",
}
for path, want := range tests {
if got := docTitle(path); got != want {
t.Errorf("docTitle(%q) = %q, want %q", path, got, want)
}
}
}
func TestDocURL(t *testing.T) {
if got := docURL("index.md"); got != "/docs" {
t.Errorf("docURL(index.md) = %q, want /docs", got)
}
if got := docURL("user/sync-guide.md"); got != "/docs/user/sync-guide" {
t.Errorf("docURL = %q, want /docs/user/sync-guide", got)
}
}
// buildTestDocs creates a representative docs tree in a temp dir and returns
// a handler over it.
func buildTestDocs(t *testing.T) *DocsHandler {
t.Helper()
dir := t.TempDir()
files := []string{
"index.md",
"user/user-guide.md",
"user/dashboard.md",
"user/admin-guide.md",
"user/devices/kobo-setup.md",
"user/devices/koreader-setup.md",
"developer/api/api-reference.md",
"developer/api/authentication/login.md",
"developer/api/authentication/overview.md",
"developer/api/saved-filters/index.md",
"developer/testing.md",
"operations/troubleshooting.md",
"contributing/development.md",
"contract_pins.md",
}
for _, f := range files {
path := filepath.Join(dir, f)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("# Test\n"), 0o644); err != nil {
t.Fatal(err)
}
}
return &DocsHandler{docsFS: os.DirFS(dir), docsPath: dir}
}
func TestBuildNavigation(t *testing.T) {
h := buildTestDocs(t)
// Current page: a device setup guide → User Guide section and its
// Devices group must render expanded; everything else collapsed.
nav := h.BuildNavigation("/docs/user/devices/kobo-setup")
if len(nav.Sections) == 0 {
t.Fatal("no sections built")
}
wantOrder := []string{"Getting Started", "User Guide", "API Reference", "Developer", "Operations", "Contributing"}
for i, want := range wantOrder {
if nav.Sections[i].Title != want {
t.Fatalf("section %d = %q, want %q", i, nav.Sections[i].Title, want)
}
}
getting := nav.Sections[0]
if getting.Collapsed {
t.Error("Getting Started should be expanded (root overview pinned)")
}
if len(getting.Items) != 1 || getting.Items[0].Title != "Overview" || getting.Items[0].URL != "/docs" {
t.Errorf("Getting Started items = %+v, want single Overview → /docs", getting.Items)
}
userGuide := nav.Sections[1]
if userGuide.Collapsed {
t.Error("User Guide should be expanded: it contains the current page")
}
if len(userGuide.Items) != 2 || userGuide.Items[0].Title != "User Guide" {
t.Errorf("User Guide direct items = %+v, want User Guide pinned first", userGuide.Items)
}
var devices *templates.NavGroup
for i := range userGuide.Groups {
if userGuide.Groups[i].Title == "Devices" {
devices = &userGuide.Groups[i]
}
}
if devices == nil {
t.Fatal("User Guide has no Devices group")
}
if devices.Collapsed {
t.Error("Devices group should be expanded: it contains the current page")
}
if len(devices.Items) != 2 || devices.Items[0].Title != "Kobo Setup" {
t.Errorf("Devices items = %+v, want Kobo Setup + KOReader Setup", devices.Items)
}
// Everything else starts collapsed.
for _, s := range nav.Sections[2:] {
if !s.Collapsed {
t.Errorf("section %q should be collapsed (current page is elsewhere)", s.Title)
}
}
// API Reference: subdirectory groups with the index page as Overview.
api := nav.Sections[2]
if len(api.Groups) != 2 {
t.Fatalf("API Reference groups = %d, want 2 (Authentication, Saved Filters)", len(api.Groups))
}
if api.Groups[0].Title != "Authentication" || len(api.Groups[0].Items) != 2 {
t.Errorf("Authentication group = %+v", api.Groups[0])
}
saved := api.Groups[1]
if saved.Title != "Saved Filters" || len(saved.Items) != 1 || saved.Items[0].Title != "Overview" {
t.Errorf("Saved Filters group = %+v, want single Overview item", saved)
}
if !saved.Collapsed {
t.Error("Saved Filters group should be collapsed on an unrelated page")
}
// Stray root files land in Developer → Reference.
dev := nav.Sections[3]
if len(dev.Groups) != 1 || dev.Groups[0].Title != "Reference" {
t.Errorf("Developer groups = %+v, want Reference", dev.Groups)
}
}
func TestBuildNavigationNothingCurrent(t *testing.T) {
h := buildTestDocs(t)
nav := h.BuildNavigation("/docs/nonexistent")
open := 0
for _, s := range nav.Sections {
if !s.Collapsed {
open++
}
}
if open != 1 {
t.Errorf("expected exactly one section open on a 404 path, got %d", open)
}
if nav.Sections[0].Collapsed {
t.Error("fallback should open the first section")
}
}