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:
John O'Keefe committed 2026-10-24 14:31:36 -04:00
1 parent 9d0ae2bcc4
commit 84cd6328f4
18 files changed
+1988 -1055

No files matched your search

+4 -2
View File
@@ -54,11 +54,13 @@ test: lint-rendering
# UI markup, JS orchestrates. Fails when a web/src file builds more HTML
# template literals than its recorded baseline (web/src/rendering-allowlist.txt),
# or when a .templ file gains an inline <script> block (theme_flash_guard.templ
# is the only sanctioned one). Shrink baselines as fragments move to templ
# is the only sanctioned one). *.test.ts files are excluded: jsdom fixtures
# build DOM in tests, they don't render shipped UI. Shrink baselines as
# fragments move to templ
# partials; grow one only with a written reason.
lint-rendering:
@violations=""; \
for f in $$(grep -rlE 'innerHTML[[:space:]]*=[[:space:]]*' web/src --include='*.ts' | grep -v 'web/src/reader/'); do \
for f in $$(grep -rlE 'innerHTML[[:space:]]*=[[:space:]]*' web/src --include='*.ts' | grep -v 'web/src/reader/' | grep -v '\.test\.ts$$'); do \
count=$$(grep -E 'innerHTML[[:space:]]*=[[:space:]]*' $$f | grep -cvE 'innerHTML[[:space:]]*=[[:space:]]*""[[:space:]]*$$'); \
allowed=$$(grep -F "$$f:" web/src/rendering-allowlist.txt | cut -d: -f2); \
allowed=$${allowed:-0}; \
+15 -154
View File
@@ -31,7 +31,11 @@ func NewDocsHandler(docsPath string) *DocsHandler {
extension.GFM,
extension.Table,
highlighting.NewHighlighting(
highlighting.WithStyle("github"),
// Chroma renders code blocks with inline styles, so the
// palette ships with the HTML — no client-side highlighter
// needed. The docs are read on dark themes most of the time;
// code blocks keep a dark surface under light themes too.
highlighting.WithStyle("github-dark"),
),
),
goldmark.WithParserOptions(
@@ -83,10 +87,6 @@ func (h *DocsHandler) LoadDocument(docPath string) (*templates.Document, error)
return nil, fmt.Errorf("failed to convert markdown: %w", err)
}
// Debug: Log the raw HTML output from goldmark
htmlOutput := buf.String()
fmt.Printf("DEBUG: Goldmark output length: %d, first 200 chars: %q\n", len(htmlOutput), htmlOutput[:min(200, len(htmlOutput))])
// Extract title from first heading
title := h.extractTitle(content)
@@ -155,46 +155,35 @@ func (h *DocsHandler) generateTOC(content []byte) []templates.TOCItem {
return toc
}
// generateBreadcrumb generates breadcrumb navigation
// generateBreadcrumb generates breadcrumb navigation. Only "Docs" links out:
// directory segments have no landing pages, so they render as plain text
// (URL left empty). The current page is omitted — the h1 below shows it.
func (h *DocsHandler) generateBreadcrumb(docPath string) []templates.BreadcrumbItem {
parts := strings.Split(strings.TrimSuffix(docPath, ".md"), "/")
breadcrumb := []templates.BreadcrumbItem{
{Title: "Docs", URL: "/docs"},
}
path := ""
for i, part := range parts {
if part == "" {
continue
}
path += "/" + part
// Don't add the last part (current page)
if i < len(parts)-1 {
if i == len(parts)-1 {
break // current page
}
breadcrumb = append(breadcrumb, templates.BreadcrumbItem{
Title: cases.Title(language.English).String(strings.ReplaceAll(part, "-", " ")),
URL: "/docs" + path,
})
}
}
return breadcrumb
}
// getCategory determines the category of a document
// getCategory determines the category of a document. It reuses the nav
// routing (placeDoc) so search results and breadcrumbs name sections exactly
// as the sidebar does.
func (h *DocsHandler) getCategory(docPath string) string {
if strings.Contains(docPath, "api") || strings.Contains(docPath, "API") {
return "API Reference"
}
if strings.Contains(docPath, "devices") {
return "Device Setup"
}
if strings.Contains(docPath, "contributing") || strings.Contains(docPath, "DEVELOPMENT") {
return "Contributing"
}
if strings.Contains(docPath, "SYNC") {
return "Sync Guide"
}
return "Documentation"
return placeDoc(docPath).section
}
// slugify converts a string to a URL-safe slug
@@ -234,134 +223,6 @@ func (h *DocsHandler) ListDocuments() ([]string, error) {
return docs, err
}
// GetAPIEndpointData returns endpoint data for the API explorer
func (h *DocsHandler) GetAPIEndpointData(endpointPath string) (*templates.EndpointInfo, error) {
// Map endpoint path to data
// For now, return static examples based on the endpoint path
// In a full implementation, this would parse the markdown files
// Clean the path
endpointPath = strings.TrimPrefix(endpointPath, "api/")
endpointPath = strings.TrimSuffix(endpointPath, ".md")
// Map paths to endpoints
endpoints := map[string]templates.EndpointInfo{
"authentication/register": {
Method: "POST",
Path: "/api/auth/register",
RequestBody: `{
"email": "user@example.com",
"username": "john",
"password": "SecureP@ss123!",
"first_name": "John",
"last_name": "Doe"
}`,
Response: `{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refresh_token": "d4f5g6h7...",
"user": {
"id": "uuid-here",
"email": "user@example.com",
"username": "john",
"role": "user",
"theme": "tokyo-night",
"created_at": "2026-01-31T10:00:00Z"
}
}`,
Description: "Create a new user account",
},
"authentication/login": {
Method: "POST",
Path: "/api/auth/login",
RequestBody: `{
"email": "user@example.com",
"password": "SecureP@ss123!"
}`,
Response: `{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refresh_token": "d4f5g6h7...",
"user": {
"id": "uuid-here",
"email": "user@example.com",
"username": "john",
"role": "user"
}
}`,
Description: "Authenticate with email and password",
},
"authentication/refresh_token": {
Method: "POST",
Path: "/api/auth/refresh",
RequestBody: `{
"refresh_token": "d4f5g6h7..."
}`,
Response: `{
"token": "new-jwt-token",
"refresh_token": "new-refresh-token"
}`,
Description: "Obtain a new JWT token using a refresh token",
},
"authentication/logout": {
Method: "POST",
Path: "/api/auth/logout",
RequestBody: `{}`,
Response: `{}`,
Description: "Invalidate the current JWT token",
},
"users/get_profile": {
Method: "GET",
Path: "/api/users/me",
RequestBody: `{}`,
Response: `{
"id": "uuid",
"email": "user@example.com",
"username": "john",
"first_name": "John",
"last_name": "Doe",
"theme": "tokyo-night",
"role": "user",
"max_devices": 10,
"created_at": "2026-01-31T10:00:00Z"
}`,
Description: "Retrieve the current authenticated user's profile",
},
"users/update_profile": {
Method: "PUT",
Path: "/api/users/me/profile",
RequestBody: `{
"first_name": "John",
"last_name": "Smith"
}`,
Response: `{
"id": "uuid",
"email": "user@example.com",
"username": "john",
"first_name": "John",
"last_name": "Smith",
"theme": "tokyo-night",
"role": "user"
}`,
Description: "Update the current user's profile information",
},
"users/change_password": {
Method: "PUT",
Path: "/api/users/me/password",
RequestBody: `{
"current_password": "oldPassword",
"new_password": "NewSecureP@ss123!"
}`,
Response: `{}`,
Description: "Change the current user's password",
},
}
if endpoint, ok := endpoints[endpointPath]; ok {
return &endpoint, nil
}
return nil, fmt.Errorf("endpoint not found: %s", endpointPath)
}
// SearchDoc represents a document for search indexing
type SearchDoc struct {
ID string `json:"id"`
+117 -328
View File
@@ -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()
// 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",
}
return h.renderDocPage(c, doc)
}
// 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()
// 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",
}
return h.renderDocPage(c, doc)
}
// 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)
// 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 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()
+281 -196
View File
@@ -10,8 +10,177 @@ import (
"golang.org/x/text/language"
)
// BuildNavigation creates the navigation structure from the docs filesystem
func (h *DocsHandler) BuildNavigation() *templates.Navigation {
// sectionOrder defines the sidebar's top-level categories, top to bottom.
// The docs directory layout IS the navigation structure: top-level
// directories map to sections, nested directories map to subcategory
// groups (see placeDoc for the routing rules).
var sectionOrder = []struct {
Title string
Icon string
}{
{Title: "Getting Started", Icon: "book-open"},
{Title: "User Guide", Icon: "book"},
{Title: "API Reference", Icon: "list"},
{Title: "Developer", Icon: "gear"},
{Title: "Operations", Icon: "database"},
{Title: "Contributing", Icon: "heart"},
}
// groupTitleOverrides fixes casing that naive title-casing gets wrong.
var groupTitleOverrides = map[string]string{
"admin": "Admin",
"api": "API",
"kobo": "Kobo",
"koreader": "KOReader",
"opds": "OPDS",
"websocket": "WebSocket",
}
// docTitleOverrides pins exact nav titles for files whose filename reads
// poorly (or which serve as a landing page). Keyed by doc path; "index.md"
// covers every subdirectory index page.
var docTitleOverrides = map[string]string{
"index.md": "Overview",
"contract_pins.md": "Contract Pins",
"FRONTEND_INTEGRATION.md": "Frontend Integration",
"user/user-guide.md": "User Guide",
"user/sync-guide.md": "Sync Guide",
"user/devices/kobo-setup.md": "Kobo Setup",
"user/devices/koreader-setup.md": "KOReader Setup",
"user/devices/android-app.md": "Android App",
"developer/development.md": "Developer Portal",
"developer/api/api-reference.md": "Overview",
"operations/operations.md": "Operations Guide",
"operations/troubleshooting.md": "Troubleshooting",
"contributing/contributing.md": "Contributing",
"contributing/development.md": "Development Guide",
"development/calibre-opf-implementation.md": "Calibre OPF Implementation",
}
// navPlacement routes one document into (section, group). An empty group
// means the doc sits directly in the section; a negative pin forces the doc
// to the front of its list (landing pages like the root Overview).
type navPlacement struct {
section string
group string
pin int
}
// placeDoc maps a doc path to its sidebar location.
func placeDoc(docPath string) navPlacement {
// Root landing page.
if docPath == "index.md" {
return navPlacement{section: "Getting Started", pin: -100}
}
// Stray root-level dev notes → Developer → Reference group.
if !strings.Contains(docPath, "/") {
return navPlacement{section: "Developer", group: "Reference"}
}
if strings.HasPrefix(docPath, "development/") {
return navPlacement{section: "Developer", group: "Reference"}
}
// Subdirectory index pages pin to the front of their group as "Overview".
pin := 0
if strings.HasSuffix(docPath, "/index.md") {
pin = -50
}
switch {
case strings.HasPrefix(docPath, "user/"):
rest := strings.TrimPrefix(docPath, "user/")
if dir, _, ok := strings.Cut(rest, "/"); ok {
// Subdirectories become subcategories: device setup gets its
// own group, the admin-facing guides another.
if dir == "devices" {
return navPlacement{section: "User Guide", group: "Devices", pin: pin}
}
return navPlacement{section: "User Guide", group: titleCase(dir), pin: pin}
}
if rest == "user-guide.md" {
return navPlacement{section: "User Guide", pin: -50}
}
if strings.HasPrefix(rest, "admin-") {
return navPlacement{section: "User Guide", group: "Administration"}
}
return navPlacement{section: "User Guide"}
case strings.HasPrefix(docPath, "developer/api/"):
rest := strings.TrimPrefix(docPath, "developer/api/")
if dir, _, ok := strings.Cut(rest, "/"); ok {
return navPlacement{section: "API Reference", group: groupTitle(dir), pin: pin}
}
if rest == "api-reference.md" {
return navPlacement{section: "API Reference", pin: -50}
}
return navPlacement{section: "API Reference"}
case strings.HasPrefix(docPath, "developer/"):
rest := strings.TrimPrefix(docPath, "developer/")
if dir, _, ok := strings.Cut(rest, "/"); ok {
return navPlacement{section: "Developer", group: groupTitle(dir), pin: pin}
}
if rest == "development.md" {
return navPlacement{section: "Developer", pin: -50}
}
return navPlacement{section: "Developer"}
case strings.HasPrefix(docPath, "operations/"):
if docPath == "operations/operations.md" {
return navPlacement{section: "Operations", pin: -50}
}
return navPlacement{section: "Operations"}
case strings.HasPrefix(docPath, "contributing/"):
if docPath == "contributing/contributing.md" {
return navPlacement{section: "Contributing", pin: -50}
}
return navPlacement{section: "Contributing"}
default:
return navPlacement{section: "Developer", group: "Reference"}
}
}
// groupTitle turns a directory name into a subcategory title.
func groupTitle(dir string) string {
if title, ok := groupTitleOverrides[dir]; ok {
return title
}
return titleCase(dir)
}
// titleCase converts "book-matching"/"media_items" to "Book Matching".
func titleCase(s string) string {
s = strings.NewReplacer("-", " ", "_", " ").Replace(s)
return cases.Title(language.English).String(s)
}
// docTitle returns the nav label for a document.
func docTitle(docPath string) string {
if title, ok := docTitleOverrides[docPath]; ok {
return title
}
filename := docPath[strings.LastIndex(docPath, "/")+1:]
if filename == "index.md" {
return "Overview"
}
return titleCase(strings.TrimSuffix(filename, ".md"))
}
// docURL converts a doc path to its site URL. The root index lives at /docs.
func docURL(docPath string) string {
if docPath == "index.md" {
return "/docs"
}
return "/docs/" + strings.TrimSuffix(docPath, ".md")
}
// BuildNavigation creates the category → subcategory navigation from the
// docs filesystem. The section and group containing currentPath render
// expanded; everything else starts collapsed (with Getting Started as the
// fallback when nothing matches).
func (h *DocsHandler) BuildNavigation(currentPath string) *templates.Navigation {
docs, err := h.ListDocuments()
if err != nil {
// Return minimal navigation on error
@@ -19,6 +188,7 @@ func (h *DocsHandler) BuildNavigation() *templates.Navigation {
Sections: []templates.NavSection{
{
Title: "Getting Started",
Icon: "book-open",
Items: []templates.NavItem{
{Title: "Overview", URL: "/docs"},
},
@@ -27,223 +197,138 @@ func (h *DocsHandler) BuildNavigation() *templates.Navigation {
}
}
// Group documents by category
categories := make(map[string][]templates.NavItem)
type groupBuild struct {
items []templates.NavItem
urls []string
overview bool // has a pinned index page
}
type sectionBuild struct {
items []templates.NavItem
urls []string
pinned map[string]bool // landing pages lead their section
groups map[string]*groupBuild
overview bool
}
builds := make(map[string]*sectionBuild, len(sectionOrder))
for _, spec := range sectionOrder {
builds[spec.Title] = &sectionBuild{groups: make(map[string]*groupBuild), pinned: make(map[string]bool)}
}
for _, doc := range docs {
// Skip INDEX.md files in subdirectories (they're section landing pages, not nav items)
// Only show root INDEX.md, skip all others like user/INDEX.md, api/INDEX.md, etc.
if strings.HasSuffix(doc, "index.md") && doc != "index.md" {
// ListDocuments returns paths without the .md extension; the
// routing/title helpers key on full doc paths.
docPath := doc + ".md"
place := placeDoc(docPath)
build, ok := builds[place.section]
if !ok {
continue
}
item := h.createNavItem(doc)
category := h.getCategoryForNav(doc)
categories[category] = append(categories[category], item)
item := templates.NavItem{Title: docTitle(docPath), URL: docURL(docPath)}
if place.group == "" {
build.items = append(build.items, item)
build.urls = append(build.urls, item.URL)
if place.pin < 0 {
build.pinned[item.URL] = true
}
if place.pin == -100 {
// Only the root Overview forces its section open.
build.overview = true
}
} else {
group, ok := build.groups[place.group]
if !ok {
group = &groupBuild{}
build.groups[place.group] = group
}
group.items = append(group.items, item)
group.urls = append(group.urls, item.URL)
if place.pin < 0 {
group.overview = true
}
}
}
// Build navigation sections
var sections []templates.NavSection
nav := &templates.Navigation{}
for _, spec := range sectionOrder {
build := builds[spec.Title]
section := templates.NavSection{Title: spec.Title, Icon: spec.Icon}
// Define section order
sectionOrder := []string{
"Getting Started",
"User Guide",
"Device Setup",
"API Reference",
"Contributing",
// Direct items: pinned landing pages lead, then alphabetical
// (case-insensitive — KOReader must not sort before Kobo).
items := append([]templates.NavItem(nil), build.items...)
sort.SliceStable(items, func(i, j int) bool {
pi, pj := build.pinned[items[i].URL], build.pinned[items[j].URL]
if pi != pj {
return pi
}
// Add sections in order
for _, sectionTitle := range sectionOrder {
if items, exists := categories[sectionTitle]; exists {
sections = append(sections, templates.NavSection{
Title: sectionTitle,
Items: items,
Collapsed: sectionTitle != "Getting Started", // Expand first section
return strings.ToLower(items[i].Title) < strings.ToLower(items[j].Title)
})
delete(categories, sectionTitle)
}
}
section.Items = items
// Add any remaining sections
for title, items := range categories {
sections = append(sections, templates.NavSection{
// Groups: alphabetical, each group's items alphabetical with the
// group's "Overview" (index page) first.
groupTitles := make([]string, 0, len(build.groups))
for title := range build.groups {
groupTitles = append(groupTitles, title)
}
sort.Strings(groupTitles)
for _, title := range groupTitles {
group := build.groups[title]
gItems := append([]templates.NavItem(nil), group.items...)
sort.SliceStable(gItems, func(i, j int) bool {
return strings.ToLower(gItems[i].Title) < strings.ToLower(gItems[j].Title)
})
if group.overview {
sort.SliceStable(gItems, func(i, j int) bool { return gItems[i].Title == "Overview" })
}
section.Groups = append(section.Groups, templates.NavGroup{
Title: title,
Items: items,
Collapsed: true,
Items: gItems,
Collapsed: !containsURL(currentPath, group.urls),
})
}
return &templates.Navigation{Sections: sections}
if len(section.Items) == 0 && len(section.Groups) == 0 {
continue
}
// createNavItem creates a navigation item from a document path
func (h *DocsHandler) createNavItem(docPath string) templates.NavItem {
title := h.getDocTitle(docPath)
url := "/docs/" + strings.TrimSuffix(docPath, ".md")
icon := h.getDocIcon(docPath)
return templates.NavItem{
Title: title,
URL: url,
Icon: icon,
section.Collapsed = !containsURL(currentPath, build.urls)
if section.Collapsed {
for _, g := range section.Groups {
if !g.Collapsed {
section.Collapsed = false
break
}
}
// getDocTitle extracts a readable title from a document path
func (h *DocsHandler) getDocTitle(docPath string) string {
// Remove .md extension
title := strings.TrimSuffix(docPath, ".md")
// Split by /
parts := strings.Split(title, "/")
// Get the last part (filename)
filename := parts[len(parts)-1]
// Convert to title case
title = cases.Title(language.English).String(strings.ReplaceAll(filename, "-", " "))
// Handle special cases
switch filename {
case "index.md":
return "Index"
case "user/user-guide.md":
return "User Guide"
case "developer/development.md":
return "Developer Portal"
case "operations/operations.md":
return "Operations Guide"
case "contributing/contributing.md":
return "Contributing"
case "developer/api-reference.md":
return "API Reference"
case "developer/api/api-reference.md":
return "API Reference"
case "developer/api/collections-api.md":
return "Collections API"
case "developer/websocket-api.md":
return "WebSocket API"
case "operations/troubleshooting.md":
return "Troubleshooting"
case "user/sync-guide.md":
return "Sync Guide"
case "user/devices/kobo-setup.md":
return "Kobo Setup"
case "user/devices/koreader-setup.md":
return "KOReader Setup"
case "DEVELOPMENT.md":
return "Development Guide"
}
if build.overview {
section.Collapsed = false
}
nav.Sections = append(nav.Sections, section)
}
return title
// Nothing matches the current page (404, search landing): open the
// first section so the sidebar doesn't render fully closed.
anyOpen := false
for _, s := range nav.Sections {
if !s.Collapsed {
anyOpen = true
break
}
}
if !anyOpen && len(nav.Sections) > 0 {
nav.Sections[0].Collapsed = false
}
// getDocIcon returns an appropriate icon for a document
func (h *DocsHandler) getDocIcon(docPath string) string {
if strings.Contains(docPath, "api") || strings.Contains(docPath, "API") {
return "🔌"
}
if strings.Contains(docPath, "device") {
return "📱"
}
if strings.Contains(docPath, "sync") || strings.Contains(docPath, "SYNC") {
return "🔄"
}
if strings.Contains(docPath, "contributing") || strings.Contains(docPath, "DEVELOPMENT") {
return "🛠️"
}
if strings.Contains(docPath, "index") {
return "📚"
}
if strings.Contains(docPath, "troubleshooting") {
return "🔧"
}
return "📄"
return nav
}
// getCategoryForNav determines the navigation category for a document
func (h *DocsHandler) getCategoryForNav(docPath string) string {
// API documentation
if strings.Contains(docPath, "api") || strings.Contains(docPath, "API") {
return "API Reference"
}
// Device setup
if strings.HasPrefix(docPath, "devices/") {
return "Device Setup"
}
// Contributing
if strings.HasPrefix(docPath, "contributing/") {
return "Contributing"
}
// Core user documentation
docName := strings.ToLower(docPath)
switch {
case strings.Contains(docName, "sync"):
return "User Guide"
case strings.Contains(docName, "troubleshoot"):
return "Getting Started"
case strings.Contains(docName, "index"):
return "Getting Started"
default:
return "Getting Started"
// containsURL reports whether currentPath is one of the given doc URLs.
func containsURL(currentPath string, urls []string) bool {
for _, u := range urls {
if currentPath == u {
return true
}
}
// GetAPIEndpoints returns a list of API endpoints for the API documentation
func (h *DocsHandler) GetAPIEndpoints() []APIEndpoint {
// This would be better auto-generated from Go code comments
// For now, return a static list based on your API
endpoints := []APIEndpoint{
// Authentication
{Method: "POST", Path: "/api/auth/register", Category: "Authentication", Title: "Register User"},
{Method: "POST", Path: "/api/auth/login", Category: "Authentication", Title: "Login"},
{Method: "POST", Path: "/api/auth/refresh", Category: "Authentication", Title: "Refresh Token"},
{Method: "POST", Path: "/api/auth/logout", Category: "Authentication", Title: "Logout"},
// Users
{Method: "GET", Path: "/api/users/me", Category: "Users", Title: "Get Current User"},
{Method: "PUT", Path: "/api/users/me/profile", Category: "Users", Title: "Update Profile"},
{Method: "PUT", Path: "/api/users/me/theme", Category: "Users", Title: "Update Theme"},
{Method: "PUT", Path: "/api/users/me/password", Category: "Users", Title: "Change Password"},
// Libraries
{Method: "GET", Path: "/api/libraries/visible", Category: "Libraries", Title: "Get Visible Libraries"},
{Method: "GET", Path: "/api/libraries/{id}", Category: "Libraries", Title: "Get Library Details"},
{Method: "POST", Path: "/api/libraries", Category: "Libraries", Title: "Create Library"},
// Media Items
{Method: "GET", Path: "/api/media-items", Category: "Media Items", Title: "List Media Items"},
{Method: "GET", Path: "/api/media-items/{id}", Category: "Media Items", Title: "Get Media Item"},
{Method: "POST", Path: "/api/media-items/search", Category: "Media Items", Title: "Search Media Items"},
// Progress
{Method: "GET", Path: "/api/media-items/{id}/progress", Category: "Progress", Title: "Get Reading Progress"},
{Method: "PUT", Path: "/api/media-items/{id}/progress", Category: "Progress", Title: "Update Reading Progress"},
// Devices
{Method: "POST", Path: "/api/devices/register", Category: "Devices", Title: "Register Device"},
{Method: "GET", Path: "/api/devices", Category: "Devices", Title: "List User Devices"},
{Method: "DELETE", Path: "/api/devices/{id}", Category: "Devices", Title: "Revoke Device"},
}
sort.Slice(endpoints, func(i, j int) bool {
if endpoints[i].Category == endpoints[j].Category {
return endpoints[i].Title < endpoints[j].Title
}
return endpoints[i].Category < endpoints[j].Category
})
return endpoints
}
type APIEndpoint struct {
Method string
Path string
Category string
Title string
return false
}
+205
View File
@@ -0,0 +1,205 @@
package docs
import (
"os"
"path/filepath"
"testing"
"bookhoard/templates"
)
func TestPlaceDoc(t *testing.T) {
tests := []struct {
path string
section string
group string
pinned bool
}{
{"index.md", "Getting Started", "", true},
{"user/user-guide.md", "User Guide", "", true},
{"user/dashboard.md", "User Guide", "", false},
{"user/admin-guide.md", "User Guide", "Administration", false},
{"user/devices/kobo-setup.md", "User Guide", "Devices", false},
{"developer/api/api-reference.md", "API Reference", "", true},
{"developer/api/dashboard.md", "API Reference", "", false},
{"developer/api/authentication/login.md", "API Reference", "Authentication", false},
{"developer/api/saved-filters/index.md", "API Reference", "Saved Filters", true},
{"developer/testing.md", "Developer", "", false},
{"developer/development.md", "Developer", "", true},
{"developer/scanner/audio-metadata.md", "Developer", "Scanner", false},
{"contract_pins.md", "Developer", "Reference", false},
{"FRONTEND_INTEGRATION.md", "Developer", "Reference", false},
{"development/calibre-opf-implementation.md", "Developer", "Reference", false},
{"operations/operations.md", "Operations", "", true},
{"operations/troubleshooting.md", "Operations", "", false},
{"contributing/contributing.md", "Contributing", "", true},
{"contributing/development.md", "Contributing", "", false},
}
for _, tt := range tests {
got := placeDoc(tt.path)
if got.section != tt.section || got.group != tt.group {
t.Errorf("placeDoc(%q) = (%q, %q), want (%q, %q)",
tt.path, got.section, got.group, tt.section, tt.group)
}
if (got.pin < 0) != tt.pinned {
t.Errorf("placeDoc(%q) pin = %d, pinned want %v", tt.path, got.pin, tt.pinned)
}
}
}
func TestDocTitle(t *testing.T) {
tests := map[string]string{
"index.md": "Overview",
"user/user-guide.md": "User Guide",
"user/devices/koreader-setup.md": "KOReader Setup",
"developer/development.md": "Developer Portal",
"developer/api/api-reference.md": "Overview",
"developer/api/users/update_profile.md": "Update Profile",
"developer/api/book-matching/link_book.md": "Link Book",
"operations/troubleshooting.md": "Troubleshooting",
"contract_pins.md": "Contract Pins",
}
for path, want := range tests {
if got := docTitle(path); got != want {
t.Errorf("docTitle(%q) = %q, want %q", path, got, want)
}
}
}
func TestDocURL(t *testing.T) {
if got := docURL("index.md"); got != "/docs" {
t.Errorf("docURL(index.md) = %q, want /docs", got)
}
if got := docURL("user/sync-guide.md"); got != "/docs/user/sync-guide" {
t.Errorf("docURL = %q, want /docs/user/sync-guide", got)
}
}
// buildTestDocs creates a representative docs tree in a temp dir and returns
// a handler over it.
func buildTestDocs(t *testing.T) *DocsHandler {
t.Helper()
dir := t.TempDir()
files := []string{
"index.md",
"user/user-guide.md",
"user/dashboard.md",
"user/admin-guide.md",
"user/devices/kobo-setup.md",
"user/devices/koreader-setup.md",
"developer/api/api-reference.md",
"developer/api/authentication/login.md",
"developer/api/authentication/overview.md",
"developer/api/saved-filters/index.md",
"developer/testing.md",
"operations/troubleshooting.md",
"contributing/development.md",
"contract_pins.md",
}
for _, f := range files {
path := filepath.Join(dir, f)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("# Test\n"), 0o644); err != nil {
t.Fatal(err)
}
}
return &DocsHandler{docsFS: os.DirFS(dir), docsPath: dir}
}
func TestBuildNavigation(t *testing.T) {
h := buildTestDocs(t)
// Current page: a device setup guide → User Guide section and its
// Devices group must render expanded; everything else collapsed.
nav := h.BuildNavigation("/docs/user/devices/kobo-setup")
if len(nav.Sections) == 0 {
t.Fatal("no sections built")
}
wantOrder := []string{"Getting Started", "User Guide", "API Reference", "Developer", "Operations", "Contributing"}
for i, want := range wantOrder {
if nav.Sections[i].Title != want {
t.Fatalf("section %d = %q, want %q", i, nav.Sections[i].Title, want)
}
}
getting := nav.Sections[0]
if getting.Collapsed {
t.Error("Getting Started should be expanded (root overview pinned)")
}
if len(getting.Items) != 1 || getting.Items[0].Title != "Overview" || getting.Items[0].URL != "/docs" {
t.Errorf("Getting Started items = %+v, want single Overview → /docs", getting.Items)
}
userGuide := nav.Sections[1]
if userGuide.Collapsed {
t.Error("User Guide should be expanded: it contains the current page")
}
if len(userGuide.Items) != 2 || userGuide.Items[0].Title != "User Guide" {
t.Errorf("User Guide direct items = %+v, want User Guide pinned first", userGuide.Items)
}
var devices *templates.NavGroup
for i := range userGuide.Groups {
if userGuide.Groups[i].Title == "Devices" {
devices = &userGuide.Groups[i]
}
}
if devices == nil {
t.Fatal("User Guide has no Devices group")
}
if devices.Collapsed {
t.Error("Devices group should be expanded: it contains the current page")
}
if len(devices.Items) != 2 || devices.Items[0].Title != "Kobo Setup" {
t.Errorf("Devices items = %+v, want Kobo Setup + KOReader Setup", devices.Items)
}
// Everything else starts collapsed.
for _, s := range nav.Sections[2:] {
if !s.Collapsed {
t.Errorf("section %q should be collapsed (current page is elsewhere)", s.Title)
}
}
// API Reference: subdirectory groups with the index page as Overview.
api := nav.Sections[2]
if len(api.Groups) != 2 {
t.Fatalf("API Reference groups = %d, want 2 (Authentication, Saved Filters)", len(api.Groups))
}
if api.Groups[0].Title != "Authentication" || len(api.Groups[0].Items) != 2 {
t.Errorf("Authentication group = %+v", api.Groups[0])
}
saved := api.Groups[1]
if saved.Title != "Saved Filters" || len(saved.Items) != 1 || saved.Items[0].Title != "Overview" {
t.Errorf("Saved Filters group = %+v, want single Overview item", saved)
}
if !saved.Collapsed {
t.Error("Saved Filters group should be collapsed on an unrelated page")
}
// Stray root files land in Developer → Reference.
dev := nav.Sections[3]
if len(dev.Groups) != 1 || dev.Groups[0].Title != "Reference" {
t.Errorf("Developer groups = %+v, want Reference", dev.Groups)
}
}
func TestBuildNavigationNothingCurrent(t *testing.T) {
h := buildTestDocs(t)
nav := h.BuildNavigation("/docs/nonexistent")
open := 0
for _, s := range nav.Sections {
if !s.Collapsed {
open++
}
}
if open != 1 {
t.Errorf("expected exactly one section open on a 404 path, got %d", open)
}
if nav.Sections[0].Collapsed {
t.Error("fallback should open the first section")
}
}
+11 -2
View File
@@ -6,8 +6,17 @@ import (
func registerDocumentationRoutes(cfg *Config) {
docsHandler := docs.NewHTTPHandler("docs")
cfg.Echo.GET("/docs", docsHandler.DocsHome)
cfg.Echo.GET("/docs/*", docsHandler.ShowDocumentation)
// Docs render for anonymous AND signed-in visitors (it's the pre-login
// surface for setup/troubleshooting), so the HTML routes use the optional
// auth middleware: handlers get a populated user when a valid token is
// present, nil otherwise. The JSON/partial endpoints are public.
docsAuth := createOptionalAuthMiddleware(cfg)
cfg.Echo.GET("/docs", docsHandler.DocsHome, docsAuth)
cfg.Echo.GET("/docs/*", docsHandler.ShowDocumentation, docsAuth)
// Server-rendered search results partial (swapped in by web/src/docs.ts).
cfg.Echo.GET("/docs/search/partial", docsHandler.SearchPartial)
cfg.Echo.GET("/docs/api/search", docsHandler.Search)
cfg.Echo.GET("/docs/search-index.json", docsHandler.ServeSearchIndex)
}
+60
View File
@@ -95,6 +95,66 @@ func (cfg *Config) getBaseURL(ctx context.Context) string {
return baseURL
}
// createOptionalAuthMiddleware parses the JWT when one is present but never
// rejects the request: pages rendered for both anonymous and signed-in
// visitors (the documentation) use it so handlers see a populated "user"
// when a valid token exists and nil otherwise.
//
// This is deliberately NOT echojwt: its ErrorHandler's return value ends the
// chain, so "return nil" for a missing token would abort the request with an
// empty 200 instead of continuing (that shipped once — every /docs page came
// back blank). A ghost-session token authenticates as nobody, same rule as
// the required middleware minus the redirect.
func createOptionalAuthMiddleware(cfg *Config) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c *echo.Context) error {
tokenString := ""
if cookie, err := c.Cookie("token"); err == nil && cookie.Value != "" {
tokenString = cookie.Value
} else if auth := c.Request().Header.Get("Authorization"); strings.HasPrefix(auth, "Bearer ") {
tokenString = strings.TrimPrefix(auth, "Bearer ")
}
if tokenString == "" {
return next(c) // anonymous
}
token, err := jwt.Parse(tokenString, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, jwt.ErrSignatureInvalid
}
return []byte(cfg.Cfg.JWTSecret), nil
})
if err != nil || !token.Valid {
return next(c) // invalid/expired → anonymous
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return next(c)
}
userIDStr, _ := claims["user_id"].(string)
userUUID, err := uuid.Parse(userIDStr)
if err != nil {
return next(c)
}
liveUser, exists := fetchLiveUser(cfg, userUUID)
if !exists {
return next(c) // ghost session → anonymous
}
c.Set("user", database.Users{
ID: pgtype.UUID{Bytes: userUUID, Valid: true},
Email: claims["user_email"].(string),
Username: claims["user_username"].(string),
Role: claims["user_role"].(string),
Timezone: liveUser.Timezone,
})
return next(c)
}
}
}
// createJWTMiddleware creates a JWT middleware with proper user context setup
func createJWTMiddleware(cfg *Config) echo.MiddlewareFunc {
return echojwt.WithConfig(echojwt.Config{
+20 -1
View File
@@ -118,9 +118,28 @@ const config: Config = {
"wood-mahogany": "url('/static/textures/wood-mahogany.png')",
},
typography: ({ theme }) => ({
invert: {
// Docs pages use plain `prose`; bind its palette to the theme
// variables so markdown renders correctly under dark AND light
// themes (the old layout hardcoded prose-invert, which painted
// light-on-dark text even under light themes).
DEFAULT: {
css: {
"--tw-prose-body": "var(--text-primary)",
"--tw-prose-headings": "var(--text-primary)",
"--tw-prose-lead": "var(--text-secondary)",
"--tw-prose-links": "var(--accent)",
"--tw-prose-bold": "var(--text-primary)",
"--tw-prose-counters": "var(--text-secondary)",
"--tw-prose-bullets": "var(--accent)",
"--tw-prose-hr": "var(--border)",
"--tw-prose-quotes": "var(--text-secondary)",
"--tw-prose-quote-borders": "var(--accent)",
"--tw-prose-captions": "var(--text-secondary)",
"--tw-prose-code": "var(--text-primary)",
"--tw-prose-pre-code": "var(--text-primary)",
"--tw-prose-pre-bg": "var(--bg-secondary)",
"--tw-prose-th-borders": "var(--border)",
"--tw-prose-td-borders": "var(--border)",
code: {
backgroundColor: "var(--bg-primary)",
color: "var(--text-primary)",
+239 -66
View File
@@ -2,88 +2,50 @@ package templates
import "fmt"
// DocsLayout renders the standalone documentation site: a docs-owned topbar
// (search, theme picker, Open App), a docs-only sidebar with category →
// subcategory navigation, and the markdown content.
//
// It deliberately does NOT embed the app Header: docs are a public surface
// (setup and troubleshooting happen pre-login), and the app chrome assumed a
// signed-in user — it rendered a sign-in form that silently degraded into a
// native GET submit (credentials into the URL) because this page never loaded
// htmx. Signed-out visitors get a plain "Sign in" LINK here; the form lives
// on /login where it belongs.
templ DocsLayout(nav Navigation, doc Document, user User, currentPath string) {
<!DOCTYPE html>
<html lang="en" class="dark">
<html lang="en">
<head>
@ThemeFlashGuard()
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>{ doc.Title } - Bookhoard Documentation</title>
<link href="/static/style.css" rel="stylesheet"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
<link rel="stylesheet" href="/static/highlight-dark.min.css"/>
</head>
<body x-data="docs" x-init="initializeSearch(); highlightCurrentPage(); initializeCodeCopyButtons()" class={ "theme-" + user.Theme + " page-docs font-sans antialiased" }>
@Header(user, currentPath)
<button
class="lg:hidden icon-btn fixed top-[4.75rem] left-4 z-50 bg-surface-raised"
onclick="toggleSidebar()"
aria-label="Toggle documentation menu"
>
@Icon("menu", "h-5 w-5")
</button>
<div id="docs-overlay" class="hidden fixed inset-0 z-40 lg:hidden" style="background: var(--surface-overlay);" onclick="toggleSidebar()"></div>
<div id="search-results" class="hidden fixed inset-0 z-50 overflow-y-auto p-8" style="background: var(--surface-overlay);"></div>
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="flex gap-8 py-8">
<aside id="docs-sidebar" class="sidebar fixed lg:sticky top-16 lg:top-20 bottom-0 lg:bottom-auto left-0 lg:left-auto z-40 lg:z-10 w-64 shrink-0 lg:h-[calc(100vh-6rem)] overflow-y-auto -translate-x-full lg:translate-x-0 transition-transform duration-300 ease-in-out bg-surface-raised border-r border-line lg:border-r-0">
<div class="p-4 border-b border-line">
<div class="relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none" style="color: var(--text-secondary);">
@Icon("search", "h-4 w-4")
</span>
<input
type="text"
class="search-input input pl-9"
placeholder="Search documentation…"
id="docs-search"
/>
</div>
</div>
for _, section := range nav.Sections {
<div class="nav-section border-b border-line">
<div
class="nav-section-title flex items-center justify-between px-4 py-3 cursor-pointer select-none text-xs uppercase tracking-wider font-semibold transition-colors hover:bg-surface-hover"
style="color: var(--text-secondary);"
@click="toggleSection($el)"
>
<span>{ section.Title }</span>
@Icon("chevron-down", "h-4 w-4 section-arrow transition-transform")
</div>
if section.Collapsed {
<div class="nav-items hidden" data-collapsed="true">
for _, item := range section.Items {
<a href={ item.URL } class="nav-item flex items-center gap-2 py-2 pl-8 pr-4 text-sm no-underline transition-colors hover:bg-surface-hover" style="color: var(--text-secondary);">
<span class="text-xs" style="color: var(--accent);">{ item.Icon }</span>
<span>{ item.Title }</span>
</a>
}
</div>
} else {
<div class="nav-items" data-collapsed="false">
for _, item := range section.Items {
<a href={ item.URL } class="nav-item flex items-center gap-2 py-2 pl-8 pr-4 text-sm no-underline transition-colors hover:bg-surface-hover" style="color: var(--text-secondary);">
<span class="text-xs" style="color: var(--accent);">{ item.Icon }</span>
<span>{ item.Title }</span>
</a>
}
</div>
}
</div>
}
</aside>
<main class="main-content flex-1 min-w-0 max-w-4xl">
<body x-data="docs" x-init="initializeSearch(); initializeCodeCopyButtons()" class={ "theme-" + user.Theme + " page-docs font-sans antialiased" }>
@DocsTopbar(user)
<!-- Mobile drawer backdrop -->
<div id="docs-overlay" class="hidden fixed inset-0 z-40 lg:hidden" style="background: var(--surface-overlay);" onclick="document.getElementById('docs-sidebar').classList.add('-translate-x-full'); this.classList.add('hidden')"></div>
<div class="flex">
@DocsSidebar(nav, currentPath)
<main class="flex-1 min-w-0">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8 lg:py-10">
if len(doc.Breadcrumb) > 0 {
<nav class="breadcrumb flex flex-wrap items-center gap-1.5 text-sm mb-8" aria-label="Breadcrumb" style="color: var(--text-secondary);">
<nav class="breadcrumb flex flex-wrap items-center gap-1.5 text-sm mb-6" aria-label="Breadcrumb" style="color: var(--text-secondary);">
for i, crumb := range doc.Breadcrumb {
if i > 0 {
@Icon("chevron-right", "h-4 w-4")
}
if crumb.URL != "" {
<a href={ crumb.URL } class="no-underline transition-colors hover:underline" style="color: var(--accent);">{ crumb.Title }</a>
} else {
<span>{ crumb.Title }</span>
}
}
</nav>
}
<h1 class="text-4xl font-bold mb-8 tracking-tight" style="color: var(--text-primary);">{ doc.Title }</h1>
<h1 class="text-3xl sm:text-4xl font-bold mb-8 tracking-tight" style="color: var(--text-primary);">{ doc.Title }</h1>
if len(doc.TOC) > 0 {
<details class="toc card p-4 mb-8">
<summary class="cursor-pointer list-none [&::-webkit-details-marker]:hidden flex items-center gap-2 font-semibold transition-colors hover:bg-surface-hover -m-4 p-4 rounded-2xl" style="color: var(--text-primary);">
@@ -95,7 +57,7 @@ templ DocsLayout(nav Navigation, doc Document, user User, currentPath string) {
<a
href={ "#" + item.Anchor }
class="toc-item block py-1 text-sm no-underline transition-colors hover:text-brand"
style={ "margin-left: " + fmt.Sprintf("%drem", item.Level) + "; color: var(--text-secondary);" }
style={ "margin-left: " + fmt.Sprintf("%d", item.Level-2) + "rem; color: var(--text-secondary);" }
>
{ item.Title }
</a>
@@ -103,11 +65,222 @@ templ DocsLayout(nav Navigation, doc Document, user User, currentPath string) {
</div>
</details>
}
<div class="prose prose-invert max-w-none">
<div class="prose max-w-none">
@UnsafeHTML(doc.Content).ToComponent()
</div>
</div>
</main>
</div>
<!-- module bundle; ?v=<stamp> busts stale intermediate builds -->
<script type="module" src="/static/main.js?v=7"></script>
</body>
</html>
}
// DocsTopbar is the docs site's own slim header. It never contains a login
// form — anonymous visitors get a link to /login (the only place credentials
// are posted from).
templ DocsTopbar(user User) {
<header class="sticky top-0 z-30 h-14 shrink-0 border-b flex items-center gap-2 sm:gap-3 px-4 sm:px-6" style="background: var(--bg-primary); border-color: var(--border);">
<button
type="button"
class="lg:hidden icon-btn shrink-0"
@click="toggleSidebar()"
aria-label="Toggle documentation menu"
>
@Icon("menu", "h-5 w-5")
</button>
<a href="/docs" class="flex items-center gap-2 shrink-0 no-underline">
<span class="grid place-items-center" style="color: var(--accent);">
@Icon("book-open", "h-6 w-6")
</span>
<span class="hidden sm:inline font-bold text-lg tracking-tight" style="color: var(--text-primary);">Bookhoard</span>
<span class="text-xs font-semibold px-2 py-0.5 rounded-full" style="background: var(--accent-muted); color: var(--accent);">Docs</span>
</a>
<div class="flex-1"></div>
@DocsSearchBox()
<div class="hidden md:block h-6 w-px shrink-0" style="background: var(--border);"></div>
@DocsThemePicker()
<div class="hidden md:flex items-center gap-2 shrink-0">
if user.ID != "" {
<a
href="/profile"
class="icon-btn"
title={ "Signed in as " + user.Username }
aria-label="Your profile"
>
@Icon("user", "h-5 w-5")
</a>
} else {
<a href="/login" class="text-sm font-medium hover:underline" style="color: var(--text-secondary);">Sign in</a>
}
<a href="/" class="btn btn-primary py-1.5 px-3 text-sm no-underline">Open App</a>
</div>
</header>
}
// DocsSearchBox hosts the header search input. Results are a server-rendered
// partial (GET /docs/search/partial) swapped into #docs-search-results by
// web/src/docs.ts — the server renders what results look like.
templ DocsSearchBox() {
<div
class="relative flex-1 max-w-72"
x-data="{ open: false }"
@click.outside="open = false"
@keydown.escape="open = false"
>
<span class="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none" style="color: var(--text-secondary);">
@Icon("search", "h-4 w-4")
</span>
<input
type="text"
class="search-input input pl-9 py-1.5 text-sm"
placeholder="Search docs…"
id="docs-search"
autocomplete="off"
/>
<div
id="docs-search-results"
class="hidden absolute top-full right-0 mt-2 w-80 max-w-[calc(100vw-2rem)] max-h-96 overflow-y-auto rounded-2xl border shadow-pop z-40"
style="background: var(--bg-secondary); border-color: var(--border);"
></div>
</div>
}
// DocsThemePicker reuses the app's theme Alpine component: initializeTheme
// applies the saved (and, when signed in, server) theme and syncs the select;
// changeTheme persists the choice. Anchors in the content smooth-scroll via
// the same init.
templ DocsThemePicker() {
<div x-data="theme" x-init="initializeTheme()" class="shrink-0">
<select
id="theme-select"
@change="changeTheme($event.target.value)"
class="input py-1.5 pr-8 text-sm cursor-pointer w-auto"
aria-label="Theme"
>
<optgroup label="Dark">
for _, opt := range ThemeOptionsFor(ModeDark) {
<option value={ opt.Name }>{ opt.Label }</option>
}
</optgroup>
<optgroup label="Light">
for _, opt := range ThemeOptionsFor(ModeLight) {
<option value={ opt.Name }>{ opt.Label }</option>
}
</optgroup>
</select>
</div>
}
// DocsSidebar renders the category → subcategory navigation. Sections and
// groups toggle collapsed/expanded client-side (pure open/closed state);
// the server decides what is expanded on first paint via data-collapsed.
templ DocsSidebar(nav Navigation, currentPath string) {
<aside
id="docs-sidebar"
class="sidebar fixed lg:sticky top-14 bottom-0 lg:bottom-auto left-0 z-40 lg:z-10 w-72 shrink-0 lg:h-[calc(100vh-3.5rem)] overflow-y-auto -translate-x-full lg:translate-x-0 transition-transform duration-300 ease-in-out bg-surface-raised border-r border-line"
>
<nav class="py-2" aria-label="Documentation">
for _, section := range nav.Sections {
<div class="nav-section border-b border-line last:border-b-0 pb-2">
<button
type="button"
class="nav-section-title w-full flex items-center gap-2.5 px-4 py-2.5 cursor-pointer select-none text-xs uppercase tracking-wider font-semibold transition-colors hover:bg-surface-hover"
style="color: var(--text-secondary);"
@click="toggleCollapsible($el)"
>
<span class="shrink-0" style="color: var(--accent);">
@Icon(section.Icon, "h-4 w-4")
</span>
<span class="flex-1 text-left">{ section.Title }</span>
<span class="section-arrow shrink-0 transition-transform" style={ chevronStyle(section.Collapsed) }>
@Icon("chevron-down", "h-4 w-4")
</span>
</button>
<div class="nav-items pb-1" data-collapsed={ collapsibleState(section.Collapsed) }>
for _, item := range section.Items {
<a href={ item.URL } class={ docsNavClass(currentPath, item.URL, "pl-7") }>
<span>{ item.Title }</span>
</a>
}
for _, group := range section.Groups {
<div class="mt-0.5">
<button
type="button"
class="nav-group-title w-full flex items-center gap-1.5 pl-7 pr-4 py-1.5 cursor-pointer select-none text-xs font-semibold uppercase tracking-wide transition-colors hover:bg-surface-hover"
style="color: var(--text-secondary);"
@click="toggleCollapsible($el)"
>
<span class="flex-1 text-left truncate">{ group.Title }</span>
<span class="section-arrow shrink-0 transition-transform" style={ chevronStyle(group.Collapsed) }>
@Icon("chevron-down", "h-3.5 w-3.5")
</span>
</button>
<div class="nav-group-items" data-collapsed={ collapsibleState(group.Collapsed) }>
for _, item := range group.Items {
<a href={ item.URL } class={ docsNavClass(currentPath, item.URL, "pl-9") }>
<span class="truncate">{ item.Title }</span>
</a>
}
</div>
</div>
}
</div>
</div>
}
</nav>
</aside>
}
// DocsSearchResults renders the search results partial swapped into the
// header dropdown. Server-rendered on purpose (rendering ownership): the
// client never builds result markup from JSON.
templ DocsSearchResults(results []SearchResultItem) {
if len(results) == 0 {
<div class="px-4 py-3 text-sm" style="color: var(--text-secondary);">No matching pages.</div>
} else {
<ul class="py-1 list-none m-0 p-0">
for _, r := range results {
<li>
<a href={ r.URL } class="block px-4 py-2 no-underline transition-colors hover:bg-surface-hover" onclick="this.closest('#docs-search-results').classList.add('hidden')">
<span class="block text-sm font-medium truncate" style="color: var(--text-primary);">{ r.Title }</span>
if r.Category != "" {
<span class="block text-xs" style="color: var(--text-secondary);">{ r.Category }</span>
}
</a>
</li>
}
</ul>
}
}
// DocsNotFound renders the standalone docs 404 page: same theme tokens, no
// app chrome, no CDN dependencies.
templ DocsNotFound(title string, message string) {
<!DOCTYPE html>
<html lang="en">
<head>
@ThemeFlashGuard()
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>{ title } - Bookhoard Documentation</title>
<link href="/static/style.css" rel="stylesheet"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
</head>
<body class="theme-tokyo-night min-h-screen font-sans antialiased">
<div class="min-h-screen flex items-center justify-center p-4">
<div class="card p-8 max-w-md w-full text-center">
<span class="inline-grid place-items-center mb-4" style="color: var(--accent);">
@Icon("book-open", "h-10 w-10")
</span>
<h1 class="text-2xl font-bold mb-3" style="color: var(--text-primary);">{ title }</h1>
<p class="text-sm mb-6" style="color: var(--text-secondary);">{ message }</p>
<div class="flex items-center justify-center gap-3">
<a href="/docs" class="btn btn-primary py-2 px-4 text-sm no-underline">Documentation</a>
<a href="/" class="btn py-2 px-4 text-sm no-underline">Bookhoard</a>
</div>
</div>
</div>
</body>
</html>
+146
View File
@@ -0,0 +1,146 @@
package templates
import (
"context"
"strings"
"testing"
)
// The docs layout is a standalone documentation site: it must NOT embed the
// app chrome (its sidebar assumed a signed-in user) and must never contain a
// login form — the old layout posted credentials via htmx that was never
// loaded on this page, degrading to a native GET that put the username and
// password into the URL. These render tests pin that fix.
func TestDocsLayoutRendersStandalone(t *testing.T) {
nav := Navigation{
Sections: []NavSection{
{
Title: "Getting Started",
Icon: "book-open",
Items: []NavItem{{Title: "Overview", URL: "/docs"}},
},
{
Title: "User Guide",
Icon: "book",
Collapsed: true,
Items: []NavItem{{Title: "Dashboard", URL: "/docs/user/dashboard"}},
Groups: []NavGroup{
{
Title: "Devices",
Items: []NavItem{{Title: "Kobo Setup", URL: "/docs/user/devices/kobo-setup"}},
Collapsed: true,
},
},
},
},
}
doc := Document{
Title: "Test Doc",
Content: "<p>Hello docs</p>",
TOC: []TOCItem{{Level: 2, Title: "Section", Anchor: "section"}},
Breadcrumb: []BreadcrumbItem{
{Title: "Docs", URL: "/docs"},
{Title: "User Guide"},
},
SourceFile: "user/test.md",
}
var b strings.Builder
if err := DocsLayout(nav, doc, User{Theme: "tokyo-night"}, "/docs/user/test").Render(context.Background(), &b); err != nil {
t.Fatalf("DocsLayout: %v", err)
}
out := b.String()
for _, banned := range []string{
"app-sidebar", // app chrome
`hx-post="/api/auth/login"`, // no inline login form (the URL-leak bug)
`name="password"`, // credentials never render on docs pages
"prose-invert", // theme-aware prose, not hardcoded dark
"highlight-dark.min.css", // highlighting is server-side now
"cdn.tailwindcss.com", // no CDN dependencies
"lunr", // dead client-side search is gone
} {
if strings.Contains(out, banned) {
t.Errorf("docs layout must not contain %q", banned)
}
}
for _, want := range []string{
`href="/docs"`, // sidebar + logo links
`data-collapsed="true"`, // collapsed section state
`data-collapsed="false"`, // expanded section state
"Open App", // topbar action
"theme-select", // theme picker
"docs-search-results", // search dropdown target
"Hello docs", // content rendered
"page-docs",
} {
if !strings.Contains(out, want) {
t.Errorf("docs layout missing %q", want)
}
}
// Anonymous visitor: a sign-in LINK, never a form.
b.Reset()
if err := DocsLayout(nav, doc, User{Theme: "tokyo-night"}, "/docs/user/test").Render(context.Background(), &b); err != nil {
t.Fatalf("DocsLayout anonymous: %v", err)
}
if !strings.Contains(b.String(), `href="/login"`) {
t.Error("anonymous docs layout should link to /login")
}
}
func TestDocsTopbarSignedIn(t *testing.T) {
var b strings.Builder
if err := DocsTopbar(User{ID: "u1", Username: "reader", Theme: "tokyo-night"}).Render(context.Background(), &b); err != nil {
t.Fatalf("DocsTopbar: %v", err)
}
out := b.String()
if strings.Contains(out, `href="/login"`) {
t.Error("signed-in topbar must not offer /login")
}
if !strings.Contains(out, "reader") {
t.Error("signed-in topbar should surface the username")
}
}
func TestDocsSearchResultsPartial(t *testing.T) {
var b strings.Builder
results := []SearchResultItem{
{Title: "Kobo Setup", Category: "User Guide", URL: "/docs/user/devices/kobo-setup"},
}
if err := DocsSearchResults(results).Render(context.Background(), &b); err != nil {
t.Fatalf("DocsSearchResults: %v", err)
}
out := b.String()
for _, want := range []string{"Kobo Setup", "User Guide", `/docs/user/devices/kobo-setup`} {
if !strings.Contains(out, want) {
t.Errorf("search results missing %q", want)
}
}
// Empty state renders, not blank markup.
b.Reset()
if err := DocsSearchResults(nil).Render(context.Background(), &b); err != nil {
t.Fatalf("DocsSearchResults empty: %v", err)
}
if !strings.Contains(b.String(), "No matching pages.") {
t.Error("search results empty state missing")
}
}
func TestDocsNotFoundRenders(t *testing.T) {
var b strings.Builder
if err := DocsNotFound("Document Not Found", "no such page").Render(context.Background(), &b); err != nil {
t.Fatalf("DocsNotFound: %v", err)
}
out := b.String()
for _, want := range []string{"Document Not Found", "no such page", `href="/docs"`} {
if !strings.Contains(out, want) {
t.Errorf("404 page missing %q", want)
}
}
if strings.Contains(out, "cdn.tailwindcss.com") {
t.Error("404 page must not pull the tailwind CDN")
}
}
+782 -205
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -241,7 +241,7 @@ templ Header(user User, currentPath string) {
</header>
<!-- module bundle; ?v=<commit-ish stamp> busts stale intermediate
builds across app restarts (browser caches are keyed by URL) -->
<script type="module" src="/static/main.js?v=6"></script>
<script type="module" src="/static/main.js?v=7"></script>
</div>
}
+1 -1
View File
@@ -751,7 +751,7 @@ func Header(user User, currentPath string) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</span> <input type=\"text\" id=\"header-search\" placeholder=\"Search your library…\" class=\"input pl-10\" autocomplete=\"off\"></div></div></header><!-- module bundle; ?v=<commit-ish stamp> busts stale intermediate\n\t\t builds across app restarts (browser caches are keyed by URL) --><script type=\"module\" src=\"/static/main.js?v=6\"></script></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</span> <input type=\"text\" id=\"header-search\" placeholder=\"Search your library…\" class=\"input pl-10\" autocomplete=\"off\"></div></div></header><!-- module bundle; ?v=<commit-ish stamp> busts stale intermediate\n\t\t builds across app restarts (browser caches are keyed by URL) --><script type=\"module\" src=\"/static/main.js?v=7\"></script></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+20 -1
View File
@@ -209,8 +209,20 @@ type Navigation struct {
Sections []NavSection
}
// NavSection is a top-level documentation category. Items are documents that
// live directly in the section; Groups are subcategories (subdirectories).
type NavSection struct {
Title string
Icon string
Items []NavItem
Groups []NavGroup
Collapsed bool
}
// NavGroup is a subcategory inside a NavSection (a subdirectory of docs).
type NavGroup struct {
Title string
Icon string
Items []NavItem
Collapsed bool
}
@@ -218,7 +230,14 @@ type NavSection struct {
type NavItem struct {
Title string
URL string
Icon string
}
// SearchResultItem is one entry in the docs search results partial. The
// server renders it (templ); the client only swaps the markup in.
type SearchResultItem struct {
Title string
Category string
URL string
}
type Document struct {
+30 -1
View File
@@ -6,9 +6,9 @@ import (
"bookhoard/internal/utils"
"encoding/json"
"fmt"
"strconv"
"net/url"
"sort"
"strconv"
"strings"
"time"
@@ -33,6 +33,35 @@ func navItemClass(current, target string) string {
return base + "text-content-muted hover:text-content hover:bg-surface-hover"
}
// docsNavClass returns classes for docs sidebar links (server-rendered active
// state — there is no client-side highlighter). indent sets the left padding,
// deeper for subcategory group items.
func docsNavClass(current, target, indent string) string {
base := "nav-item flex items-center " + indent + " pr-4 mr-2 py-1.5 rounded-lg text-sm transition-colors "
if strings.TrimRight(current, "/") == strings.TrimRight(target, "/") {
return base + "bg-brand/15 text-brand font-medium"
}
return base + "text-content-muted hover:text-content hover:bg-surface-hover"
}
// collapsibleState renders the data-collapsed attribute value for a docs nav
// items container; web/src/docs.ts toggles it on click.
func collapsibleState(collapsed bool) string {
if collapsed {
return "true"
}
return "false"
}
// chevronStyle pre-rotates a docs nav section/group chevron when collapsed
// (quote-free value: templ double-escapes quotes in style attributes).
func chevronStyle(collapsed bool) string {
if collapsed {
return "transform: rotate(-90deg);"
}
return "transform: rotate(0deg);"
}
// themeCheckClass returns the class suffix for a theme option's checkmark
// wrapper: empty for the active theme (visible), " hidden" otherwise. The
// check element is always rendered so updateThemeIndicators() can move it
+54 -95
View File
@@ -1,41 +1,41 @@
import { Alpine } from "./alpine";
// Documentation site behavior. Rendering ownership (AGENTS.md): the server
// renders what things look like — nav markup, search results — and this
// module only orchestrates client-local state (open/closed panels, the
// search dropdown) and swaps in the server-rendered search partial.
function toggleSidebar(): void {
const sidebar = document.getElementById("docs-sidebar");
const overlay = document.getElementById("docs-overlay");
if (!sidebar || !overlay) return;
if (sidebar && overlay) {
sidebar.classList.toggle("translate-x-0");
sidebar.classList.toggle("-translate-x-full");
overlay.classList.toggle("hidden");
}
}
function toggleSection(el: HTMLElement): void {
const items = el.nextElementSibling as HTMLElement;
const arrow = el.querySelector(".section-arrow") as HTMLElement;
if (!items || !arrow) return;
function toggleCollapsible(header: HTMLElement): void {
const items = header.nextElementSibling as HTMLElement | null;
const arrow = header.querySelector<HTMLElement>(".section-arrow");
if (!items) return;
const isCollapsed = items.getAttribute("data-collapsed") === "true";
if (isCollapsed) {
items.classList.remove("hidden");
items.setAttribute("data-collapsed", "false");
arrow.style.transform = "rotate(0deg)";
} else {
items.classList.add("hidden");
items.setAttribute("data-collapsed", "true");
arrow.style.transform = "rotate(-90deg)";
items.classList.toggle("hidden", !isCollapsed);
items.setAttribute("data-collapsed", isCollapsed ? "false" : "true");
if (arrow) {
arrow.style.transform = isCollapsed ? "rotate(0deg)" : "rotate(-90deg)";
}
}
function initializeDocsSearch(): void {
const searchInput = document.getElementById("docs-search") as HTMLInputElement;
const searchResults = document.getElementById("docs-search-results");
if (!searchInput || !searchResults) return;
let docsSearchTimeout: ReturnType<typeof setTimeout> | null = null;
let docsSearchAbort: AbortController | null = null;
function initializeDocsSearch(): void {
const searchInput = document.getElementById("docs-search") as HTMLInputElement | null;
const searchResults = document.getElementById("docs-search-results");
if (!searchInput || !searchResults) return;
searchInput.addEventListener("input", () => {
const query = searchInput.value.trim();
@@ -45,118 +45,77 @@ function initializeDocsSearch(): void {
}
if (query.length < 2) {
searchResults.innerHTML = "";
searchResults.classList.add("hidden");
hideDocsSearchResults();
return;
}
docsSearchTimeout = setTimeout(() => {
performDocsSearch(query);
}, 300);
void performDocsSearch(query);
}, 250);
});
}
function performDocsSearch(query: string): void {
function hideDocsSearchResults(): void {
const searchResults = document.getElementById("docs-search-results");
if (!searchResults) return;
searchResults.replaceChildren();
searchResults.classList.add("hidden");
}
// performDocsSearch fetches the server-rendered results partial and swaps it
// in verbatim. The partial is templ-rendered (DocsSearchResults); this module
// never builds result markup itself.
async function performDocsSearch(query: string): Promise<void> {
const searchResults = document.getElementById("docs-search-results");
if (!searchResults) return;
if (!(window as any).lunr) {
console.warn("Lunr.js not loaded");
return;
}
docsSearchAbort?.abort();
docsSearchAbort = new AbortController();
try {
const idx = (window as any).lunrIndex;
if (!idx) {
searchResults.innerHTML =
'<p class="p-2 text-sm" style="color: var(--text-secondary)">Search index not loaded</p>';
searchResults.classList.remove("hidden");
return;
const response = await fetch(`/docs/search/partial?q=${encodeURIComponent(query)}`, {
signal: docsSearchAbort.signal,
});
if (!response.ok) {
throw new Error(`search failed: ${response.status}`);
}
const results = idx.search(query);
if (results.length === 0) {
searchResults.innerHTML =
'<p class="p-2 text-sm" style="color: var(--text-secondary)">No results found</p>';
} else {
searchResults.innerHTML = results
.slice(0, 10)
.map((result: { ref: string }) => {
const doc = (window as any).docsData?.[result.ref];
if (!doc) return "";
return `
<a href="${result.ref}" class="block p-2 hover:bg-opacity-50 transition-colors" style="background-color: var(--bg-secondary)">
<p class="font-medium text-sm" style="color: var(--text-primary)">${doc.title || result.ref}</p>
${doc.section ? `<p class="text-xs" style="color: var(--text-secondary)">${doc.section}</p>` : ""}
</a>
`;
})
.join("");
}
searchResults.innerHTML = await response.text();
searchResults.classList.remove("hidden");
} catch (error) {
console.error("Search error:", error);
searchResults.innerHTML =
'<p class="p-2 text-sm" style="color: var(--text-secondary)">Search error</p>';
if (error instanceof DOMException && error.name === "AbortError") return;
console.error("Docs search error:", error);
searchResults.textContent = "Search failed — try again.";
searchResults.classList.add("px-4", "py-3", "text-sm");
searchResults.style.color = "var(--text-secondary)";
searchResults.classList.remove("hidden");
}
}
function highlightCurrentPage(): void {
const currentPath = window.location.pathname;
document.querySelectorAll(".nav-item").forEach((item) => {
if (item.getAttribute("href") === currentPath) {
item.classList.add("text-accent", "bg-accent/10", "border-r-2", "border-accent");
}
});
}
function initializeCodeCopyButtons(): void {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const hljs = (window as any).hljs;
if (hljs) {
hljs.highlightAll();
document.querySelectorAll("pre code").forEach((block) => {
const pre = block.parentElement;
if (!pre) return;
document.querySelectorAll(".prose pre").forEach((pre) => {
const wrapper = document.createElement("div");
wrapper.className = "relative group";
pre.parentNode.insertBefore(wrapper, pre);
pre.parentNode?.insertBefore(wrapper, pre);
wrapper.appendChild(pre);
const button = document.createElement("button");
button.className =
"absolute top-2 right-2 bg-background-secondary text-text-secondary px-2 py-1 rounded text-xs opacity-0 group-hover:opacity-100 transition-opacity border border-border hover:text-accent";
"absolute top-2 right-2 btn px-2 py-1 rounded-lg text-xs opacity-0 group-hover:opacity-100 transition-opacity";
button.textContent = "Copy";
button.onclick = async () => {
await navigator.clipboard.writeText(block.textContent || "");
button.addEventListener("click", async () => {
await navigator.clipboard.writeText(pre.textContent || "");
button.textContent = "Copied!";
setTimeout(() => {
button.textContent = "Copy";
}, 2000);
};
});
wrapper.appendChild(button);
});
}
}
Alpine.data("docs", () => ({
toggleSidebar,
toggleSection,
toggleCollapsible,
initializeSearch: initializeDocsSearch,
highlightCurrentPage,
initializeCodeCopyButtons,
}));
export {
toggleSidebar,
toggleSection,
initializeDocsSearch,
highlightCurrentPage,
initializeCodeCopyButtons,
};
+1 -1
View File
@@ -12,7 +12,7 @@ web/src/collection-rules.ts:6
web/src/collections.ts:5
web/src/custom-section-builder.ts:10
web/src/device-management.ts:4
web/src/docs.ts:5
web/src/docs.ts:1
web/src/dashboard.ts:2
web/src/linking.ts:3
web/src/markdown.ts:1
+1 -1
View File
@@ -1,2 +1,2 @@
*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }
/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}input:where(:not([type])),input:where([type=date]),input:where([type=datetime-local]),input:where([type=email]),input:where([type=month]),input:where([type=number]),input:where([type=password]),input:where([type=search]),input:where([type=tel]),input:where([type=text]),input:where([type=time]),input:where([type=url]),input:where([type=week]),select,select:where([multiple]),textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}input:where(:not([type])):focus,input:where([type=date]):focus,input:where([type=datetime-local]):focus,input:where([type=email]):focus,input:where([type=month]):focus,input:where([type=number]):focus,input:where([type=password]):focus,input:where([type=search]):focus,input:where([type=tel]):focus,input:where([type=text]):focus,input:where([type=time]):focus,input:where([type=url]):focus,input:where([type=week]):focus,select:focus,select:where([multiple]):focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath Line truncated
/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}input:where(:not([type])),input:where([type=date]),input:where([type=datetime-local]),input:where([type=email]),input:where([type=month]),input:where([type=number]),input:where([type=password]),input:where([type=search]),input:where([type=tel]),input:where([type=text]),input:where([type=time]),input:where([type=url]),input:where([type=week]),select,select:where([multiple]),textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}input:where(:not([type])):focus,input:where([type=date]):focus,input:where([type=datetime-local]):focus,input:where([type=email]):focus,input:where([type=month]):focus,input:where([type=number]):focus,input:where([type=password]):focus,input:where([type=search]):focus,input:where([type=tel]):focus,input:where([type=text]):focus,input:where([type=time]):focus,input:where([type=url]):focus,input:where([type=week]):focus,select:focus,select:where([multiple]):focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath Line truncated