diff --git a/cmd/server/main.go b/cmd/server/main.go
index 3fdee84..347b3e2 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -3,6 +3,7 @@ package main
import (
"bookhoard/internal/config"
"bookhoard/internal/database"
+ "bookhoard/internal/docs"
"bookhoard/internal/handlers"
"bookhoard/internal/middleware"
ratelimit "bookhoard/internal/middleware"
@@ -601,6 +602,12 @@ func main() {
return c.HTML(http.StatusOK, buf.String())
})
+ // Documentation routes (no authentication required)
+ docsHandler := docs.NewHTTPHandler("docs")
+ e.GET("/docs", docsHandler.DocsHome)
+ e.GET("/docs/*", docsHandler.ShowDocumentation)
+ e.GET("/docs/api/search", docsHandler.Search)
+
// Start server
log.Printf("Starting server on port %s", cfg.ServerPort)
e.Logger.Fatal(e.Start(":" + cfg.ServerPort))
diff --git a/go.mod b/go.mod
index dbdbf44..bfa55c6 100644
--- a/go.mod
+++ b/go.mod
@@ -19,7 +19,9 @@ require (
)
require (
+ github.com/alecthomas/chroma v0.10.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/dlclark/regexp2 v1.4.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
@@ -35,6 +37,8 @@ require (
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
+ github.com/yuin/goldmark v1.7.16 // indirect
+ github.com/yuin/goldmark-highlighting v0.0.0-20220208100518-594be1970594 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
diff --git a/go.sum b/go.sum
index 242f5c7..7b886ed 100644
--- a/go.sum
+++ b/go.sum
@@ -2,10 +2,14 @@ github.com/ArcadiaLin/go-epub v0.1.1 h1:13roe62tarrZ1Y1QTxE+Bzd/NKlChhRzegLVmU5H
github.com/ArcadiaLin/go-epub v0.1.1/go.mod h1:GY09AG6jnEbsYytkw6VeICLOt25F1GQDGXjYS7cxNhU=
github.com/a-h/templ v0.3.977 h1:kiKAPXTZE2Iaf8JbtM21r54A8bCNsncrfnokZZSrSDg=
github.com/a-h/templ v0.3.977/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
+github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek=
+github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E=
+github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
@@ -65,6 +69,11 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
+github.com/yuin/goldmark v1.4.5/go.mod h1:rmuwmfZ0+bvzB24eSC//bk1R1Zp3hM0OXYv/G2LIilg=
+github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
+github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
+github.com/yuin/goldmark-highlighting v0.0.0-20220208100518-594be1970594 h1:yHfZyN55+5dp1wG7wDKv8HQ044moxkyGq12KFFMFDxg=
+github.com/yuin/goldmark-highlighting v0.0.0-20220208100518-594be1970594/go.mod h1:U9ihbh+1ZN7fR5Se3daSPoz1CGF9IYtSvWwVQtnzGHU=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
diff --git a/internal/docs/handler.go b/internal/docs/handler.go
new file mode 100644
index 0000000..ffc675f
--- /dev/null
+++ b/internal/docs/handler.go
@@ -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
+}
diff --git a/internal/docs/http_handler.go b/internal/docs/http_handler.go
new file mode 100644
index 0000000..800e432
--- /dev/null
+++ b/internal/docs/http_handler.go
@@ -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(`
+
+
+
+ Document Not Found - Bookhoard
+
+
+
+
+
Document Not Found
+
%s
+
Return to Documentation
+
+
+`, 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 := `
+
+
+
+ API Endpoint Not Found - Bookhoard
+
+
+
+
+
API Endpoint Not Found
+
Return to API Documentation
+
+
+`
+
+ 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("Documentation for %s %s
", 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 := "API Endpoints
Complete API reference for Bookhoard v1.0.
"
+ for _, ep := range endpoints {
+ content += fmt.Sprintf(`- %s %s - %s
`,
+ strings.TrimPrefix(ep.Path, "/api"),
+ ep.Method,
+ ep.Path,
+ ep.Title,
+ )
+ }
+ content += "
"
+
+ 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",
+ })
+}
diff --git a/internal/docs/navigation.go b/internal/docs/navigation.go
new file mode 100644
index 0000000..58a80d9
--- /dev/null
+++ b/internal/docs/navigation.go
@@ -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
+}
diff --git a/templates/docs.templ b/templates/docs.templ
new file mode 100644
index 0000000..5ca5dac
--- /dev/null
+++ b/templates/docs.templ
@@ -0,0 +1,295 @@
+package templates
+
+import (
+ "fmt"
+ "html/template"
+)
+
+templ rawHTML(content string) {
+ { template.HTML(content) }
+}
+
+templ DocsLayout(nav Navigation, doc Document, user User) {
+
+
+
+
+
+ { doc.Title } - Bookhoard Documentation
+
+
+
+
+
+
+
+
+
+
+
+
+
+ if len(doc.Breadcrumb) > 0 {
+
+ for i, crumb := range doc.Breadcrumb {
+ if i > 0 {
+
›
+ }
+
{ crumb.Title }
+ }
+
+ }
+
+
+
{ doc.Title }
+
+
+ if len(doc.TOC) > 0 {
+
+ }
+
+
+
+ @rawHTML(doc.Content)
+
+
+
+
+
+
+}
diff --git a/templates/docs_templ.go b/templates/docs_templ.go
new file mode 100644
index 0000000..62480b3
--- /dev/null
+++ b/templates/docs_templ.go
@@ -0,0 +1,360 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.3.977
+package templates
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import templruntime "github.com/a-h/templ/runtime"
+
+import (
+ "fmt"
+ "html/template"
+)
+
+func rawHTML(content string) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var1 == nil {
+ templ_7745c5c3_Var1 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ var templ_7745c5c3_Var2 string
+ templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(template.HTML(content))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 9, Col: 25}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+func DocsLayout(nav Navigation, doc Document, user User) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var3 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var3 == nil {
+ templ_7745c5c3_Var3 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var4 string
+ templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(doc.Title)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 18, Col: 21}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " - Bookhoard Documentation")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if len(doc.Breadcrumb) > 0 {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ for i, crumb := range doc.Breadcrumb {
+ if i > 0 {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
›")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var13 string
+ templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(crumb.Title)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 223, Col: 42}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var14 string
+ templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(doc.Title)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 229, Col: 48}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if len(doc.TOC) > 0 {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "
On this page ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ for _, item := range doc.TOC {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var17 string
+ templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 237, Col: 20}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = rawHTML(doc.Content).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+var _ = templruntime.GeneratedTemplate
diff --git a/templates/types.go b/templates/types.go
index 9605fb2..b9adc6a 100644
--- a/templates/types.go
+++ b/templates/types.go
@@ -123,3 +123,40 @@ type PotentialMatchData struct {
MatchMethod string
Confidence float64
}
+
+// Documentation types
+type Navigation struct {
+ Sections []NavSection
+}
+
+type NavSection struct {
+ Title string
+ Items []NavItem
+ Collapsed bool
+}
+
+type NavItem struct {
+ Title string
+ URL string
+ Icon string
+}
+
+type Document struct {
+ Title string
+ Content string
+ TOC []TOCItem
+ Breadcrumb []BreadcrumbItem
+ Category string
+ SourceFile string
+}
+
+type TOCItem struct {
+ Level int
+ Title string
+ Anchor string
+}
+
+type BreadcrumbItem struct {
+ Title string
+ URL string
+}