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 · 66ff02c7fb