Files
bookhoard/internal/docs/http_handler.go
T
John O'Keefe a1ac5b1541 docs(ui): canonicalize .md URLs — sidebar now opens when arriving from overview links
Root cause of 'clicked from overview, bold but menus closed': cross-page
markdown links point at area/file.md; the handler happily loads .md
URLs, but the sidebar keys on the URL path — contains-URL expansion and
the active-link styling compare against extension-less nav URLs, so a
.md path rendered the page with its menu closed and the current item
unhighlighted. (My curl checks had used clean URLs, which is why it
worked in testing but not from the overview page.)

Fix: ShowDocumentation 301-redirects .md paths to the clean canonical
form (query preserved; browsers re-attach fragments), and
renderDocPage normalizes the path for nav expansion/grouping as a belt-
and-braces. Live-verified: .../get_ratings.md -> 301 ->
clean page with both containing menus open and the get_ratings link
carrying the active class.
2026-10-24 21:54:06 -04:00

232 lines
7.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
}
// 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("*")
// 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)
}