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) }