docs: add interactive documentation system with Go+HTMX

- Add internal/docs package with markdown renderer (goldmark)
- Create docs layout template with sidebar navigation
- Implement hierarchical navigation auto-generated from docs folder
- Add table of contents generator (extract ## headings)
- Add syntax highlighting for code blocks (highlight.js)
- Add mobile responsive design
- Add /docs routes to main.go

The documentation system features:
- Dark theme matching app design
- Collapsible sidebar sections (Getting Started, User Guide, Device Setup, API Reference, Contributing)
- Table of contents for each page
- Breadcrumb navigation
- Full-text search (client-side JavaScript, API endpoint ready)
- Syntax highlighting for code blocks
- Mobile-friendly with hamburger menu

All documentation is served from /docs route, no authentication required.
Markdown files are rendered using goldmark with GFM extensions and syntax highlighting.
This commit is contained in:
2026-02-01 18:33:55 -05:00
parent 555bd0df15
commit 5c7137feb8
9 changed files with 1439 additions and 0 deletions
+229
View File
@@ -0,0 +1,229 @@
package docs
import (
"bytes"
"fmt"
"io/fs"
"os"
"strings"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark-highlighting"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer/html"
"bookhoard/templates"
)
type DocsHandler struct {
markdown goldmark.Markdown
docsPath string
docsFS fs.FS
}
func NewDocsHandler(docsPath string) *DocsHandler {
// Create goldmark renderer with syntax highlighting
md := goldmark.New(
goldmark.WithExtensions(
extension.GFM,
extension.Table,
highlighting.NewHighlighting(
highlighting.WithStyle("github"),
),
),
goldmark.WithParserOptions(
parser.WithAutoHeadingID(),
),
goldmark.WithRendererOptions(
html.WithHardWraps(),
html.WithXHTML(),
html.WithUnsafe(),
),
)
return &DocsHandler{
markdown: md,
docsPath: docsPath,
docsFS: os.DirFS(docsPath),
}
}
// LoadDocument loads and renders a markdown document
func (h *DocsHandler) LoadDocument(docPath string) (*templates.Document, error) {
// Clean the path
docPath = strings.TrimPrefix(docPath, "/")
docPath = strings.TrimSuffix(docPath, "/")
// Default to INDEX.md if root
if docPath == "" || docPath == "docs" {
docPath = "INDEX.md"
} else {
// Remove /docs prefix if present
docPath = strings.TrimPrefix(docPath, "docs/")
// Add .md if not present
if !strings.HasSuffix(docPath, ".md") {
docPath += ".md"
}
}
// Read the markdown file
content, err := fs.ReadFile(h.docsFS, docPath)
if err != nil {
return nil, fmt.Errorf("failed to read document: %w", err)
}
// Convert markdown to HTML
var buf bytes.Buffer
context := parser.NewContext()
if err := h.markdown.Convert([]byte(content), &buf, parser.WithContext(context)); err != nil {
return nil, fmt.Errorf("failed to convert markdown: %w", err)
}
// Extract title from first heading
title := h.extractTitle(content)
// Generate table of contents
toc := h.generateTOC(content)
// Generate breadcrumb
breadcrumb := h.generateBreadcrumb(docPath)
// Determine category
category := h.getCategory(docPath)
return &templates.Document{
Title: title,
Content: buf.String(),
TOC: toc,
Breadcrumb: breadcrumb,
Category: category,
SourceFile: docPath,
}, nil
}
// extractTitle extracts the first heading from markdown content
func (h *DocsHandler) extractTitle(content []byte) string {
lines := bytes.Split(content, []byte("\n"))
for _, line := range lines {
line = bytes.TrimSpace(line)
if bytes.HasPrefix(line, []byte("# ")) {
return string(bytes.TrimPrefix(line, []byte("# ")))
}
}
return "Documentation"
}
// generateTOC generates a table of contents from markdown headings
func (h *DocsHandler) generateTOC(content []byte) []templates.TOCItem {
var toc []templates.TOCItem
lines := bytes.Split(content, []byte("\n"))
for _, line := range lines {
line = bytes.TrimSpace(line)
if bytes.HasPrefix(line, []byte("#")) && !bytes.HasPrefix(line, []byte("# ")) {
// Count heading level
level := 0
for _, c := range line {
if c == '#' {
level++
} else {
break
}
}
if level > 1 && level <= 4 { // Only ## ### ####
title := strings.TrimSpace(string(bytes.TrimLeft(line, "#")))
anchor := h.slugify(title)
toc = append(toc, templates.TOCItem{
Level: level,
Title: title,
Anchor: anchor,
})
}
}
}
return toc
}
// generateBreadcrumb generates breadcrumb navigation
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 {
breadcrumb = append(breadcrumb, templates.BreadcrumbItem{
Title: strings.Title(strings.ReplaceAll(part, "-", " ")),
URL: "/docs" + path,
})
}
}
return breadcrumb
}
// getCategory determines the category of a document
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"
}
// slugify converts a string to a URL-safe slug
func (h *DocsHandler) slugify(s string) string {
s = strings.ToLower(s)
s = strings.ReplaceAll(s, " ", "-")
s = strings.ReplaceAll(s, "/", "-")
// Remove non-alphanumeric characters (except hyphens)
var result strings.Builder
for _, c := range s {
if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' {
result.WriteRune(c)
}
}
return result.String()
}
// ListDocuments returns all markdown files in the docs directory
func (h *DocsHandler) ListDocuments() ([]string, error) {
var docs []string
err := fs.WalkDir(h.docsFS, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
if strings.HasSuffix(path, ".md") {
docs = append(docs, strings.TrimSuffix(path, ".md"))
}
return nil
})
return docs, err
}
+268
View File
@@ -0,0 +1,268 @@
package docs
import (
"bytes"
"fmt"
"net/http"
"strings"
"bookhoard/templates"
"github.com/labstack/echo/v4"
)
// HTTPHandler wraps the docs handler for Echo framework
type HTTPHandler struct {
docs *DocsHandler
}
// NewHTTPHandler creates a new docs HTTP handler
func NewHTTPHandler(docsPath string) *HTTPHandler {
return &HTTPHandler{
docs: NewDocsHandler(docsPath),
}
}
// ShowDocumentation renders a documentation page
func (h *HTTPHandler) ShowDocumentation(c echo.Context) error {
// Get the document path from URL parameter
docPath := c.Param("*")
// Load the document
doc, err := h.docs.LoadDocument(docPath)
if err != nil {
// Try to load as API endpoint
if strings.HasPrefix(docPath, "api/") {
return h.ShowAPIEndpoint(c, strings.TrimPrefix(docPath, "api/"))
}
// Return 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)
}
// Get navigation
nav := h.docs.BuildNavigation()
// Create empty user (docs are public, no auth required)
user := templates.User{
ID: "",
Username: "",
Email: "",
Role: "",
Theme: "tokyo-night",
}
// Render documentation page
var buf bytes.Buffer
err = templates.DocsLayout(*nav, *doc, user).Render(c.Request().Context(), &buf)
if err != nil {
fmt.Printf("DOCS RENDER ERROR: %v\n", err)
return c.HTML(http.StatusInternalServerError, "Failed to render page: "+err.Error())
}
fmt.Printf("DOCS: Successfully rendered %s (%d bytes)\n", docPath, buf.Len())
return c.HTML(http.StatusOK, buf.String())
}
// ShowAPIEndpoint shows a specific API endpoint documentation
func (h *HTTPHandler) ShowAPIEndpoint(c echo.Context, endpointPath string) 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()
// Create empty user
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).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()
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: "API Reference", URL: ""},
},
Category: "API Reference",
SourceFile: "API_REFERENCE.md",
}
var buf bytes.Buffer
err := templates.DocsLayout(*nav, *apiDoc, user).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
func (h *HTTPHandler) Search(c echo.Context) error {
query := c.QueryParam("q")
if query == "" {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "query parameter 'q' is required",
})
}
// Get all documents
docs, err := h.docs.ListDocuments()
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": "failed to list documents",
})
}
// 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),
})
}
// SearchResult represents a search result
type SearchResult struct {
Title string
URL string
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",
})
}
+230
View File
@@ -0,0 +1,230 @@
package docs
import (
"sort"
"strings"
"bookhoard/templates"
)
// BuildNavigation creates the navigation structure from the docs filesystem
func (h *DocsHandler) BuildNavigation() *templates.Navigation {
docs, err := h.ListDocuments()
if err != nil {
// Return minimal navigation on error
return &templates.Navigation{
Sections: []templates.NavSection{
{
Title: "Getting Started",
Items: []templates.NavItem{
{Title: "Overview", URL: "/docs"},
},
},
},
}
}
// Group documents by category
categories := make(map[string][]templates.NavItem)
for _, doc := range docs {
item := h.createNavItem(doc)
category := h.getCategoryForNav(doc)
categories[category] = append(categories[category], item)
}
// Build navigation sections
var sections []templates.NavSection
// Define section order
sectionOrder := []string{
"Getting Started",
"User Guide",
"Device Setup",
"API Reference",
"Contributing",
}
// 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
})
delete(categories, sectionTitle)
}
}
// Add any remaining sections
for title, items := range categories {
sections = append(sections, templates.NavSection{
Title: title,
Items: items,
Collapsed: true,
})
}
return &templates.Navigation{Sections: sections}
}
// 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,
}
}
// 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 = strings.Title(strings.ReplaceAll(filename, "-", " "))
// Handle special cases
switch filename {
case "INDEX.md":
return "Documentation Index"
case "API_REFERENCE.md":
return "API Reference"
case "TROUBLESHOOTING.md":
return "Troubleshooting"
case "SYNC_USER_GUIDE.md":
return "Sync Guide"
case "DEVELOPMENT.md":
return "Development Guide"
case "WEBSOCKET_API.md":
return "WebSocket API"
case "COLLECTIONS_API.md":
return "Collections API"
case "KOBO_SETUP.md":
return "Kobo Setup"
case "KOREADER_SETUP.md":
return "KOReader Setup"
}
return title
}
// 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 "📄"
}
// 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"
}
}
// 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
}