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>
124 lines
2.7 KiB
Go
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
|
|
}
|