render: callout blocks via a hand-rolled goldmark extension
commit 23326fe6d3ecf0fbd7e9b0677717ab20de971b98 · parent c6ea0e9b8f
· browse this tree
Ximianth, 2026-08-09 10:46:45 UTC
Adds :::type ... ::: admonition blocks to rendered markdown.
A line of three-or-more colons with a non-empty remainder opens a
callout (first word is the type, the trimmed rest an optional title);
a lone line of three-or-more colons closes it. The body is ordinary
markdown.
Bare :::type is the author syntax deliberately: it avoids the extra
sigils of braced :::{.class} or per-line-prefixed forms, which is the
whole ergonomic point.
Output is <div class="callout callout-{type}"> with an optional
<p class="callout-title">, matching the markdown-it-container / VuePress
convention so docs authored for those tools render as intended; a div
(not aside) is used for the same reason. The type and title are
HTML-escaped and the type only ever appears as a class suffix, so an
author string cannot inject structure into vitrine's chrome.
Callouts do not nest, by design: nesting admonitions is not a real
authoring need, and forbidding it keeps the parser dumb and its output
deterministic. An unterminated callout runs to the end of its
container rather than breaking the document.
diff --git a/callout.go b/callout.go new file mode 100644 index 0000000..323deee --- /dev/null +++ b/callout.go @@ -0,0 +1,155 @@ +package main + +import ( + "bytes" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/renderer" + "github.com/yuin/goldmark/text" + "github.com/yuin/goldmark/util" +) + +// Callouts are colon-fenced admonition blocks: +// +// :::warning Optional title text +// body content, which is **full markdown** +// ::: +// +// A line of three-or-more colons followed by a non-empty remainder opens a +// callout; the first whitespace-delimited word of the remainder is the type, +// the trimmed rest is an optional title. A line of three-or-more colons alone +// closes it. The body is parsed as ordinary markdown. +// +// Callouts do not nest: an opener encountered while already inside a callout is +// left as plain content. Nesting admonitions is not a real authoring need, and +// forbidding it keeps this parser dumb and its output deterministic. + +// calloutNode is the block node produced by calloutParser and consumed by +// calloutRenderer. Its children are the fully-parsed body blocks. +type calloutNode struct { + ast.BaseBlock + Kind_ string // callout type, e.g. "warning"; only ever emitted as a class suffix + Title string // optional title text; empty means no title paragraph +} + +var kindCallout = ast.NewNodeKind("Callout") + +func (n *calloutNode) Kind() ast.NodeKind { return kindCallout } + +func (n *calloutNode) Dump(src []byte, level int) { ast.DumpHelper(n, src, level, nil, nil) } + +// isCalloutFence reports whether line (right-trimmed) is three-or-more colons +// followed by remainder, and returns that remainder trimmed. A lone fence +// (empty remainder) is the closer; a fence with a remainder is the opener. +func isCalloutFence(line []byte) (rest []byte, ok bool) { + l := util.TrimRightSpace(line) + n := 0 + for n < len(l) && l[n] == ':' { + n++ + } + if n < 3 { + return nil, false + } + return bytes.TrimSpace(l[n:]), true +} + +// insideCallout reports whether node or any ancestor is a callout, which is how +// the no-nesting rule is enforced at open time. +func insideCallout(node ast.Node) bool { + for n := node; n != nil; n = n.Parent() { + if n.Kind() == kindCallout { + return true + } + } + return false +} + +type calloutParser struct{} + +func (calloutParser) Trigger() []byte { return []byte{':'} } + +func (calloutParser) Open(parent ast.Node, reader text.Reader, pc parser.Context) (ast.Node, parser.State) { + pos := pc.BlockOffset() + if pos < 0 { // blank line + return nil, parser.NoChildren + } + line, _ := reader.PeekLine() + rest, ok := isCalloutFence(line[pos:]) + if !ok || len(rest) == 0 { // not a fence, or a lone fence (a closer, not an opener) + return nil, parser.NoChildren + } + if insideCallout(parent) { // no nesting; leave the opener as plain content + return nil, parser.NoChildren + } + typ := rest + var title []byte + if sp := bytes.IndexByte(rest, ' '); sp >= 0 { + typ, title = rest[:sp], bytes.TrimSpace(rest[sp+1:]) + } + reader.Advance(len(line)) + return &calloutNode{Kind_: string(typ), Title: string(title)}, parser.HasChildren +} + +func (calloutParser) Continue(node ast.Node, reader text.Reader, pc parser.Context) parser.State { + line, _ := reader.PeekLine() + if rest, ok := isCalloutFence(line); ok && len(rest) == 0 { // a lone fence closes + reader.Advance(len(line)) + return parser.Close + } + return parser.Continue | parser.HasChildren +} + +// Close is a required part of the parser interface; an unterminated callout +// simply runs to the end of its container, which needs no cleanup here. +func (calloutParser) Close(node ast.Node, reader text.Reader, pc parser.Context) {} + +// CanInterruptParagraph lets a callout opener start immediately after a line of +// prose, without a blank line between them. +func (calloutParser) CanInterruptParagraph() bool { return true } + +func (calloutParser) CanAcceptIndentedLine() bool { return false } + +// calloutRenderer writes a calloutNode as a div carrying a constant class plus a +// type class, matching the markdown-it-container / VuePress convention so docs +// authored for those tools render as intended. A div (not aside) is used +// deliberately to match that ecosystem's output. +type calloutRenderer struct{} + +func (calloutRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { + reg.Register(kindCallout, renderCallout) +} + +func renderCallout(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + n := node.(*calloutNode) + if entering { + // The type reaches HTML only as a class suffix, and both type and title + // are escaped, so an author's string can never inject structure into our + // chrome; the fixed "callout-" prefix also keeps it from colliding. + _, _ = w.WriteString(`<div class="callout callout-`) + _, _ = w.Write(util.EscapeHTML([]byte(n.Kind_))) + _, _ = w.WriteString(`">` + "\n") + if n.Title != "" { + _, _ = w.WriteString(`<p class="callout-title">`) + _, _ = w.Write(util.EscapeHTML([]byte(n.Title))) + _, _ = w.WriteString("</p>\n") + } + } else { + _, _ = w.WriteString("</div>\n") + } + return ast.WalkContinue, nil +} + +// callouts is the goldmark extender that wires the callout parser and renderer +// into the pipeline, at the same priority headingAnchors uses. +type callouts struct{} + +func (callouts) Extend(m goldmark.Markdown) { + m.Parser().AddOptions(parser.WithBlockParsers( + util.Prioritized(calloutParser{}, 100), + )) + m.Renderer().AddOptions(renderer.WithNodeRenderers( + util.Prioritized(calloutRenderer{}, 100), + )) +} diff --git a/callout_test.go b/callout_test.go new file mode 100644 index 0000000..ec5ea0e --- /dev/null +++ b/callout_test.go @@ -0,0 +1,122 @@ +package main + +import ( + "bytes" + "strings" + "testing" + + "github.com/yuin/goldmark/parser" +) + +// renderMD runs source through the real markdown pipeline (the same package +// global used to serve pages), so these tests exercise the wired-in extension. +func renderMD(t *testing.T, source string) string { + t.Helper() + var buf bytes.Buffer + ctx := parser.NewContext(parser.WithIDs(newSlugIDs())) + if err := markdown.Convert([]byte(source), &buf, parser.WithContext(ctx)); err != nil { + t.Fatalf("convert: %v", err) + } + return buf.String() +} + +func TestCalloutBareType(t *testing.T) { + got := renderMD(t, ":::note\nheads up\n:::\n") + want := "<div class=\"callout callout-note\">\n<p>heads up</p>\n</div>\n" + if got != want { + t.Errorf("got:\n%s\nwant:\n%s", got, want) + } +} + +func TestCalloutTypeAndTitle(t *testing.T) { + got := renderMD(t, ":::warning Be careful\nbody here\n:::\n") + want := "<div class=\"callout callout-warning\">\n" + + "<p class=\"callout-title\">Be careful</p>\n" + + "<p>body here</p>\n" + + "</div>\n" + if got != want { + t.Errorf("got:\n%s\nwant:\n%s", got, want) + } +} + +func TestCalloutMultiParagraphAndMarkdownBody(t *testing.T) { + got := renderMD(t, ":::tip Handy\nfirst *para* here\n\nsecond para\n\n- one\n- two\n:::\n") + for _, want := range []string{ + "<div class=\"callout callout-tip\">\n", + "<p class=\"callout-title\">Handy</p>\n", + "<em>para</em>", + "<p>second para</p>", + "<ul>\n<li>one</li>\n<li>two</li>\n</ul>", + } { + if !strings.Contains(got, want) { + t.Errorf("missing %q in:\n%s", want, got) + } + } +} + +func TestCalloutQuadColonCloser(t *testing.T) { + // Opener and closer may both be three-OR-MORE colons. + got := renderMD(t, "::::info\nboxed\n::::\n") + want := "<div class=\"callout callout-info\">\n<p>boxed</p>\n</div>\n" + if got != want { + t.Errorf("got:\n%s\nwant:\n%s", got, want) + } +} + +func TestCalloutSurroundingProse(t *testing.T) { + got := renderMD(t, "before text\n\n:::note\ninside\n:::\n\nafter text\n") + for _, want := range []string{ + "<p>before text</p>", + "<div class=\"callout callout-note\">", + "<p>inside</p>", + "<p>after text</p>", + } { + if !strings.Contains(got, want) { + t.Errorf("missing %q in:\n%s", want, got) + } + } +} + +func TestCalloutUnterminatedRunsToEnd(t *testing.T) { + // An opener with no closer should still render, running to end of document. + got := renderMD(t, ":::warning No closer\nstill inside\nmore body\n") + for _, want := range []string{ + "<div class=\"callout callout-warning\">", + "<p class=\"callout-title\">No closer</p>", + "still inside", + "</div>\n", + } { + if !strings.Contains(got, want) { + t.Errorf("missing %q in:\n%s", want, got) + } + } +} + +func TestCalloutLoneOpenerFallsBackToParagraph(t *testing.T) { + // A lone ":::" is a closer token, not an opener: with no open callout it is + // just ordinary paragraph text, never a callout div. + got := renderMD(t, ":::\n") + if strings.Contains(got, "callout") { + t.Errorf("lone ::: became a callout; got:\n%s", got) + } + if !strings.Contains(got, "<p>:::</p>") { + t.Errorf("lone ::: should be a plain paragraph; got:\n%s", got) + } +} + +func TestCalloutTypeIsEscaped(t *testing.T) { + // The type only appears as a class suffix and must be HTML-escaped, so an + // author string cannot inject structure into our chrome. + got := renderMD(t, ":::a\"><script>\nx\n:::\n") + if strings.Contains(got, "<script>") { + t.Errorf("type escaped incorrectly; got:\n%s", got) + } +} + +func TestCalloutNoNesting(t *testing.T) { + // An opener inside a callout is left as plain content, not a nested box. + got := renderMD(t, ":::note\n:::warning\ninner\n:::\n") + if strings.Count(got, "callout callout-") != 1 { + t.Errorf("expected exactly one callout div; got:\n%s", got) + } +} diff --git a/render.go b/render.go index 333f97f..3e6e3a4 100644 --- a/render.go +++ b/render.go @@ -20,7 +20,7 @@ import ( const maxRenderSize = 4 << 20 var markdown = goldmark.New( - goldmark.WithExtensions(gmext.GFM), + goldmark.WithExtensions(gmext.GFM, callouts{}), goldmark.WithParserOptions( parser.WithAutoHeadingID(), parser.WithASTTransformers(gmutil.Prioritized(headingAnchors{}, 100)),