package docs import ( "bytes" "fmt" "io/fs" "os" "strings" "bookhoard/templates" "github.com/yuin/goldmark" highlighting "github.com/yuin/goldmark-highlighting" "github.com/yuin/goldmark/extension" "github.com/yuin/goldmark/parser" "github.com/yuin/goldmark/renderer/html" "golang.org/x/text/cases" "golang.org/x/text/language" ) 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( // Chroma renders code blocks with inline styles, so the // palette ships with the HTML — no client-side highlighter // needed. The docs are read on dark themes most of the time; // code blocks keep a dark surface under light themes too. highlighting.WithStyle("github-dark"), ), ), 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(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. Only "Docs" links out: // directory segments have no landing pages, so they render as plain text // (URL left empty). The current page is omitted — the h1 below shows it. func (h *DocsHandler) generateBreadcrumb(docPath string) []templates.BreadcrumbItem { parts := strings.Split(strings.TrimSuffix(docPath, ".md"), "/") breadcrumb := []templates.BreadcrumbItem{ {Title: "Docs", URL: "/docs"}, } for i, part := range parts { if part == "" { continue } if i == len(parts)-1 { break // current page } breadcrumb = append(breadcrumb, templates.BreadcrumbItem{ Title: cases.Title(language.English).String(strings.ReplaceAll(part, "-", " ")), }) } return breadcrumb } // getCategory determines the category of a document. It reuses the nav // routing (placeDoc) so search results and breadcrumbs name sections exactly // as the sidebar does. func (h *DocsHandler) getCategory(docPath string) string { return placeDoc(docPath).section } // 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 } // SearchDoc represents a document for search indexing type SearchDoc struct { ID string `json:"id"` Title string `json:"title"` Content string `json:"content"` URL string `json:"url"` } // GenerateSearchIndex generates a search index for all documentation func (h *DocsHandler) GenerateSearchIndex() ([]SearchDoc, error) { docs, err := h.ListDocuments() if err != nil { return nil, fmt.Errorf("failed to list documents: %w", err) } var searchDocs []SearchDoc for _, path := range docs { doc, err := h.LoadDocument(path) if err != nil { continue } // Strip HTML tags for better search content := h.stripHTML(doc.Content) searchDocs = append(searchDocs, SearchDoc{ ID: path, Title: doc.Title, Content: content, URL: "/docs/" + strings.TrimSuffix(path, ".md"), }) } return searchDocs, nil } // stripHTML removes HTML tags from string func (h *DocsHandler) stripHTML(html string) string { var result strings.Builder inTag := false for _, r := range html { if r == '<' { inTag = true continue } if r == '>' { inTag = false continue } if !inTag { result.WriteRune(r) } } return result.String() }