slidewalk/internal/parser/markdown.go
2026-07-15 20:14:07 +02:00

159 lines
5.5 KiB
Go

package parser
import (
"bytes"
"fmt"
"github.com/alecthomas/chroma/v2"
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
"github.com/alecthomas/chroma/v2/lexers"
"github.com/alecthomas/chroma/v2/styles"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/renderer/html"
"github.com/yuin/goldmark/util"
)
// mermaidLang is the fenced-code-block language tag that triggers Mermaid
// rendering.
var mermaidLang = []byte("mermaid")
// chromaFormatter renders tokenized code as HTML using CSS classes (rather
// than inline styles), so the actual colors live in web/assets/chroma.css
// (see web/gen_chroma_css.go) and can vary between light and dark mode.
var chromaFormatter = chromahtml.New(chromahtml.WithClasses(true))
// chromaBaseStyle is passed to chromaFormatter.Format as required by its
// signature. In WithClasses mode its colors are unused (they live in the
// generated stylesheet instead); only its background-vs-foreground
// classification matters, which is the same across chroma styles.
var chromaBaseStyle = styles.Get("github")
// md is the shared goldmark instance used to convert slide Markdown bodies
// to HTML. It enables GFM (tables, strikethrough, autolinks, task lists),
// footnotes, raw inline/block HTML passthrough, and Mermaid code-block
// rendering.
var md = goldmark.New(
goldmark.WithExtensions(extension.GFM, extension.Footnote, mermaidExtension),
goldmark.WithRendererOptions(html.WithUnsafe()),
)
// renderMarkdown converts a Markdown slide body to HTML.
func renderMarkdown(body []byte) ([]byte, error) {
var buf bytes.Buffer
if err := md.Convert(body, &buf); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// mermaidExtender registers a NodeRenderer that intercepts ```mermaid fenced
// code blocks so they render as <pre class="mermaid">...</pre> instead of
// the default <pre><code class="language-mermaid">...</code></pre>, since
// mermaid.js expects the diagram source as the direct text content of a
// <pre class="mermaid"> element.
type mermaidExtender struct{}
var mermaidExtension goldmark.Extender = mermaidExtender{}
func (mermaidExtender) Extend(m goldmark.Markdown) {
m.Renderer().AddOptions(renderer.WithNodeRenderers(
util.Prioritized(&mermaidRenderer{}, 100),
))
}
// mermaidRenderer renders fenced code blocks, special-casing the "mermaid"
// language and falling back to goldmark's standard rendering otherwise.
type mermaidRenderer struct{}
func (r *mermaidRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
reg.Register(ast.KindFencedCodeBlock, r.renderFencedCodeBlock)
}
func (r *mermaidRenderer) renderFencedCodeBlock(
w util.BufWriter, source []byte, node ast.Node, entering bool,
) (ast.WalkStatus, error) {
n := node.(*ast.FencedCodeBlock)
language := n.Language(source)
if bytes.Equal(language, mermaidLang) {
if entering {
_, _ = w.WriteString(`<pre class="mermaid">`)
writeLinesEscaped(w, source, n)
} else {
_, _ = w.WriteString("</pre>\n")
}
return ast.WalkContinue, nil
}
if lexer := lexers.Get(string(language)); lexer != nil {
if entering {
if err := renderHighlighted(w, source, n, lexer); err != nil {
return ast.WalkStop, err
}
}
return ast.WalkContinue, nil
}
return renderDefaultFencedCodeBlock(w, source, n, entering)
}
// renderHighlighted writes a fenced code block as syntax-highlighted HTML
// using chroma, given a lexer already matched to the block's language tag.
// It writes the complete "<pre>...</pre>" in one call (on the "entering"
// walk step); the corresponding "!entering" step is a no-op, mirroring how
// renderDefaultFencedCodeBlock already writes the opening tag and content
// together and only the closing tag separately.
func renderHighlighted(w util.BufWriter, source []byte, n *ast.FencedCodeBlock, lexer chroma.Lexer) error {
var code bytes.Buffer
lines := n.Lines()
for i := 0; i < lines.Len(); i++ {
line := lines.At(i)
code.Write(line.Value(source))
}
iterator, err := lexer.Tokenise(nil, code.String())
if err != nil {
return fmt.Errorf("tokenizing code block for syntax highlighting: %w", err)
}
if err := chromaFormatter.Format(w, chromaBaseStyle, iterator); err != nil {
return fmt.Errorf("formatting syntax-highlighted code block: %w", err)
}
return nil
}
// renderDefaultFencedCodeBlock reproduces goldmark's standard
// <pre><code class="language-...">...</code></pre> rendering for
// non-Mermaid fenced code blocks.
func renderDefaultFencedCodeBlock(
w util.BufWriter, source []byte, n *ast.FencedCodeBlock, entering bool,
) (ast.WalkStatus, error) {
if entering {
_, _ = w.WriteString("<pre><code")
if language := n.Language(source); language != nil {
_, _ = w.WriteString(` class="language-`)
_, _ = w.Write(util.EscapeHTML(language))
_, _ = w.WriteString(`"`)
}
_ = w.WriteByte('>')
writeLinesEscaped(w, source, n)
} else {
_, _ = w.WriteString("</code></pre>\n")
}
return ast.WalkContinue, nil
}
// writeLinesEscaped writes a node's source lines HTML-escaped, matching
// goldmark's own code-block output. Escaping is safe here (and required, to
// prevent literal HTML injection from slide content) because a browser
// decodes entities back to their original characters before Mermaid reads
// an element's text content.
func writeLinesEscaped(w util.BufWriter, source []byte, n ast.Node) {
lines := n.Lines()
for i := 0; i < lines.Len(); i++ {
line := lines.At(i)
_, _ = w.Write(util.EscapeHTML(line.Value(source)))
}
}