Files
bookhoard/internal/docs/http_handler.go
T
John O'Keefe 393a00e8dd docs(nav): Administration section lives in docs/admin/; sidebar ordered by usability
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.
2026-11-02 13:38:01 -05:00

255 lines
8.0 KiB
Go

package docs
import (
"bytes"
"fmt"
"net/http"
"strings"
"bookhoard/internal/database"
"bookhoard/templates"
"github.com/google/uuid"
"github.com/labstack/echo/v5"
)
// HTTPHandler wraps the docs handler for Echo framework
type HTTPHandler struct {
docs *DocsHandler
}
// docRedirects maps retired doc paths (as passed to ShowDocumentation —
// no leading /docs, .md suffix optional) to their current paths. Docs move
// rarely; when one moves, add it here so old links 301 to the new home
// instead of 404ing. Checked BEFORE .md canonicalization so a moved page's
// .md spelling also one-hops straight to the new clean URL.
// 2026-11-02: admin guides moved from docs/user/ to docs/admin/, and
// admin-library-management.md became admin/libraries.md (it will be
// rebuilt as the Libraries page).
var docRedirects = map[string]string{
"user/admin-guide": "admin/admin-guide",
"user/admin-library-management": "admin/libraries",
}
// NewHTTPHandler creates a new docs HTTP handler
func NewHTTPHandler(docsPath string) *HTTPHandler {
return &HTTPHandler{
docs: NewDocsHandler(docsPath),
}
}
// 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 {
// .md suffixes are canonicalized to clean URLs before rendering, but
// normalize here too so the nav expansion + active-link styling can
// never see an extension the nav URLs don't carry.
currentPath := strings.TrimSuffix(c.Request().URL.Path, ".md")
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
docPath := c.Param("*")
// Retired doc paths 301 to their current homes (see docRedirects)
// before any load attempt.
if target, moved := docRedirects[strings.TrimSuffix(docPath, ".md")]; moved {
loc := "/docs/" + target
if c.Request().URL.RawQuery != "" {
loc += "?" + c.Request().URL.RawQuery
}
return c.Redirect(http.StatusMovedPermanently, loc)
}
// Canonicalize ".md" URLs to the clean form. Markdown links across the
// docs tree point at `area/file.md`; the handler loads them fine, but
// the sidebar logic keys on the URL path (contains-URL expansion, the
// active-link styling) whose nav URLs are extension-less — and a raw
// .md path would render the page with its menu closed and the current
// item unhighlighted. 301 keeps the browser URL canonical.
if strings.HasSuffix(docPath, ".md") {
target := strings.TrimSuffix("/docs/"+docPath, ".md")
if c.Request().URL.RawQuery != "" {
target += "?" + c.Request().URL.RawQuery
}
return c.Redirect(http.StatusMovedPermanently, target)
}
// Load the document
doc, err := h.docs.LoadDocument(docPath)
if err != nil {
// Try to load as API endpoint
if strings.HasPrefix(docPath, "api/") {
return h.ShowAPIEndpoint(c, strings.TrimPrefix(docPath, "api/"))
}
return h.notFoundPage(c, "Document Not Found", err.Error())
}
return h.renderDocPage(c, doc)
}
// 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 {
return h.notFoundPage(c, "Documentation Not Found", err.Error())
}
return h.renderDocPage(c, doc)
}
// ShowAPIEndpoint shows a specific API endpoint documentation
func (h *HTTPHandler) ShowAPIEndpoint(c *echo.Context, endpointPath string) error {
// Load the markdown documentation for this endpoint
docPath := "api/" + endpointPath + ".md"
doc, err := h.docs.LoadDocument(docPath)
if err != nil {
return h.notFoundPage(c, "API Endpoint Not Found", "No documentation exists for this endpoint.")
}
return h.renderDocPage(c, doc)
}
// 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
}
docs, err := h.docs.ListDocuments()
if err != nil {
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())
}
// Search searches through documentation (JSON API).
func (h *HTTPHandler) Search(c *echo.Context) error {
query := c.QueryParam("q")
if query == "" {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "query parameter 'q' is required",
})
}
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,
})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"results": jsonResults,
"count": len(jsonResults),
})
}
// SearchResult represents a search result
type SearchResult struct {
Title string
URL string
Type string
}
// ServeSearchIndex serves the Lunr.js search index
func (h *HTTPHandler) ServeSearchIndex(c *echo.Context) error {
index, err := h.docs.GenerateSearchIndex()
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": "failed to generate search index",
})
}
return c.JSON(http.StatusOK, index)
}