Files
mcdoc/internal/render/render.go
Kyle Isom 28afaa2c56 Implement mcdoc v0.1.0: public documentation server
Single-binary Go server that fetches markdown from Gitea (mc org),
renders to HTML with goldmark (GFM, chroma syntax highlighting,
heading anchors), and serves a navigable read-only documentation site.

Features:
- Boot fetch with retry, webhook refresh, 15-minute poll fallback
- In-memory cache with atomic per-repo swap
- chi router with htmx partial responses for SPA-like navigation
- HMAC-SHA256 webhook validation
- Responsive CSS, TOC generation, priority doc ordering
- $PORT env var support for MCP agent port assignment

33 tests across config, cache, render, and server packages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 13:04:15 -07:00

124 lines
2.7 KiB
Go

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
}