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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user