`:::name{…}`, a body of Markdown, then `:::`. This spends the form reserved by
ADR-0059 rather than leaving it a promise — and building it back then would have
been a mechanism with no user, which is what the reserve was avoiding.
The body renders first and reaches the theme fragment as .Body, already HTML, so
emphasis, links, subscripts and icons all work inside an admonition. That is one
addition to the theme contract, additive as the stability rule requires, and two
lines of core — which is what the remaining budget allowed.
Rendered by a transformer rather than the node renderer, for the same reason an
include is: rendering a subtree needs the document, and a node renderer never
gets one.
When the theme has no template for a kind, the engine writes the body out
unwrapped. Same principle as an unknown icon keeping its text, and it matters
more here: a theme not knowing one name must never cost an author paragraphs,
and an unstyled aside is a far smaller failure than a missing one.
The leaf parser was parameterised by prefix rather than copied — a second copy of
parsing logic is a stop condition, and the two forms differ by one colon.
Containers do not nest: a `:::` inside closes the one it is in, the same limit an
include carries. Stated in the ADR and the contract rather than left to be found.
core 2796/2800, ext 1743/2000, 34 gates green, 0 warnings.
118 lines
3.9 KiB
Go
118 lines
3.9 KiB
Go
package shortcodes
|
|
|
|
import (
|
|
"bytes"
|
|
"html/template"
|
|
"log/slog"
|
|
"strings"
|
|
|
|
"github.com/yuin/goldmark"
|
|
"github.com/yuin/goldmark/ast"
|
|
"github.com/yuin/goldmark/parser"
|
|
"github.com/yuin/goldmark/text"
|
|
"github.com/yuin/goldmark/util"
|
|
|
|
"khosra/internal/render"
|
|
)
|
|
|
|
// containerKind is a call that wraps content: `:::name{…}`, a body of Markdown, then `:::` (ADR-0064).
|
|
var containerKind = ast.NewNodeKind("ShortcodeContainer")
|
|
|
|
type container struct {
|
|
ast.BaseBlock
|
|
name string
|
|
args map[string]string
|
|
// body is the content, rendered before the fragment is asked for anything. Filled in by the transformer,
|
|
// for the same reason an include is: a node renderer never receives the parse context.
|
|
body []byte
|
|
}
|
|
|
|
func (n *container) Kind() ast.NodeKind { return containerKind }
|
|
|
|
func (n *container) Dump(source []byte, level int) { ast.DumpHelper(n, source, level, nil, nil) }
|
|
|
|
// containers parses the opening fence and everything up to the closing one.
|
|
type containers struct{}
|
|
|
|
func (containers) Trigger() []byte { return []byte{':'} }
|
|
|
|
func (containers) Open(parent ast.Node, reader text.Reader, pc parser.Context) (ast.Node, parser.State) {
|
|
line, seg := reader.PeekLine()
|
|
name, args, ok := parse(string(line), containerOpener)
|
|
if !ok {
|
|
return nil, parser.NoChildren
|
|
}
|
|
reader.Advance(seg.Len() - 1)
|
|
return &container{name: name, args: args}, parser.HasChildren
|
|
}
|
|
|
|
// Continue reads the body until a line that is nothing but the fence.
|
|
//
|
|
// One level: a `:::` inside a container closes it rather than opening another, which is the same limit an
|
|
// include carries and for the same reason — the alternative is a nesting depth nobody asked for (ADR-0038).
|
|
func (containers) Continue(node ast.Node, reader text.Reader, pc parser.Context) parser.State {
|
|
line, seg := reader.PeekLine()
|
|
if strings.TrimSpace(string(line)) == containerOpener {
|
|
reader.Advance(seg.Len() - 1)
|
|
return parser.Close
|
|
}
|
|
return parser.Continue | parser.HasChildren
|
|
}
|
|
|
|
func (containers) Close(node ast.Node, reader text.Reader, pc parser.Context) {}
|
|
|
|
func (containers) CanInterruptParagraph() bool { return true }
|
|
|
|
func (containers) CanAcceptIndentedLine() bool { return false }
|
|
|
|
// bodies renders each container's content and hangs it on the node.
|
|
//
|
|
// A transformer for the same reason includes is one: rendering needs the document, and by the time a node
|
|
// renderer runs there is no way to render a subtree into a string a fragment can be handed.
|
|
type bodies struct {
|
|
md goldmark.Markdown
|
|
}
|
|
|
|
func (b bodies) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) {
|
|
var found []*container
|
|
_ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
|
|
if entering {
|
|
if call, is := n.(*container); is {
|
|
found = append(found, call)
|
|
}
|
|
}
|
|
return ast.WalkContinue, nil
|
|
})
|
|
for _, call := range found {
|
|
var out bytes.Buffer
|
|
for child := call.FirstChild(); child != nil; child = child.NextSibling() {
|
|
if err := b.md.Renderer().Render(&out, reader.Source(), child); err != nil {
|
|
slog.Error("rendering a container body", "name", call.name, "err", err)
|
|
}
|
|
}
|
|
call.body = out.Bytes()
|
|
call.RemoveChildren(call)
|
|
}
|
|
}
|
|
|
|
// renderContainer writes the theme's fragment for the call, or the body alone when the theme has no template
|
|
// for it. Losing a paragraph because a theme does not know one name is never the right answer (ADR-0064).
|
|
func (f fragments) renderContainer(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
|
|
if !entering {
|
|
return ast.WalkContinue, nil
|
|
}
|
|
call := n.(*container)
|
|
out, err := f.partial(call.name, render.Fragment{Args: call.args, Body: template.HTML(call.body)})
|
|
if err != nil {
|
|
slog.Error("skipping container", "name", call.name, "err", err)
|
|
out = nil
|
|
}
|
|
if len(bytes.TrimSpace(out)) == 0 {
|
|
out = call.body
|
|
}
|
|
if _, err := w.Write(out); err != nil {
|
|
return ast.WalkStop, err
|
|
}
|
|
return ast.WalkSkipChildren, nil
|
|
}
|