feat(docs): standalone docs site chrome + category/subcategory navigation
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.
This commit is contained in:
1 parent
9d0ae2bcc4
commit
84cd6328f4
19 files changed
+2030
-1097
No files matched your search
+118
-329
@@ -25,6 +25,49 @@ func NewHTTPHandler(docsPath string) *HTTPHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -38,312 +81,96 @@ func (h *HTTPHandler) ShowDocumentation(c *echo.Context) error {
|
||||
return h.ShowAPIEndpoint(c, strings.TrimPrefix(docPath, "api/"))
|
||||
}
|
||||
|
||||
// Return error page
|
||||
errorHTML := fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Document Not Found - Bookhoard</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
<body class="bg-gray-900 text-gray-100 min-h-screen flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<h1 class="text-2xl font-bold mb-4">Document Not Found</h1>
|
||||
<p class="mb-4">%s</p>
|
||||
<a href="/docs" class="text-blue-400 hover:underline">Return to Documentation</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>`, err.Error())
|
||||
|
||||
return c.HTML(http.StatusNotFound, errorHTML)
|
||||
return h.notFoundPage(c, "Document Not Found", err.Error())
|
||||
}
|
||||
|
||||
// Get navigation
|
||||
nav := h.docs.BuildNavigation()
|
||||
return h.renderDocPage(c, doc)
|
||||
}
|
||||
|
||||
// Check if user is authenticated via middleware
|
||||
var user templates.User
|
||||
if userID := c.Get("user"); userID != nil {
|
||||
dbUser := userID.(database.Users)
|
||||
var theme string
|
||||
if dbUser.Theme.Valid {
|
||||
theme = dbUser.Theme.String
|
||||
} else {
|
||||
theme = "tokyo-night"
|
||||
}
|
||||
user = templates.User{
|
||||
ID: uuid.UUID(dbUser.ID.Bytes).String(),
|
||||
Username: dbUser.Username,
|
||||
Email: dbUser.Email,
|
||||
Role: dbUser.Role,
|
||||
Theme: theme,
|
||||
}
|
||||
} else {
|
||||
// Create empty user for unauthenticated users
|
||||
user = templates.User{
|
||||
ID: "",
|
||||
Username: "",
|
||||
Email: "",
|
||||
Role: "",
|
||||
Theme: "tokyo-night",
|
||||
}
|
||||
}
|
||||
|
||||
// Render documentation page
|
||||
var buf bytes.Buffer
|
||||
currentPath := c.Request().URL.Path
|
||||
err = templates.DocsLayout(*nav, *doc, user, currentPath).Render(c.Request().Context(), &buf)
|
||||
// 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 {
|
||||
fmt.Printf("DOCS RENDER ERROR: %v\n", err)
|
||||
return c.HTML(http.StatusInternalServerError, "Failed to render page: "+err.Error())
|
||||
return h.notFoundPage(c, "Documentation Not Found", err.Error())
|
||||
}
|
||||
fmt.Printf("DOCS: Successfully rendered %s (%d bytes)\n", docPath, buf.Len())
|
||||
return c.HTML(http.StatusOK, buf.String())
|
||||
return h.renderDocPage(c, doc)
|
||||
}
|
||||
|
||||
// ShowAPIEndpoint shows a specific API endpoint documentation
|
||||
func (h *HTTPHandler) ShowAPIEndpoint(c *echo.Context, endpointPath string) error {
|
||||
// Check if user is logged in
|
||||
isLoggedIn := false
|
||||
if userID := c.Get("user_id"); userID != nil {
|
||||
isLoggedIn = true
|
||||
}
|
||||
|
||||
// Get endpoint data for the explorer
|
||||
endpointInfo, err := h.docs.GetAPIEndpointData(endpointPath)
|
||||
_ = endpointInfo
|
||||
if err != nil {
|
||||
// Endpoint not found in explorer data, fall back to old behavior
|
||||
return h.showLegacyAPIEndpoint(c, endpointPath, isLoggedIn)
|
||||
}
|
||||
|
||||
// Load the markdown documentation for this endpoint
|
||||
docPath := "api/" + endpointPath + ".md"
|
||||
doc, err := h.docs.LoadDocument(docPath)
|
||||
if err != nil {
|
||||
errorHTML := `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>API Endpoint Not Found - Bookhoard</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
<body class="bg-gray-900 text-gray-100 min-h-screen flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<h1 class="text-2xl font-bold mb-4">API Endpoint Not Found</h1>
|
||||
<a href="/docs/api" class="text-blue-400 hover:underline">Return to API Documentation</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
return c.HTML(http.StatusNotFound, errorHTML)
|
||||
return h.notFoundPage(c, "API Endpoint Not Found", "No documentation exists for this endpoint.")
|
||||
}
|
||||
|
||||
// Get navigation
|
||||
nav := h.docs.BuildNavigation()
|
||||
return h.renderDocPage(c, doc)
|
||||
}
|
||||
|
||||
// Check if user is authenticated via middleware
|
||||
var user templates.User
|
||||
if userID := c.Get("user"); userID != nil {
|
||||
dbUser := userID.(database.Users)
|
||||
var theme string
|
||||
if dbUser.Theme.Valid {
|
||||
theme = dbUser.Theme.String
|
||||
} else {
|
||||
theme = "tokyo-night"
|
||||
}
|
||||
user = templates.User{
|
||||
ID: uuid.UUID(dbUser.ID.Bytes).String(),
|
||||
Username: dbUser.Username,
|
||||
Email: dbUser.Email,
|
||||
Role: dbUser.Role,
|
||||
Theme: theme,
|
||||
}
|
||||
} else {
|
||||
// Create empty user for unauthenticated users
|
||||
user = templates.User{
|
||||
ID: "",
|
||||
Username: "",
|
||||
Email: "",
|
||||
Role: "",
|
||||
Theme: "tokyo-night",
|
||||
}
|
||||
// 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
|
||||
}
|
||||
|
||||
// Render API endpoint page
|
||||
var buf bytes.Buffer
|
||||
currentPath := c.Request().URL.Path
|
||||
err = templates.DocsLayout(*nav, *doc, user, currentPath).Render(c.Request().Context(), &buf)
|
||||
|
||||
docs, err := h.docs.ListDocuments()
|
||||
if err != nil {
|
||||
return c.HTML(http.StatusInternalServerError, "Failed to render page")
|
||||
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())
|
||||
}
|
||||
|
||||
// showLegacyAPIEndpoint shows API endpoint documentation without explorer data
|
||||
func (h *HTTPHandler) showLegacyAPIEndpoint(c *echo.Context, endpointPath string, isLoggedIn bool) error {
|
||||
endpoints := h.docs.GetAPIEndpoints()
|
||||
|
||||
// Find the endpoint
|
||||
var foundEndpoint *APIEndpoint
|
||||
for _, ep := range endpoints {
|
||||
if strings.HasSuffix(endpointPath, strings.TrimPrefix(ep.Path, "/api/")) {
|
||||
foundEndpoint = &ep
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if foundEndpoint == nil {
|
||||
errorHTML := `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>API Endpoint Not Found - Bookhoard</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
<body class="bg-gray-900 text-gray-100 min-h-screen flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<h1 class="text-2xl font-bold mb-4">API Endpoint Not Found</h1>
|
||||
<a href="/docs/api" class="text-blue-400 hover:underline">Return to API Documentation</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
return c.HTML(http.StatusNotFound, errorHTML)
|
||||
}
|
||||
|
||||
// Get navigation
|
||||
nav := h.docs.BuildNavigation()
|
||||
|
||||
// Check if user is authenticated via middleware
|
||||
var user templates.User
|
||||
currentPath := c.Request().URL.Path
|
||||
if userID := c.Get("user"); userID != nil {
|
||||
dbUser := userID.(database.Users)
|
||||
var theme string
|
||||
if dbUser.Theme.Valid {
|
||||
theme = dbUser.Theme.String
|
||||
} else {
|
||||
theme = "tokyo-night"
|
||||
}
|
||||
user = templates.User{
|
||||
ID: uuid.UUID(dbUser.ID.Bytes).String(),
|
||||
Username: dbUser.Username,
|
||||
Email: dbUser.Email,
|
||||
Role: dbUser.Role,
|
||||
Theme: theme,
|
||||
}
|
||||
} else {
|
||||
// Create empty user for unauthenticated users
|
||||
user = templates.User{
|
||||
ID: "",
|
||||
Username: "",
|
||||
Email: "",
|
||||
Role: "",
|
||||
Theme: "tokyo-night",
|
||||
}
|
||||
}
|
||||
|
||||
// Render API endpoint page
|
||||
var buf bytes.Buffer
|
||||
err := templates.DocsLayout(*nav, templates.Document{
|
||||
Title: foundEndpoint.Title,
|
||||
Content: fmt.Sprintf("<p>Documentation for <code>%s %s</code></p>", foundEndpoint.Method, foundEndpoint.Path),
|
||||
TOC: []templates.TOCItem{},
|
||||
Breadcrumb: []templates.BreadcrumbItem{
|
||||
{Title: "Docs", URL: "/docs"},
|
||||
{Title: "API Reference", URL: "/docs/api"},
|
||||
{Title: foundEndpoint.Category, URL: "/docs/api"},
|
||||
{Title: foundEndpoint.Title, URL: ""},
|
||||
},
|
||||
Category: "API Reference",
|
||||
SourceFile: "api",
|
||||
}, user, currentPath).Render(c.Request().Context(), &buf)
|
||||
|
||||
if err != nil {
|
||||
return c.HTML(http.StatusInternalServerError, "Failed to render page")
|
||||
}
|
||||
|
||||
return c.HTML(http.StatusOK, buf.String())
|
||||
}
|
||||
|
||||
// DocsHome redirects to the documentation index
|
||||
func (h *HTTPHandler) DocsHome(c *echo.Context) error {
|
||||
return c.Redirect(http.StatusFound, "/docs/index.md")
|
||||
}
|
||||
|
||||
// APIHome shows the API documentation home page
|
||||
func (h *HTTPHandler) APIHome(c *echo.Context) error {
|
||||
endpoints := h.docs.GetAPIEndpoints()
|
||||
nav := h.docs.BuildNavigation()
|
||||
|
||||
// Check if user is authenticated via middleware
|
||||
var user templates.User
|
||||
currentPath := c.Request().URL.Path
|
||||
if userID := c.Get("user"); userID != nil {
|
||||
dbUser := userID.(database.Users)
|
||||
var theme string
|
||||
if dbUser.Theme.Valid {
|
||||
theme = dbUser.Theme.String
|
||||
} else {
|
||||
theme = "tokyo-night"
|
||||
}
|
||||
user = templates.User{
|
||||
ID: uuid.UUID(dbUser.ID.Bytes).String(),
|
||||
Username: dbUser.Username,
|
||||
Email: dbUser.Email,
|
||||
Role: dbUser.Role,
|
||||
Theme: theme,
|
||||
}
|
||||
} else {
|
||||
// Create empty user for unauthenticated users
|
||||
user = templates.User{
|
||||
ID: "",
|
||||
Username: "",
|
||||
Email: "",
|
||||
Role: "",
|
||||
Theme: "tokyo-night",
|
||||
}
|
||||
}
|
||||
|
||||
// Build API documentation content
|
||||
content := "<h2>API Endpoints</h2><p>Complete API reference for Bookhoard v1.0.</p><ul>"
|
||||
for _, ep := range endpoints {
|
||||
content += fmt.Sprintf(`<li><a href="/docs/api%s"><strong>%s %s</strong></a> - %s</li>`,
|
||||
strings.TrimPrefix(ep.Path, "/api"),
|
||||
ep.Method,
|
||||
ep.Path,
|
||||
ep.Title,
|
||||
)
|
||||
}
|
||||
content += "</ul>"
|
||||
|
||||
apiDoc := &templates.Document{
|
||||
Title: "API Reference",
|
||||
Content: content,
|
||||
TOC: []templates.TOCItem{},
|
||||
Breadcrumb: []templates.BreadcrumbItem{
|
||||
{Title: "Docs", URL: "/docs"},
|
||||
{Title: "Developer", URL: "/docs/developer"},
|
||||
{Title: "API Reference", URL: ""},
|
||||
},
|
||||
Category: "API Reference",
|
||||
SourceFile: "developer/api-reference.md",
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
err := templates.DocsLayout(*nav, *apiDoc, user, currentPath).Render(c.Request().Context(), &buf)
|
||||
|
||||
if err != nil {
|
||||
return c.HTML(http.StatusInternalServerError, "Failed to render page")
|
||||
}
|
||||
|
||||
return c.HTML(http.StatusOK, buf.String())
|
||||
}
|
||||
|
||||
// Search searches through documentation
|
||||
// Search searches through documentation (JSON API).
|
||||
func (h *HTTPHandler) Search(c *echo.Context) error {
|
||||
query := c.QueryParam("q")
|
||||
if query == "" {
|
||||
@@ -352,48 +179,19 @@ func (h *HTTPHandler) Search(c *echo.Context) error {
|
||||
})
|
||||
}
|
||||
|
||||
// Get all documents
|
||||
docs, err := h.docs.ListDocuments()
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"error": "failed to list documents",
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
// Simple search implementation
|
||||
var results []SearchResult
|
||||
queryLower := strings.ToLower(query)
|
||||
|
||||
for _, docPath := range docs {
|
||||
doc, err := h.docs.LoadDocument(docPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if title matches
|
||||
if strings.Contains(strings.ToLower(doc.Title), queryLower) {
|
||||
results = append(results, SearchResult{
|
||||
Title: doc.Title,
|
||||
URL: "/docs/" + strings.TrimSuffix(doc.SourceFile, ".md"),
|
||||
Type: "Documentation",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if content matches (simple substring search)
|
||||
contentLower := strings.ToLower(doc.Content)
|
||||
if strings.Contains(contentLower, queryLower) {
|
||||
results = append(results, SearchResult{
|
||||
Title: doc.Title,
|
||||
URL: "/docs/" + strings.TrimSuffix(doc.SourceFile, ".md"),
|
||||
Type: "Documentation",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"results": results,
|
||||
"count": len(results),
|
||||
"results": jsonResults,
|
||||
"count": len(jsonResults),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -404,15 +202,6 @@ type SearchResult struct {
|
||||
Type string
|
||||
}
|
||||
|
||||
// TryEndpoint executes an API endpoint (for the interactive explorer)
|
||||
func (h *HTTPHandler) TryEndpoint(c *echo.Context) error {
|
||||
// This would execute the actual API request
|
||||
// For now, return a placeholder
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"message": "API explorer not yet implemented",
|
||||
})
|
||||
}
|
||||
|
||||
// ServeSearchIndex serves the Lunr.js search index
|
||||
func (h *HTTPHandler) ServeSearchIndex(c *echo.Context) error {
|
||||
index, err := h.docs.GenerateSearchIndex()
|
||||
|
||||
Reference in new issue
Block a user