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>
This commit is contained in:
123
internal/render/render.go
Normal file
123
internal/render/render.go
Normal file
@@ -0,0 +1,123 @@
|
||||
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
|
||||
}
|
||||
99
internal/render/render_test.go
Normal file
99
internal/render/render_test.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package render
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRenderBasicMarkdown(t *testing.T) {
|
||||
r := New()
|
||||
result, err := r.Render([]byte("# Hello\n\nThis is a paragraph."))
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
if !strings.Contains(result.HTML, "<h1") {
|
||||
t.Fatalf("expected h1 tag, got: %s", result.HTML)
|
||||
}
|
||||
if !strings.Contains(result.HTML, "Hello") {
|
||||
t.Fatalf("expected heading text, got: %s", result.HTML)
|
||||
}
|
||||
if !strings.Contains(result.HTML, "<p>This is a paragraph.</p>") {
|
||||
t.Fatalf("expected paragraph, got: %s", result.HTML)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderGFMTable(t *testing.T) {
|
||||
md := "| A | B |\n|---|---|\n| 1 | 2 |"
|
||||
r := New()
|
||||
result, err := r.Render([]byte(md))
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
if !strings.Contains(result.HTML, "<table>") {
|
||||
t.Fatalf("expected table, got: %s", result.HTML)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCodeHighlighting(t *testing.T) {
|
||||
md := "```go\nfunc main() {}\n```"
|
||||
r := New()
|
||||
result, err := r.Render([]byte(md))
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
// chroma with classes should produce class attributes
|
||||
if !strings.Contains(result.HTML, "class=") {
|
||||
t.Fatalf("expected syntax highlighting classes, got: %s", result.HTML)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractHeadings(t *testing.T) {
|
||||
md := "# Title\n## Section\n### Subsection"
|
||||
r := New()
|
||||
result, err := r.Render([]byte(md))
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
if len(result.Headings) != 3 {
|
||||
t.Fatalf("expected 3 headings, got %d", len(result.Headings))
|
||||
}
|
||||
if result.Headings[0].Level != 1 || result.Headings[0].Text != "Title" {
|
||||
t.Fatalf("unexpected first heading: %+v", result.Headings[0])
|
||||
}
|
||||
if result.Headings[1].Level != 2 || result.Headings[1].Text != "Section" {
|
||||
t.Fatalf("unexpected second heading: %+v", result.Headings[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadingAnchors(t *testing.T) {
|
||||
md := "# Hello World"
|
||||
r := New()
|
||||
result, err := r.Render([]byte(md))
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
if len(result.Headings) == 0 {
|
||||
t.Fatal("no headings extracted")
|
||||
}
|
||||
if result.Headings[0].ID == "" {
|
||||
t.Fatal("expected heading ID to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlugify(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"Hello World", "hello-world"},
|
||||
{"API Design", "api-design"},
|
||||
{"foo--bar", "foo-bar"},
|
||||
{" leading spaces ", "leading-spaces"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := Slugify(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("Slugify(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user