vitrine

callout.go

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

callout.go · 5.8 KiB · 3ea36d82a8