package render import ( "bytes" "fmt" "regexp" "strings" chromahtml "github.com/alecthomas/chroma/v2/formatters/html" "github.com/yuin/goldmark" highlighting "github.com/yuin/goldmark-highlighting/v2" "github.com/yuin/goldmark/ast" "github.com/yuin/goldmark/extension" "github.com/yuin/goldmark/parser" "github.com/yuin/goldmark/renderer/html" "github.com/yuin/goldmark/text" ) // Heading represents a heading extracted from a document for TOC generation. type Heading struct { Level int ID string Text string } // Result holds the rendered HTML and extracted metadata. type Result struct { HTML string Headings []Heading } // Renderer converts markdown to HTML using goldmark. type Renderer struct { md goldmark.Markdown } // New creates a Renderer with GFM, syntax highlighting, and heading anchors. func New() *Renderer { md := goldmark.New( goldmark.WithExtensions( extension.GFM, highlighting.NewHighlighting( highlighting.WithStyle("github"), highlighting.WithFormatOptions( chromahtml.WithClasses(true), ), ), ), goldmark.WithParserOptions( parser.WithAutoHeadingID(), ), goldmark.WithRendererOptions( html.WithUnsafe(), ), ) return &Renderer{md: md} } // Render converts markdown source to HTML and extracts headings. func (r *Renderer) Render(source []byte) (*Result, error) { var buf bytes.Buffer if err := r.md.Convert(source, &buf); err != nil { return nil, fmt.Errorf("render markdown: %w", err) } headings := extractHeadings(source, r.md.Parser()) return &Result{ HTML: buf.String(), Headings: headings, }, nil } func extractHeadings(source []byte, p parser.Parser) []Heading { reader := text.NewReader(source) doc := p.Parse(reader) var headings []Heading _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) { if !entering { return ast.WalkContinue, nil } heading, ok := n.(*ast.Heading) if !ok { return ast.WalkContinue, nil } id, _ := n.AttributeString("id") idStr := "" if idBytes, ok := id.([]byte); ok { idStr = string(idBytes) } var textBuf bytes.Buffer for child := n.FirstChild(); child != nil; child = child.NextSibling() { if t, ok := child.(*ast.Text); ok { textBuf.Write(t.Segment.Value(source)) } } headings = append(headings, Heading{ Level: heading.Level, ID: idStr, Text: textBuf.String(), }) return ast.WalkContinue, nil }) return headings } var nonAlphanumeric = regexp.MustCompile(`[^a-z0-9]+`) // Slugify creates a URL-safe slug from text, matching goldmark's auto heading IDs. func Slugify(text string) string { text = strings.ToLower(text) text = nonAlphanumeric.ReplaceAllString(text, "-") text = strings.Trim(text, "-") return text }