vitrine

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)),