Files
bookhoard/internal/docs/http_handler.go
T
John O'Keefe 73e8cdef4b docs(ui): drop sidebar saved-state — server defaults already ARE the standard docs behavior
Simplification per spec: the menu tracks state through the URL alone.
Land on /docs: fully closed, headers only (an explicit home rule now
overrides the contains-URL rule, since the root overview doc lives in
Getting Started and would otherwise auto-open it). Land on any doc page:
its containing section + group render open at first paint, current link
bold — no JS, no jump, arrows consistent. Navigate elsewhere: old
closes, new opens.

Deleted: docs_nav cookie const/parse, ApplyNavOverrides, cookie write +
collect/persist helpers in web/src/docs.ts, data-key attributes in the
sidebar templ, and the first-section-open fallback (docs home used to
force-open the first section — spec'd as closed).

toggleCollapsible remains for in-page peeking; purely ephemeral.
The [data-collapsed=true] CSS from the previous round stays — it is what
makes the server-rendered state actually hide sections at paint (the
original bug was that this CSS never existed).

Tests: TestBuildNavigationNothingCurrent now asserts zero sections open
on unmatched paths; TestBuildNavigation asserts Getting Started closed
on a device page (no site-wide pin). make test green; 146 vitests; live
verified: home 0 expanded, doc pages exactly 2 (section+group).
2026-10-24 21:17:55 -04:00

215 lines
6.1 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 {
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
docPath := c.Param("*")
// 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)
}