vitrine

Merge branch 'callouts'. Yay, decorative readability elements.

commit 84ae9471aba4696e9d37ea68d85fd425fa7ebfe4 · parent c6ea0e9b8f · parent f38ddfc771 · browse this tree
warpfork, 2026-08-09 12:17:24 UTC

I'm very surprised how nonstandard this turns out to be in markdown.

The convention used here is one I've both seem before in several
systems (for example, in what hackmd.io does, and in the JS
library for markdown-it-container), but also is...
distinct from several other approaches and... well.

Markdown.  Standards.  hah.

Anyway: I like this one.  So here it is.
diff --git a/README.md b/README.md
index 2a86879..c0317c0 100644
--- a/README.md
+++ b/README.md
@@ -64,6 +64,35 @@ on every build and every host.
 The `-theme-seed` flag substitutes any string for the hostname segment --
 useful for testing, or if you dislike the color your hostname landed on.
 
+Callouts
+--------
+
+Markdown can carry callout boxes (notes, warnings, tips, and the like)
+using colon fences:
+
+```
+:::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 word of the remainder is the callout *type*; anything after it is an optional *title*.
+- A line of three-or-more colons alone closes it.
+- The body is ordinary markdown: lists, emphasis, links, and code all work inside.
+
+The rendered box is `<div class="callout callout-{type}">`
+(matching the markdown-it-container / VuePress convention,
+so docs authored for those tools render as intended).
+The styled types are `info`, `note`, `tip`, `success`, `todo`, `warning`, `danger`, and `reject`,
+each with its own accent color and icon; `info` and `note` ride the per-host accent.
+`warn` is accepted as an alias for `warning`.
+An unknown type still renders as a plain neutral box.
+
+Callouts do not nest, by design,
+and an unterminated callout simply runs to the end of the document rather than breaking it.
+
+
 expose-git
 ----------
 
diff --git a/callout.go b/callout.go
new file mode 100644
index 0000000..9d65fba
--- /dev/null
+++ b/callout.go
@@ -0,0 +1,171 @@
+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")
+
+// calloutTypeAliases folds alternate type spellings onto the canonical set that
+// style.css styles.  Kept as a table because it is cheap to grow; for now it
+// just spells warn out in full.
+var calloutTypeAliases = map[string]string{
+	"warn": "warning",
+}
+
+// canonicalCalloutType maps an author's type onto its canonical spelling,
+// leaving unknown types untouched (they render as a neutral box).
+func canonicalCalloutType(typ string) string {
+	if c, ok := calloutTypeAliases[typ]; ok {
+		return c
+	}
+	return typ
+}
+
+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_: canonicalCalloutType(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..69f3efd
--- /dev/null
+++ b/callout_test.go
@@ -0,0 +1,138 @@
+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 TestCalloutTypeNormalized(t *testing.T) {
+	// warn folds onto the canonical warning type that style.css styles.
+	got := renderMD(t, ":::warn Heads up\nx\n:::\n")
+	if !strings.Contains(got, "callout-warning") {
+		t.Errorf("warn not normalized to warning; got:\n%s", got)
+	}
+}
+
+func TestCalloutUnknownTypePassesThrough(t *testing.T) {
+	// An unknown type is left as-is on the class, for the neutral fallback box.
+	got := renderMD(t, ":::obscure\nx\n:::\n")
+	if !strings.Contains(got, "callout callout-obscure") {
+		t.Errorf("unknown type not preserved; 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)),
diff --git a/style.css b/style.css
index 3c8e604..42fcaf2 100644
--- a/style.css
+++ b/style.css
@@ -108,3 +108,55 @@ article th, article td {
 	padding: .2rem .5rem;
 }
 article th { background: var(--well); }
+
+/* Callouts: :::type admonition boxes.
+   Each type carries one accent hue in --callout, and the base box derives its
+   tinted fill and border from that hue with color-mix, so a single value drives
+   the whole look (info and note simply inherit the per-host --accent).
+   --callout-icon is the emoji shown before a title, following this repo's
+   emoji-as-icon idiom (see the heading chainlink); it rides the title, so a
+   title-less callout shows no icon. */
+article .callout {
+	--callout: var(--accent);
+	--callout-fill: 12%; /* how much of --callout tints the box background */
+	margin-top: 1.25rem;
+	margin-bottom: 1.25rem;
+	padding: .7rem 1rem;
+	border: 1px solid color-mix(in srgb, var(--callout) 45%, var(--well-edge));
+	border-left: 3px solid var(--callout);
+	border-radius: .5rem;
+	background: color-mix(in srgb, var(--callout) var(--callout-fill), var(--paper));
+	box-shadow: 0 1px 3px rgba(0, 0, 0, .08);
+}
+/* The children are already inset by "article > *"; inside the box they
+   should sit flush to its padding instead. */
+article .callout > * { margin-left: 0; }
+article .callout-title {
+	margin: 0 0 .4rem;
+	font-weight: bold;
+	font-size: .95rem;
+	text-transform: uppercase;
+	letter-spacing: .03em;
+	color: var(--callout);
+}
+/* The icon rides the title via --callout-icon.  Only the known types below set
+   it, so an unknown type shows a clean icon-less box rather than a misleading
+   glyph; a title-less callout shows no icon at all. */
+article .callout-title::before {
+	content: var(--callout-icon, "");
+	margin-right: .45rem;
+}
+
+/* Per-type accent hues and icons, for the canonical type set only;
+   warn is folded onto warning by the normalizer before it reaches the class.
+   info and note keep the per-host --accent.  An unknown type matches none of
+   these and falls back to the neutral base box above, still readable. */
+article .callout-info,
+article .callout-note { --callout-icon: "\2139\FE0F"; }  /* information source, theme color */
+article .callout-tip { --callout: #c99000; --callout-icon: "\1F4A1"; }     /* light bulb, sunflower gold */
+article .callout-success { --callout: #1a7f37; --callout-icon: "\2705"; }  /* check mark, green */
+article .callout-todo { --callout: #8250df; --callout-icon: "\1F6A7"; }    /* construction sign, purple */
+article .callout-warning { --callout: #ef7c00; --callout-fill: 18%; --callout-icon: "\26A0\FE0F"; } /* warning sign, orange (weaker hue, so tinted harder) */
+article .callout-danger { --callout: #cf222e; --callout-icon: "\1F9E8"; }   /* firecracker, red */
+article .callout-reject { --callout: #cf222e; --callout-icon: "\274C"; }    /* cross mark, red */
+