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:
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"bookhoard/internal/config"
|
"bookhoard/internal/config"
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
|
"bookhoard/internal/docs"
|
||||||
"bookhoard/internal/handlers"
|
"bookhoard/internal/handlers"
|
||||||
"bookhoard/internal/middleware"
|
"bookhoard/internal/middleware"
|
||||||
ratelimit "bookhoard/internal/middleware"
|
ratelimit "bookhoard/internal/middleware"
|
||||||
@@ -601,6 +602,12 @@ func main() {
|
|||||||
return c.HTML(http.StatusOK, buf.String())
|
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
|
// Start server
|
||||||
log.Printf("Starting server on port %s", cfg.ServerPort)
|
log.Printf("Starting server on port %s", cfg.ServerPort)
|
||||||
e.Logger.Fatal(e.Start(":" + cfg.ServerPort))
|
e.Logger.Fatal(e.Start(":" + cfg.ServerPort))
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/alecthomas/chroma v0.10.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // 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/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.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/rogpeppe/go-internal v1.14.1 // indirect
|
||||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
github.com/valyala/fasttemplate v1.2.2 // 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/net v0.47.0 // indirect
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.39.0 // indirect
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
|
|||||||
@@ -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/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 h1:kiKAPXTZE2Iaf8JbtM21r54A8bCNsncrfnokZZSrSDg=
|
||||||
github.com/a-h/templ v0.3.977/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
|
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/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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
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/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 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||||
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
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 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{ doc.Title } - Bookhoard Documentation</title>
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg-primary: #1a1b26;
|
||||||
|
--bg-secondary: #24283b;
|
||||||
|
--text-primary: #c0caf5;
|
||||||
|
--text-secondary: #9aa5ce;
|
||||||
|
--border: #414868;
|
||||||
|
--accent: #7aa2f7;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 280px;
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
.main-content {
|
||||||
|
margin-left: 280px;
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 900px;
|
||||||
|
}
|
||||||
|
.nav-section {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.nav-section-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.nav-item {
|
||||||
|
display: block;
|
||||||
|
padding: 0.5rem 1rem 0.5rem 2rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
.nav-item:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.nav-item.active {
|
||||||
|
color: var(--accent);
|
||||||
|
background-color: rgba(122, 162, 247, 0.1);
|
||||||
|
border-right: 2px solid var(--accent);
|
||||||
|
}
|
||||||
|
pre {
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
code {
|
||||||
|
background-color: rgba(122, 162, 247, 0.1);
|
||||||
|
padding: 0.125rem 0.25rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.875em;
|
||||||
|
}
|
||||||
|
.breadcrumb {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
.breadcrumb a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.toc {
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.toc-item {
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
display: block;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
.toc-item:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.search-box {
|
||||||
|
padding: 1rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.search-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
.search-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.mobile-menu-button {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 1rem;
|
||||||
|
left: 1rem;
|
||||||
|
z-index: 100;
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.sidebar {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform 0.3s;
|
||||||
|
}
|
||||||
|
.sidebar.open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
.main-content {
|
||||||
|
margin-left: 0;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
.mobile-menu-button {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<button class="mobile-menu-button" onclick="toggleSidebar()">
|
||||||
|
☰
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<div class="sidebar" id="sidebar">
|
||||||
|
<!-- Search -->
|
||||||
|
<div class="search-box">
|
||||||
|
<input type="text"
|
||||||
|
class="search-input"
|
||||||
|
placeholder="Search documentation..."
|
||||||
|
id="docs-search"
|
||||||
|
oninput="searchDocs(this.value)">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navigation Sections -->
|
||||||
|
for _, section := range nav.Sections {
|
||||||
|
<div class="nav-section">
|
||||||
|
<div class="nav-section-title" onclick="toggleSection(this)">
|
||||||
|
{ section.Title }
|
||||||
|
</div>
|
||||||
|
if section.Collapsed {
|
||||||
|
<div class="nav-items" style="display: none;">
|
||||||
|
for _, item := range section.Items {
|
||||||
|
<a href={ item.URL } class="nav-item">
|
||||||
|
{ item.Icon } { item.Title }
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="nav-items">
|
||||||
|
for _, item := range section.Items {
|
||||||
|
<a href={ item.URL } class="nav-item">
|
||||||
|
{ item.Icon } { item.Title }
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<div class="main-content">
|
||||||
|
<!-- Breadcrumb -->
|
||||||
|
if len(doc.Breadcrumb) > 0 {
|
||||||
|
<div class="breadcrumb">
|
||||||
|
for i, crumb := range doc.Breadcrumb {
|
||||||
|
if i > 0 {
|
||||||
|
<span>›</span>
|
||||||
|
}
|
||||||
|
<a href={ crumb.URL }>{ crumb.Title }</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Title -->
|
||||||
|
<h1 style="margin-bottom: 2rem;">{ doc.Title }</h1>
|
||||||
|
|
||||||
|
<!-- Table of Contents -->
|
||||||
|
if len(doc.TOC) > 0 {
|
||||||
|
<div class="toc">
|
||||||
|
<strong style="display: block; margin-bottom: 0.5rem; color: var(--text-primary);">On this page</strong>
|
||||||
|
for _, item := range doc.TOC {
|
||||||
|
<a href={ "#" + item.Anchor } class="toc-item" style={ "margin-left: " + fmt.Sprintf("%drem", item.Level) }>
|
||||||
|
{ item.Title }
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div>
|
||||||
|
@rawHTML(doc.Content)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Toggle sidebar section
|
||||||
|
function toggleSection(el) {
|
||||||
|
const items = el.nextElementSibling;
|
||||||
|
if (items.style.display === 'none') {
|
||||||
|
items.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
items.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle sidebar on mobile
|
||||||
|
function toggleSidebar() {
|
||||||
|
document.getElementById('sidebar').classList.toggle('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Highlight current page in nav
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const currentPath = window.location.pathname;
|
||||||
|
document.querySelectorAll('.nav-item').forEach(item => {
|
||||||
|
if (item.getAttribute('href') === currentPath) {
|
||||||
|
item.classList.add('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize syntax highlighting
|
||||||
|
hljs.highlightAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Search functionality
|
||||||
|
async function searchDocs(query) {
|
||||||
|
if (query.length < 2) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/docs/api/search?q=' + encodeURIComponent(query));
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Display search results (you can enhance this)
|
||||||
|
console.log('Search results:', data.results);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Search failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
}
|
||||||
@@ -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, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>")
|
||||||
|
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</title><link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css\"><script src=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js\"></script><style>\n\t\t\t\t:root {\n\t\t\t\t\t--bg-primary: #1a1b26;\n\t\t\t\t\t--bg-secondary: #24283b;\n\t\t\t\t\t--text-primary: #c0caf5;\n\t\t\t\t\t--text-secondary: #9aa5ce;\n\t\t\t\t\t--border: #414868;\n\t\t\t\t\t--accent: #7aa2f7;\n\t\t\t\t}\n\t\t\t\tbody {\n\t\t\t\t\tfont-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif;\n\t\t\t\t\tbackground-color: var(--bg-primary);\n\t\t\t\t\tcolor: var(--text-primary);\n\t\t\t\t\tmargin: 0;\n\t\t\t\t\tpadding: 0;\n\t\t\t\t}\n\t\t\t\t.sidebar {\n\t\t\t\t\tposition: fixed;\n\t\t\t\t\tleft: 0;\n\t\t\t\t\ttop: 0;\n\t\t\t\t\tbottom: 0;\n\t\t\t\t\twidth: 280px;\n\t\t\t\t\tbackground-color: var(--bg-secondary);\n\t\t\t\t\tborder-right: 1px solid var(--border);\n\t\t\t\t\toverflow-y: auto;\n\t\t\t\t\tz-index: 50;\n\t\t\t\t}\n\t\t\t\t.main-content {\n\t\t\t\t\tmargin-left: 280px;\n\t\t\t\t\tpadding: 2rem;\n\t\t\t\t\tmax-width: 900px;\n\t\t\t\t}\n\t\t\t\t.nav-section {\n\t\t\t\t\tmargin-bottom: 1.5rem;\n\t\t\t\t}\n\t\t\t\t.nav-section-title {\n\t\t\t\t\tfont-weight: 600;\n\t\t\t\t\tfont-size: 0.875rem;\n\t\t\t\t\ttext-transform: uppercase;\n\t\t\t\t\tletter-spacing: 0.05em;\n\t\t\t\t\tcolor: var(--text-secondary);\n\t\t\t\t\tpadding: 0.75rem 1rem;\n\t\t\t\t\tcursor: pointer;\n\t\t\t\t\tuser-select: none;\n\t\t\t\t}\n\t\t\t\t.nav-item {\n\t\t\t\t\tdisplay: block;\n\t\t\t\t\tpadding: 0.5rem 1rem 0.5rem 2rem;\n\t\t\t\t\tcolor: var(--text-secondary);\n\t\t\t\t\ttext-decoration: none;\n\t\t\t\t\tfont-size: 0.875rem;\n\t\t\t\t\ttransition: color 0.2s;\n\t\t\t\t}\n\t\t\t\t.nav-item:hover {\n\t\t\t\t\tcolor: var(--accent);\n\t\t\t\t}\n\t\t\t\t.nav-item.active {\n\t\t\t\t\tcolor: var(--accent);\n\t\t\t\t\tbackground-color: rgba(122, 162, 247, 0.1);\n\t\t\t\t\tborder-right: 2px solid var(--accent);\n\t\t\t\t}\n\t\t\t\tpre {\n\t\t\t\t\tbackground-color: var(--bg-secondary);\n\t\t\t\t\tborder: 1px solid var(--border);\n\t\t\t\t\tborder-radius: 0.5rem;\n\t\t\t\t\tpadding: 1rem;\n\t\t\t\t\toverflow-x: auto;\n\t\t\t\t}\n\t\t\t\tcode {\n\t\t\t\t\tbackground-color: rgba(122, 162, 247, 0.1);\n\t\t\t\t\tpadding: 0.125rem 0.25rem;\n\t\t\t\t\tborder-radius: 0.25rem;\n\t\t\t\t\tfont-size: 0.875em;\n\t\t\t\t}\n\t\t\t\t.breadcrumb {\n\t\t\t\t\tdisplay: flex;\n\t\t\t\t\tgap: 0.5rem;\n\t\t\t\t\tfont-size: 0.875rem;\n\t\t\t\t\tcolor: var(--text-secondary);\n\t\t\t\t\tmargin-bottom: 2rem;\n\t\t\t\t}\n\t\t\t\t.breadcrumb a {\n\t\t\t\t\tcolor: var(--accent);\n\t\t\t\t\ttext-decoration: none;\n\t\t\t\t}\n\t\t\t\t.toc {\n\t\t\t\t\tbackground-color: var(--bg-secondary);\n\t\t\t\t\tpadding: 1rem;\n\t\t\t\t\tborder-radius: 0.5rem;\n\t\t\t\t\tmargin-bottom: 2rem;\n\t\t\t\t\tborder: 1px solid var(--border);\n\t\t\t\t}\n\t\t\t\t.toc-item {\n\t\t\t\t\tpadding: 0.25rem 0;\n\t\t\t\t\tdisplay: block;\n\t\t\t\t\tcolor: var(--text-secondary);\n\t\t\t\t\ttext-decoration: none;\n\t\t\t\t\tfont-size: 0.875rem;\n\t\t\t\t}\n\t\t\t\t.toc-item:hover {\n\t\t\t\t\tcolor: var(--accent);\n\t\t\t\t}\n\t\t\t\t.search-box {\n\t\t\t\t\tpadding: 1rem;\n\t\t\t\t\tborder-bottom: 1px solid var(--border);\n\t\t\t\t}\n\t\t\t\t.search-input {\n\t\t\t\t\twidth: 100%;\n\t\t\t\t\tpadding: 0.5rem 0.75rem;\n\t\t\t\t\tborder-radius: 0.375rem;\n\t\t\t\t\tbackground-color: var(--bg-primary);\n\t\t\t\t\tcolor: var(--text-primary);\n\t\t\t\t\tborder: 1px solid var(--border);\n\t\t\t\t\tfont-size: 0.875rem;\n\t\t\t\t}\n\t\t\t\t.search-input:focus {\n\t\t\t\t\toutline: none;\n\t\t\t\t\tborder-color: var(--accent);\n\t\t\t\t}\n\t\t\t\t.mobile-menu-button {\n\t\t\t\t\tdisplay: none;\n\t\t\t\t\tposition: fixed;\n\t\t\t\t\ttop: 1rem;\n\t\t\t\t\tleft: 1rem;\n\t\t\t\t\tz-index: 100;\n\t\t\t\t\tbackground-color: var(--bg-secondary);\n\t\t\t\t\tborder: 1px solid var(--border);\n\t\t\t\t\tborder-radius: 0.375rem;\n\t\t\t\t\tpadding: 0.5rem;\n\t\t\t\t\tcolor: var(--text-primary);\n\t\t\t\t\tcursor: pointer;\n\t\t\t\t}\n\t\t\t\t@media (max-width: 768px) {\n\t\t\t\t\t.sidebar {\n\t\t\t\t\t\ttransform: translateX(-100%);\n\t\t\t\t\t\ttransition: transform 0.3s;\n\t\t\t\t\t}\n\t\t\t\t\t.sidebar.open {\n\t\t\t\t\t\ttransform: translateX(0);\n\t\t\t\t\t}\n\t\t\t\t\t.main-content {\n\t\t\t\t\t\tmargin-left: 0;\n\t\t\t\t\t\tpadding: 1rem;\n\t\t\t\t\t}\n\t\t\t\t\t.mobile-menu-button {\n\t\t\t\t\t\tdisplay: block;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t</style></head><body><button class=\"mobile-menu-button\" onclick=\"toggleSidebar()\">☰</button><!-- Sidebar --><div class=\"sidebar\" id=\"sidebar\"><!-- Search --><div class=\"search-box\"><input type=\"text\" class=\"search-input\" placeholder=\"Search documentation...\" id=\"docs-search\" oninput=\"searchDocs(this.value)\"></div><!-- Navigation Sections -->")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
for _, section := range nav.Sections {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div class=\"nav-section\"><div class=\"nav-section-title\" onclick=\"toggleSection(this)\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var5 string
|
||||||
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 191, Col: 22}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if section.Collapsed {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<div class=\"nav-items\" style=\"display: none;\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
for _, item := range section.Items {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<a href=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var6 templ.SafeURL
|
||||||
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinURLErrs(item.URL)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 196, Col: 27}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\" class=\"nav-item\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var7 string
|
||||||
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(item.Icon)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 197, Col: 21}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, " ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var8 string
|
||||||
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 197, Col: 36}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</a>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<div class=\"nav-items\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
for _, item := range section.Items {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<a href=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var9 templ.SafeURL
|
||||||
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinURLErrs(item.URL)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 204, Col: 27}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" class=\"nav-item\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var10 string
|
||||||
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(item.Icon)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 205, Col: 21}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, " ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var11 string
|
||||||
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 205, Col: 36}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</a>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</div><!-- Main Content --><div class=\"main-content\"><!-- Breadcrumb -->")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if len(doc.Breadcrumb) > 0 {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<div class=\"breadcrumb\">")
|
||||||
|
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, "<span>›</span>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, " <a href=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var12 templ.SafeURL
|
||||||
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinURLErrs(crumb.URL)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 223, Col: 26}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\">")
|
||||||
|
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, "</a>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<!-- Title --><h1 style=\"margin-bottom: 2rem;\">")
|
||||||
|
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, "</h1><!-- Table of Contents -->")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if len(doc.TOC) > 0 {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<div class=\"toc\"><strong style=\"display: block; margin-bottom: 0.5rem; color: var(--text-primary);\">On this page</strong> ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
for _, item := range doc.TOC {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<a href=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var15 templ.SafeURL
|
||||||
|
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs("#" + item.Anchor)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 236, Col: 34}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" class=\"toc-item\" style=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var16 string
|
||||||
|
templ_7745c5c3_Var16, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("margin-left: " + fmt.Sprintf("%drem", item.Level))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 236, Col: 112}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\">")
|
||||||
|
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, "</a>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<!-- Content --><div>")
|
||||||
|
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, "</div></div><script>\n\t\t\t\t// Toggle sidebar section\n\t\t\t\tfunction toggleSection(el) {\n\t\t\t\t\tconst items = el.nextElementSibling;\n\t\t\t\t\tif (items.style.display === 'none') {\n\t\t\t\t\t\titems.style.display = 'block';\n\t\t\t\t\t} else {\n\t\t\t\t\t\titems.style.display = 'none';\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\t\n\t\t\t\t// Toggle sidebar on mobile\n\t\t\t\tfunction toggleSidebar() {\n\t\t\t\t\tdocument.getElementById('sidebar').classList.toggle('open');\n\t\t\t\t}\n\t\t\t\t\n\t\t\t\t// Highlight current page in nav\n\t\t\t\tdocument.addEventListener('DOMContentLoaded', function() {\n\t\t\t\t\tconst currentPath = window.location.pathname;\n\t\t\t\t\tdocument.querySelectorAll('.nav-item').forEach(item => {\n\t\t\t\t\t\tif (item.getAttribute('href') === currentPath) {\n\t\t\t\t\t\t\titem.classList.add('active');\n\t\t\t\t\t\t}\n\t\t\t\t\t});\n\t\t\t\t\t\n\t\t\t\t\t// Initialize syntax highlighting\n\t\t\t\t\thljs.highlightAll();\n\t\t\t\t});\n\t\t\t\t\n\t\t\t\t// Search functionality\n\t\t\t\tasync function searchDocs(query) {\n\t\t\t\t\tif (query.length < 2) return;\n\t\t\t\t\t\n\t\t\t\t\ttry {\n\t\t\t\t\t\tconst response = await fetch('/docs/api/search?q=' + encodeURIComponent(query));\n\t\t\t\t\t\tconst data = await response.json();\n\t\t\t\t\t\t\n\t\t\t\t\t\t// Display search results (you can enhance this)\n\t\t\t\t\t\tconsole.log('Search results:', data.results);\n\t\t\t\t\t} catch (error) {\n\t\t\t\t\t\tconsole.error('Search failed:', error);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t</script></body></html>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = templruntime.GeneratedTemplate
|
||||||
@@ -123,3 +123,40 @@ type PotentialMatchData struct {
|
|||||||
MatchMethod string
|
MatchMethod string
|
||||||
Confidence float64
|
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user