add container directives, and admonitions as their first user
`:::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.
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
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
|
||||
}
|
||||
@@ -25,7 +25,10 @@ import (
|
||||
// The leaf form of the generic directive syntax the wider Markdown world already uses, rather than an
|
||||
// invention of this engine (ADR-0059). Three colons open a container directive and are deliberately not
|
||||
// parsed here — that form arrives with the first feature that needs a body. The syntax is a disk contract.
|
||||
const opener = "::"
|
||||
const (
|
||||
opener = "::"
|
||||
containerOpener = ":::"
|
||||
)
|
||||
|
||||
// New returns the Markdown extension, rendering each call through partial.
|
||||
//
|
||||
@@ -46,8 +49,15 @@ type extension struct {
|
||||
// page including it — not by a second pipeline that could drift from this one (ADR-0038).
|
||||
func (e extension) Extend(md goldmark.Markdown) {
|
||||
md.Parser().AddOptions(
|
||||
parser.WithBlockParsers(util.Prioritized(blocks{}, 100)),
|
||||
parser.WithASTTransformers(util.Prioritized(includes{md: md}, 100)),
|
||||
parser.WithBlockParsers(
|
||||
// Containers first: three colons are not a leaf call, and the leaf parser must never see them.
|
||||
util.Prioritized(containers{}, 99),
|
||||
util.Prioritized(blocks{}, 100),
|
||||
),
|
||||
parser.WithASTTransformers(
|
||||
util.Prioritized(includes{md: md}, 100),
|
||||
util.Prioritized(bodies{md: md}, 150),
|
||||
),
|
||||
parser.WithInlineParsers(util.Prioritized(icons{}, 500)),
|
||||
)
|
||||
md.Renderer().AddOptions(renderer.WithNodeRenderers(
|
||||
@@ -193,7 +203,7 @@ func (blocks) Trigger() []byte { return []byte{':'} }
|
||||
|
||||
func (blocks) Open(parent ast.Node, reader text.Reader, pc parser.Context) (ast.Node, parser.State) {
|
||||
line, seg := reader.PeekLine()
|
||||
name, args, ok := parse(string(line))
|
||||
name, args, ok := parse(string(line), opener)
|
||||
if !ok {
|
||||
return nil, parser.NoChildren
|
||||
}
|
||||
@@ -267,6 +277,7 @@ type fragments struct {
|
||||
func (f fragments) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
|
||||
reg.Register(kind, f.render)
|
||||
reg.Register(iconKind, f.renderIcon)
|
||||
reg.Register(containerKind, f.renderContainer)
|
||||
}
|
||||
|
||||
// render writes the theme's fragment for this call.
|
||||
@@ -300,8 +311,8 @@ func (f fragments) render(w util.BufWriter, source []byte, n ast.Node, entering
|
||||
// The whole line must be the call, and every argument is key="value" — one spelling, so there is nothing
|
||||
// to guess and no half-parsed state. Values are returned raw; escaping is the template's job, which is
|
||||
// what keeps an author's text out of the markup (ADR-0036).
|
||||
func parse(line string) (name string, args map[string]string, ok bool) {
|
||||
body, found := strings.CutPrefix(strings.TrimSpace(line), opener)
|
||||
func parse(line, prefix string) (name string, args map[string]string, ok bool) {
|
||||
body, found := strings.CutPrefix(strings.TrimSpace(line), prefix)
|
||||
if !found {
|
||||
return "", nil, false
|
||||
}
|
||||
|
||||
@@ -14,14 +14,14 @@ import (
|
||||
func TestParseAcceptsOnlyAWholeLineCall(t *testing.T) {
|
||||
// Quotes only where a value has spaces: that is what makes the directive form shorter than the one it
|
||||
// replaced (ADR-0059).
|
||||
name, args, ok := parse(` ::figure{src=a.jpg alt="A cat"} `)
|
||||
name, args, ok := parse(` ::figure{src=a.jpg alt="A cat"} `, opener)
|
||||
if !ok || name != "figure" {
|
||||
t.Fatalf("parse gave %q %v ok=%v", name, args, ok)
|
||||
}
|
||||
if args["src"] != "a.jpg" || args["alt"] != "A cat" {
|
||||
t.Errorf("args = %v", args)
|
||||
}
|
||||
if name, args, ok := parse("::gallery"); !ok || name != "gallery" || len(args) != 0 {
|
||||
if name, args, ok := parse("::gallery", opener); !ok || name != "gallery" || len(args) != 0 {
|
||||
t.Errorf("a call with no attributes needs no braces: %q %v ok=%v", name, args, ok)
|
||||
}
|
||||
for _, line := range []string{
|
||||
@@ -32,14 +32,14 @@ func TestParseAcceptsOnlyAWholeLineCall(t *testing.T) {
|
||||
"::{src=a.jpg}", // no name
|
||||
`::figure{src="unclosed}`, // unbalanced quote
|
||||
"::", // empty
|
||||
// Three colons open a container directive. Nothing parses it yet, and this parser must not claim
|
||||
// it as a leaf named ":note", or the form is spent before its first user arrives.
|
||||
// Three colons open a container directive, which has its own parser: the leaf one must never claim
|
||||
// them as a call named ":note".
|
||||
":::note",
|
||||
":::note{title=Careful}",
|
||||
// A definition list description shares the trigger byte and must fall through to its own parser.
|
||||
": a definition",
|
||||
} {
|
||||
if _, _, ok := parse(line); ok {
|
||||
if _, _, ok := parse(line, opener); ok {
|
||||
t.Errorf("parse accepted %q", line)
|
||||
}
|
||||
}
|
||||
@@ -323,3 +323,41 @@ func TestAnIconInCodeIsLiteral(t *testing.T) {
|
||||
t.Errorf("a code span is the author's literal text:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAContainerWrapsItsRenderedBody(t *testing.T) {
|
||||
got := body(t, wired(t, nil), ":::note{title=\"Read this\"}\nA body with *emphasis* and a [link](/posts/).\n\nTwo paragraphs.\n:::\n\nAfter.\n")
|
||||
for _, want := range []string{
|
||||
`<aside class="admonition note">`, `<p class="admonition-title">Read this</p>`,
|
||||
"<em>emphasis</em>", `href="/posts/"`, "Two paragraphs.", "</aside>",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(got, "After.") {
|
||||
t.Errorf("the page continues after the fence:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, ":::") {
|
||||
t.Errorf("the fences are syntax, not content:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A theme with no template for a kind renders nothing. Losing an author's paragraphs because of that would
|
||||
// be far worse than an unstyled aside (ADR-0064).
|
||||
func TestAnUnknownContainerKeepsItsBody(t *testing.T) {
|
||||
got := body(t, wired(t, nil), ":::nosuchkind\nThis body must survive.\n:::\n")
|
||||
if !strings.Contains(got, "This body must survive.") {
|
||||
t.Errorf("the body must survive an unknown kind:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "nosuchkind") {
|
||||
t.Errorf("the kind is not content:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAContainerDoesNotSwallowTheRestOfThePage(t *testing.T) {
|
||||
// An unclosed fence ends with the document rather than eating a later one.
|
||||
got := body(t, wired(t, nil), ":::note\nInside.\n\nStill inside.\n")
|
||||
if !strings.Contains(got, "Inside.") || !strings.Contains(got, "Still inside.") {
|
||||
t.Errorf("an unclosed container keeps its content:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,8 @@ type Fragment struct {
|
||||
// gallery, none when the call names nothing a picture. Kept apart from Args so a supplied value can never
|
||||
// be mistaken for an authored one.
|
||||
Pictures []Picture
|
||||
// Body is a container call's content, already rendered. Empty for a leaf call (ADR-0064).
|
||||
Body template.HTML
|
||||
}
|
||||
|
||||
// Picture is one image a fragment can render (ADR-0042).
|
||||
|
||||
@@ -35,3 +35,11 @@
|
||||
{{- else if eq .Args.name "cross"}}❌
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
|
||||
{{/* Admonitions. One template per kind, because a call renders through a template of its own name — the
|
||||
markup is the theme's and the engine supplies only the name, the arguments and the rendered body
|
||||
(ADR-0064). `.Body` is already HTML. A theme with no template for a kind renders nothing, and the
|
||||
engine writes the body out unwrapped rather than losing it. */}}
|
||||
{{define "note"}}<aside class="admonition note">{{with .Args.title}}<p class="admonition-title">{{.}}</p>{{end}}{{.Body}}</aside>{{end}}
|
||||
{{define "warn"}}<aside class="admonition warn">{{with .Args.title}}<p class="admonition-title">{{.}}</p>{{end}}{{.Body}}</aside>{{end}}
|
||||
{{define "tip"}}<aside class="admonition tip">{{with .Args.title}}<p class="admonition-title">{{.}}</p>{{end}}{{.Body}}</aside>{{end}}
|
||||
|
||||
@@ -22,10 +22,13 @@ th, td { border-bottom: 1px solid #d8d5cd; padding: 0.35rem 0.75rem 0.35rem 0; t
|
||||
dt { font-weight: 600; margin-top: 0.75rem; }
|
||||
dd { margin-left: 1.25rem; }
|
||||
.footnotes { font-size: 0.9em; }
|
||||
.admonition { border-left: 3px solid #d8d5cd; border-radius: 0; padding: 0.25rem 0 0.25rem 1rem; margin: 1.5rem 0; }
|
||||
.admonition-title { font-weight: 600; margin: 0 0 0.5rem; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html { color: #e8e6e1; background: #16161a; }
|
||||
a { color: #8ab4dd; }
|
||||
pre { background: #22222a; }
|
||||
.kind { color: #9a9a9a; }
|
||||
th, td { border-bottom-color: #33333c; }
|
||||
.admonition { border-left-color: #33333c; }
|
||||
}
|
||||
|
||||
@@ -127,6 +127,9 @@ var exampleFeatures = []featureCase{
|
||||
{what: "an abbreviation expands and its definition line renders nothing", path: "/writing/notes-on-water/", code: 200,
|
||||
expect: []string{`<abbr title="National Institute of Water and Atmospheric Research">NIWA</abbr>`},
|
||||
absent: []string{"*[NIWA]"}},
|
||||
{what: "a container renders its body through the theme fragment", path: "/writing/notes-on-water/", code: 200,
|
||||
expect: []string{`<aside class="admonition warn">`, `<p class="admonition-title">Calibration</p>`,
|
||||
"<em>emphasis</em>", "H<sub>2</sub>O"}, absent: []string{":::"}},
|
||||
{what: "an icon renders through the theme, and prose colons are untouched", path: "/writing/notes-on-water/", code: 200,
|
||||
expect: []string{"⚠️", ":nosuchicon:", "10:30:15", "key:value:pair"}},
|
||||
{what: "the dialect renders tables, definition lists and strikethrough", path: "/writing/notes-on-water/", code: 200,
|
||||
|
||||
Reference in New Issue
Block a user