Three problems, one root cause each: 1. Sidebar jump: the server rendered its own default (current section open, rest collapsed) and restoreDocsNav corrected it after paint — open-then-close. Fix: persistence moved server-side. toggleCollapsible now writes a docs_nav cookie (strictly-necessary UI state, no identifiers, SameSite=Lax); ApplyNavOverrides merges it into the nav before render, so first paint IS the visitor's state. The post-paint restore is deleted. 2. Arrows out of sync / double-click: two visibility mechanisms disagreed. There was NO data-collapsed CSS at all — collapsed sections were only ever hidden by JS after paint, and the arrow rotation came from a different (server) state. Now: style.css hides on [data-collapsed=true] (single source of truth, server-rendered), applyCollapsed sets only the attribute, arrows rotate from the same merged value. Live check: 37/37 collapsibles arrow-consistent. 3. api-reference.md links: all 20+ quick links were directory-style (authentication/ etc.) that 404 on the docs site, plus stale targets (books/, ../collections-api.md, websocket 'coming soon'). Rewritten to real per-file links — all 9 unique targets return 200 live. Same rot fixed in admin/hash-conflicts.md, development.md, android-app.md, create_media_item.md, koreader-protocol.md; docs/index.md redirected from the deleted collections-api monolith (stale 530-line duplicate of the gated api/collections docs — deleted, per the WO2 doctrine). New gate: TestDocsRelativeLinks walks docs/developer/** and fails make test on any relative link that is directory-style or unresolvable — overview links can never silently rot again.
244 lines
7.1 KiB
Go
244 lines
7.1 KiB
Go
package docs
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"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"}
|
|
}
|
|
|
|
// docsNavCookie is the browser-saved collapse state of the docs sidebar
|
|
// (set by web/src/docs.ts on every toggle). Strictly-necessary UI state:
|
|
// first-party, no identifiers, lets the server render the sidebar in the
|
|
// visitor's chosen state so there is no post-paint jump.
|
|
const docsNavCookie = "docs_nav"
|
|
|
|
// docsNavOverrides reads the saved collapse state from the cookie.
|
|
// Keys are the sidebar's data-key values ("Section", "Section / Group");
|
|
// values are the collapsed flag. Malformed cookies are ignored — the
|
|
// server-rendered defaults win.
|
|
func docsNavOverrides(c *echo.Context) map[string]bool {
|
|
cookie, err := c.Cookie(docsNavCookie)
|
|
if err != nil || cookie.Value == "" {
|
|
return nil
|
|
}
|
|
raw, err := url.QueryUnescape(cookie.Value)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var overrides map[string]bool
|
|
if err := json.Unmarshal([]byte(raw), &overrides); err != nil {
|
|
return nil
|
|
}
|
|
return overrides
|
|
}
|
|
|
|
// 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)
|
|
ApplyNavOverrides(nav, docsNavOverrides(c))
|
|
|
|
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)
|
|
}
|