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.
215 lines
6.1 KiB
Go
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)
|
|
}
|