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.
232 lines
7.0 KiB
Go
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)
|
|
}
|