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:
Claude Opus 5
2026-08-01 22:00:33 +06:00
committed by bdeshi
parent df9335df33
commit 78b8c51dff
13 changed files with 302 additions and 73 deletions
+4 -3
View File
@@ -370,9 +370,10 @@ form of the generic directive syntax the wider Markdown world uses, not an inven
A call with no arguments needs no braces: `::gallery`. A malformed call stays visible as text instead of
half-working.
Three colons open a **container** directive — `:::name{…}`, body, `:::` — reserved for calls that wrap
content. Nothing parses that form yet; it arrives with the first feature that needs a body, and until then a
`:::` line is prose.
Three colons open a **container** directive — `:::name{…}`, body, `:::` on its own line — for calls that wrap
content (ADR-0064). The body is Markdown and renders as such. One level: a `:::` inside a container closes it
rather than opening another, the same limit an include carries. `note`, `warn` and `tip` exist; a kind the
theme does not define renders its body unwrapped rather than losing it.
**Migrating from the retired form.** `{{< name key="value" >}}` is no longer a call and renders as literal
text. `khosra check` reports every one as fatal and names the replacement, so a site root is migrated by
+20
View File
@@ -1028,3 +1028,23 @@ this could damage prose are tests. Expensive — a theme cannot tell the engine
unknown name costs a fragment execution before falling back; and the reference theme's Unicode set is a
`{{if}}` chain, which is fine at six names and would not be at sixty.
Revisit if: a theme wants to declare its set to the engine — which is the settings cascade, not this.
## ADR-0064 — Container directives, and a fragment may receive a body
Date: 2026-08-01 · Status: accepted (spends the `:::` form reserved by ADR-0059; extends the theme contract
additively with `Fragment.Body`)
Decision: `:::name{…}`, a body of Markdown, then `:::` on its own line. The body is rendered first and handed
to the theme fragment of that name as `.Body`, already HTML. One level: a `:::` inside a container closes it.
When the theme has no template for the kind, the engine writes the body out unwrapped. The reference theme
defines `note`, `warn` and `tip`, each an `<aside>` with an optional title.
Why: admonitions are the first call that wraps content rather than replacing a line, which is what the
reserved form was for — building it in ADR-0059 would have been a mechanism with no user. The body is
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 has no way to get one. Falling back to the bare body is the
same principle as an unknown icon keeping its text — 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.
Consequence: cheap — `epigraph`, `spec` or anything else that wraps content is now a template, not a code
change; `.Body` is two lines of contract. Expensive — containers do not nest, which is a real limit for a
warning inside a note; every kind needs its own template, so a theme with twelve admonition styles writes
twelve; and the body is rendered before the fragment sees it, so a fragment cannot choose *not* to render it.
Revisit if: nesting is genuinely wanted — which needs a fence length rule like CommonMark's code fences, not
a deeper parser.
+2 -2
View File
@@ -22,10 +22,10 @@ table owns.
| `internal/content/settings.go` | `site.yaml`: the site's own declarations (`base`, `title`) and absolute-URL building (ADR-0039) |
| `internal/content/site.go` | the indexed site: lookup with language fallback, aliases, `Query` and `Run`, sections, `Sequence`, `Everything`, slug routes, publication visibility |
| `internal/render/render.go` | goldmark with the typographer, per-kind template sets with site override, the render methods. The parsed sets plus the stylesheet are one snapshot behind an `atomic.Pointer`; `Refresh` is the only thing that replaces it, so every page serves one theme (ADR-0055, ADR-0056). Heading ids are a parser option set here (ADR-0058), and this is the one renderer that enables raw HTML (ADR-0060) |
| `internal/render/view.go` | the theme contract in Go: `Page`, `List`, `Sequence`, `Extras`, `Item`, `Fragment`, `Picture`, `Origin` |
| `internal/render/view.go` | the theme contract in Go: `Page`, `List`, `Sequence`, `Extras`, `Item`, `Fragment` (with `Body` since ADR-0064), `Picture`, `Origin` |
| `internal/render/chrome.go` | the engine's own words: phrase table, month names, digits, and the `t`/`num`/`day` template funcs (ADR-0034) |
| `internal/render/templates/` | reference theme, complete (six icon names map to Unicode, no assets — ADR-0063): `base.html` (shell, navigation, language links, feed and OpenGraph), `page.html` (bundle, sequence, tags, extras), `list.html`, `extras.html`, `shortcodes.html`, `theme.css` (ADR-0026, ADR-0049) |
| `internal/ext/shortcodes/` | first feature: `::name{key=value}` block parser and node renderer, rendering through a theme fragment (ADR-0036). `figure`, `gallery`, `include`, plus the derivative pass and remembered picture inspection (ADR-0042, ADR-0044). `FootnotePrefix` namespaces an included file's footnote ids (ADR-0058). Directive syntax since ADR-0059, plus `icons.go`: `:name:` inline, rendered by the theme's one `icon` fragment (ADR-0063) |
| `internal/ext/shortcodes/` | first feature: `::name{key=value}` block parser and node renderer, rendering through a theme fragment (ADR-0036). `figure`, `gallery`, `include`, plus the derivative pass and remembered picture inspection (ADR-0042, ADR-0044). `FootnotePrefix` namespaces an included file's footnote ids (ADR-0058). Directive syntax since ADR-0059, plus `icons.go`: `:name:` inline, rendered by the theme's one `icon` fragment (ADR-0063), and `containers.go`: `:::name{…}``:::` wrapping a rendered body (ADR-0064) |
| `internal/ext/notation/` | the inline marks CommonMark lacks: `~sub~`, `^sup^`, `==mark==`, and `~~strike~~`, which it owns so a single tilde can mean subscript (ADR-0061). `abbr.go` adds `*[TERM]:` definitions and the pass that expands them (ADR-0062) |
| `internal/ext/scaffold/` | writes one draft directory bundle into a site root through `os.Root`: never an overwrite |
| `internal/ext/watch/` | polls `content/` and `templates/` on an interval it is given, ignores editor droppings, and reports a settled change (ADR-0022, ADR-0048, ADR-0056). `site.yaml` is deliberately not fingerprinted (ADR-0055) |
+71 -57
View File
@@ -175,10 +175,24 @@ doc.go 8 · scaffold.go 94
- scaffold.go:76 func titleFrom(key string) string
- scaffold.go:85 func mkdirAll(root *os.Root, dir string) error
## internal/ext/shortcodes — 738 lines + 477 test
## internal/ext/shortcodes — 866 lines + 515 test
doc.go 7 · icons.go 125 · images.go 250 · shortcodes.go 356
containers.go 117 · doc.go 7 · icons.go 125 · images.go 250 · shortcodes.go 367
- containers.go:19 var containerKind = ast.NewNodeKind("ShortcodeContainer")
- containers.go:21 type container struct
- containers.go:30 func (n *container) Kind() ast.NodeKind { return containerKind }
- containers.go:32 func (n *container) Dump(source []byte, level int) { ast.DumpHelper(n, source, level, nil, nil) }
- containers.go:35 type containers struct{}
- containers.go:37 func (containers) Trigger() []byte { return []byte{' '} }
- containers.go:39 func (containers) Open(parent ast.Node, reader text.Reader, pc parser.Context) (ast.Node, parser.State)
- containers.go:53 func (containers) Continue(node ast.Node, reader text.Reader, pc parser.Context) parser.State
- containers.go:62 func (containers) Close(node ast.Node, reader text.Reader, pc parser.Context) {}
- containers.go:64 func (containers) CanInterruptParagraph() bool { return true }
- containers.go:66 func (containers) CanAcceptIndentedLine() bool { return false }
- containers.go:72 type bodies struct
- containers.go:76 func (b bodies) Transform(doc *ast.Document, reader text.Reader, pc parser.Context)
- containers.go:100 func (f fragments) renderContainer(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error)
- icons.go:19 const iconFragment = "icon"
- icons.go:27 type icons struct{}
- icons.go:29 func (icons) Trigger() []byte { return []byte{' '} }
@@ -203,35 +217,35 @@ doc.go 7 · icons.go 125 · images.go 250 · shortcodes.go 356
- images.go:224 func lossless(source string) bool
- images.go:234 func showable(name string) bool
- images.go:244 func derivable(name string) bool
- shortcodes.go:28 const opener = " "
- shortcodes.go:34 func New(partial render.Partial) goldmark.Extender
- shortcodes.go:38 type extension struct
- shortcodes.go:47 func (e extension) Extend(md goldmark.Markdown)
- shortcodes.go:58 var nested = parser.NewContextKey()
- shortcodes.go:63 var includedAs = parser.NewContextKey()
- shortcodes.go:65 const footnoteKey = "khosra footnote-prefix"
- shortcodes.go:73 func FootnotePrefix(n ast.Node) []byte
- shortcodes.go:91 type includes struct
- shortcodes.go:95 func (in includes) Transform(doc *ast.Document, reader text.Reader, pc parser.Context)
- shortcodes.go:119 func (in includes) convert(name string, pc parser.Context) ([]byte, error)
- shortcodes.go:150 func pending(doc *ast.Document) []*node
- shortcodes.go:168 var kind = ast.NewNodeKind("Shortcode")
- shortcodes.go:171 type node struct
- shortcodes.go:185 func (n *node) Kind() ast.NodeKind { return kind }
- shortcodes.go:187 func (n *node) Dump(source []byte, level int) { ast.DumpHelper(n, source, level, nil, nil) }
- shortcodes.go:190 type blocks struct{}
- shortcodes.go:192 func (blocks) Trigger() []byte { return []byte{' '} }
- shortcodes.go:194 func (blocks) Open(parent ast.Node, reader text.Reader, pc parser.Context) (ast.Node, parser.State)
- shortcodes.go:225 func gallery(pc parser.Context) []render.Picture
- shortcodes.go:252 func (blocks) Continue(n ast.Node, reader text.Reader, pc parser.Context) parser.State
- shortcodes.go:256 func (blocks) Close(n ast.Node, reader text.Reader, pc parser.Context) {}
- shortcodes.go:258 func (blocks) CanInterruptParagraph() bool { return true }
- shortcodes.go:260 func (blocks) CanAcceptIndentedLine() bool { return false }
- shortcodes.go:263 type fragments struct
- shortcodes.go:267 func (f fragments) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer)
- shortcodes.go:276 func (f fragments) render(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error)
- shortcodes.go:303 func parse(line string) (name string, args map[string]string, ok bool)
- shortcodes.go:338 func argument(s string) (key, value, rest string, ok bool)
- shortcodes.go:28 const
- shortcodes.go:37 func New(partial render.Partial) goldmark.Extender
- shortcodes.go:41 type extension struct
- shortcodes.go:50 func (e extension) Extend(md goldmark.Markdown)
- shortcodes.go:68 var nested = parser.NewContextKey()
- shortcodes.go:73 var includedAs = parser.NewContextKey()
- shortcodes.go:75 const footnoteKey = "khosra footnote-prefix"
- shortcodes.go:83 func FootnotePrefix(n ast.Node) []byte
- shortcodes.go:101 type includes struct
- shortcodes.go:105 func (in includes) Transform(doc *ast.Document, reader text.Reader, pc parser.Context)
- shortcodes.go:129 func (in includes) convert(name string, pc parser.Context) ([]byte, error)
- shortcodes.go:160 func pending(doc *ast.Document) []*node
- shortcodes.go:178 var kind = ast.NewNodeKind("Shortcode")
- shortcodes.go:181 type node struct
- shortcodes.go:195 func (n *node) Kind() ast.NodeKind { return kind }
- shortcodes.go:197 func (n *node) Dump(source []byte, level int) { ast.DumpHelper(n, source, level, nil, nil) }
- shortcodes.go:200 type blocks struct{}
- shortcodes.go:202 func (blocks) Trigger() []byte { return []byte{' '} }
- shortcodes.go:204 func (blocks) Open(parent ast.Node, reader text.Reader, pc parser.Context) (ast.Node, parser.State)
- shortcodes.go:235 func gallery(pc parser.Context) []render.Picture
- shortcodes.go:262 func (blocks) Continue(n ast.Node, reader text.Reader, pc parser.Context) parser.State
- shortcodes.go:266 func (blocks) Close(n ast.Node, reader text.Reader, pc parser.Context) {}
- shortcodes.go:268 func (blocks) CanInterruptParagraph() bool { return true }
- shortcodes.go:270 func (blocks) CanAcceptIndentedLine() bool { return false }
- shortcodes.go:273 type fragments struct
- shortcodes.go:277 func (f fragments) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer)
- shortcodes.go:287 func (f fragments) render(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error)
- shortcodes.go:314 func parse(line, prefix string) (name string, args map[string]string, ok bool)
- shortcodes.go:349 func argument(s string) (key, value, rest string, ok bool)
## internal/ext/watch — 133 lines + 114 test
@@ -243,9 +257,9 @@ doc.go 8 · watch.go 125
- watch.go:90 func record(sum hash.Hash, p string, d fs.DirEntry, err error) error
- watch.go:113 func dropping(name string) bool
## internal/render — 713 lines + 489 test
## internal/render — 715 lines + 489 test
chrome.go 110 · render.go 473 · view.go 130
chrome.go 110 · render.go 475 · view.go 130
- chrome.go:19 var chrome = map[string]map[string]string{
- chrome.go:33 var months = map[string][]string{
@@ -260,29 +274,29 @@ chrome.go 110 · render.go 473 · view.go 130
- render.go:49 type parsedTheme struct
- render.go:63 type Partial func(name string, data Fragment) ([]byte, error)
- render.go:66 type Fragment struct
- render.go:76 type Picture struct
- render.go:93 type Origin struct
- render.go:102 var originKey = parser.NewContextKey()
- render.go:105 func OriginFrom(pc parser.Context) (Origin, bool)
- render.go:112 func WithOrigin(pc parser.Context, origin Origin)
- render.go:125 func New(siteFS fs.FS, settings content.Settings, extend func(Partial) []goldmark.Extender) (*Renderer, error)
- render.go:156 func parseTheme(siteFS fs.FS) (*parsedTheme, error)
- render.go:184 func (r *Renderer) head(title, lang, canonical string) head
- render.go:201 func (r *Renderer) absolute(path string) string
- render.go:209 func (r *Renderer) Navigation(sections func() []string) { r.sections = sections }
- render.go:221 func (r *Renderer) Refresh() error
- render.go:232 func (r *Renderer) Partial(name string, data Fragment) ([]byte, error)
- render.go:251 func parseSet(siteFS fs.FS, names ...string) (*template.Template, error)
- render.go:273 func readStyle(siteFS fs.FS) (template.CSS, error)
- render.go:291 func (r *Renderer) Extras(b content.Bundle, served string, entries []content.Entry, selected *Selected) ([]byte, error)
- render.go:312 func (r *Renderer) RenderText(kind string, data []byte) (template.HTML, error)
- render.go:330 func (r *Renderer) Bundle(b content.Bundle, served string, variants []string, seq *content.Sequence) ([]byte, error)
- render.go:367 func (r *Renderer) Listing(section, lang string, all []content.Bundle, page int) ([]byte, error)
- render.go:386 func (r *Renderer) Tag(section, slug, lang string, all []content.Bundle, page int) ([]byte, error)
- render.go:413 func (r *Renderer) sequence(seq *content.Sequence, lang string) *Sequence
- render.go:440 func (r *Renderer) item(b content.Bundle, lang string) Item
- render.go:445 func (r *Renderer) paginate(title, lang, canonical string, all []content.Bundle, page int, url func(int) string) (List, []content.Bundle)
- render.go:467 func (r *Renderer) execute(set *template.Template, data any, what string) ([]byte, error)
- render.go:78 type Picture struct
- render.go:95 type Origin struct
- render.go:104 var originKey = parser.NewContextKey()
- render.go:107 func OriginFrom(pc parser.Context) (Origin, bool)
- render.go:114 func WithOrigin(pc parser.Context, origin Origin)
- render.go:127 func New(siteFS fs.FS, settings content.Settings, extend func(Partial) []goldmark.Extender) (*Renderer, error)
- render.go:158 func parseTheme(siteFS fs.FS) (*parsedTheme, error)
- render.go:186 func (r *Renderer) head(title, lang, canonical string) head
- render.go:203 func (r *Renderer) absolute(path string) string
- render.go:211 func (r *Renderer) Navigation(sections func() []string) { r.sections = sections }
- render.go:223 func (r *Renderer) Refresh() error
- render.go:234 func (r *Renderer) Partial(name string, data Fragment) ([]byte, error)
- render.go:253 func parseSet(siteFS fs.FS, names ...string) (*template.Template, error)
- render.go:275 func readStyle(siteFS fs.FS) (template.CSS, error)
- render.go:293 func (r *Renderer) Extras(b content.Bundle, served string, entries []content.Entry, selected *Selected) ([]byte, error)
- render.go:314 func (r *Renderer) RenderText(kind string, data []byte) (template.HTML, error)
- render.go:332 func (r *Renderer) Bundle(b content.Bundle, served string, variants []string, seq *content.Sequence) ([]byte, error)
- render.go:369 func (r *Renderer) Listing(section, lang string, all []content.Bundle, page int) ([]byte, error)
- render.go:388 func (r *Renderer) Tag(section, slug, lang string, all []content.Bundle, page int) ([]byte, error)
- render.go:415 func (r *Renderer) sequence(seq *content.Sequence, lang string) *Sequence
- render.go:442 func (r *Renderer) item(b content.Bundle, lang string) Item
- render.go:447 func (r *Renderer) paginate(title, lang, canonical string, all []content.Bundle, page int, url func(int) string) (List, []content.Bundle)
- render.go:469 func (r *Renderer) execute(set *template.Template, data any, what string) ([]byte, error)
- view.go:16 type head struct
- view.go:36 type Page struct
- view.go:55 type Sequence struct
@@ -293,7 +307,7 @@ chrome.go 110 · render.go 473 · view.go 130
- view.go:111 type Item struct
- view.go:122 type Alternate struct
## internal/web — 734 lines + 1610 test
## internal/web — 734 lines + 1613 test
asset.go 58 · discover.go 71 · extras.go 93 · feed.go 125 · resolve.go 170 · web.go 217
+7
View File
@@ -90,6 +90,7 @@ Every fragment receives the same two fields (ADR-0037):
|---|---|
| `.Args` | the call's `key=value` pairs, exactly as the author wrote them |
| `.Pictures` | images the *engine* gathered: one for a figure, many for a gallery, none when the call names nothing it recognises (ADR-0042) |
| `.Body` | a container call's content, already rendered to HTML. Empty for every leaf call (ADR-0064) |
Each picture carries:
@@ -104,6 +105,12 @@ Each picture carries:
| `::figure{src=… alt="…" caption=…}` | `figure` | `.Args.src`, `.Args.alt`, `.Args.caption` |
| `::gallery` | `gallery` | `.Pictures` — every picture beside the bundle, in filename order |
| `:name:` | `icon` | `.Args.name` — the name as written, nothing else |
| `:::note{title=…}``:::` | `note` | `.Args.title`, `.Body` — the rendered content |
A **container** call wraps content: `:::name{…}`, a body, then `:::`. It renders through the fragment of that
name with `.Body` already HTML, and one level only — a `:::` inside closes the one it is in. A theme with no
template for the kind renders nothing, and the engine writes the body out unwrapped, so an unknown kind costs
an author styling and never paragraphs (ADR-0064). The reference theme defines `note`, `warn` and `tip`.
The `icon` fragment is one template for the whole set: **which names exist is the theme's decision**, and the
engine holds no list of them (ADR-0063). Render nothing for a name you do not know — the engine then writes
@@ -23,6 +23,11 @@ dropped. Readings go to the NIWA archive, and the definition below expands every
*[NIWA]: National Institute of Water and Atmospheric Research
:::warn{title="Calibration"}
The south gauge reads low after a storm, so readings are corrected against the north one. A container's body
is Markdown: *emphasis*, links and H~2~O all work inside it.
:::
:warn: The south gauge reads low after a storm — the theme decides what that icon is, and an unknown one like
:nosuchicon: keeps its text. Times like 10:30:15 and pairs like key:value:pair are left alone.
+117
View File
@@ -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
}
+17 -6
View File
@@ -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
}
+43 -5
View File
@@ -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)
}
}
+2
View File
@@ -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}}
+3
View File
@@ -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; }
}
+3
View File
@@ -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,