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) } // Debug: Log the raw HTML output from goldmark htmlOutput := buf.String() fmt.Printf("DEBUG: Goldmark output length: %d, first 200 chars: %q\n", len(htmlOutput), htmlOutput[:min(200, len(htmlOutput))]) // Extract title from first heading title := h.extractTitle(content) // 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 }