Files
khosra/internal/ext/shortcodes/shortcodes.go
T
Claude Opus 5andbdeshi 78b8c51dff 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.
2026-08-01 22:00:33 +06:00

368 lines
13 KiB
Go

package shortcodes
import (
"bytes"
"fmt"
"io/fs"
"log/slog"
"path"
"sort"
"strings"
"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"
"khosra/internal/content"
"khosra/internal/render"
)
// opener begins a call: `::name`, alone on a line, with optional `{key=value}` attributes.
//
// 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 = "::"
containerOpener = ":::"
)
// New returns the Markdown extension, rendering each call through partial.
//
// The feature never writes markup: it hands the call's name and arguments to a theme template of the same
// name and writes whatever comes back (ADR-0036).
func New(partial render.Partial) goldmark.Extender {
return extension{partial: partial}
}
type extension struct {
partial render.Partial
}
// Extend registers the block parser, the include expander, and the node renderer. Priorities sit above
// goldmark's paragraph parser so a line that is only a call never becomes a paragraph.
//
// The expander is handed md itself, because an included file is converted by the same configuration as the
// 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(
// 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(
util.Prioritized(fragments{partial: e.partial}, 100)))
}
// nested marks a parse that is already inside an included file, so one level is all there is (ADR-0038).
var nested = parser.NewContextKey()
// includedAs carries the name of the file a nested parse is converting, and footnoteKey is where the same
// name lands on that parse's document once it starts. Two steps, because the parse context is what `convert`
// can reach and the document is what a renderer can reach.
var includedAs = parser.NewContextKey()
const footnoteKey = "khosra:footnote-prefix"
// FootnotePrefix namespaces a footnote's id by the included file it came from, and is handed to goldmark in
// `cmd/khosra/wire.go`.
//
// An included file is converted on its own bytes (ADR-0038), so goldmark numbers its footnotes from one all
// over again: without this a page carrying its own footnote and an included one has two `id="fn:1"`s, and the
// first reference jumps to the wrong note. Returns nothing for the page itself, which keeps the plain ids.
func FootnotePrefix(n ast.Node) []byte {
doc := n.OwnerDocument()
if doc == nil {
return nil
}
name, _ := doc.Meta()[footnoteKey].(string)
if name == "" {
return nil
}
return []byte(content.TagSlug(strings.TrimSuffix(name, path.Ext(name))) + "-")
}
// includes fills in each include call with the converted content of the file it names.
//
// A transformer, running after the parse, rather than a node renderer: converting needs the parse context to
// know which bundle this is, and a node renderer never receives one. The included file is converted on its
// own bytes and its output stored on the node — never by splicing its nodes into this tree, which cannot
// work, since a goldmark node holds offsets into the source it came from (ADR-0038).
type includes struct {
md goldmark.Markdown
}
func (in includes) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) {
insideInclude := pc.Get(nested) != nil
if name, is := pc.Get(includedAs).(string); is {
// Stamped here rather than in convert, because the document does not exist until the parse begins.
doc.AddMeta(footnoteKey, name)
}
for _, call := range pending(doc) {
if insideInclude {
slog.Error("ignoring an include inside an included file", "file", call.args["file"])
continue
}
content, err := in.convert(call.args["file"], pc)
if err != nil {
slog.Error("skipping include", "file", call.args["file"], "err", err)
continue
}
call.content = content
}
}
// convert reads one included file and renders it, relative to the bundle being rendered.
//
// The nested parse carries the same Origin, so a gallery inside an included file still resolves against the
// bundle, and it is marked nested, so an include there renders nothing.
func (in includes) convert(name string, pc parser.Context) ([]byte, error) {
if name == "" {
return nil, fmt.Errorf("include needs a file argument")
}
// A name is relative to the bundle and stays inside it. os.Root already refuses a path leaving the site
// root, but path.Join collapses ".." long before it gets there, so without this an include could read
// anything else in the site root — a template, a stray dotfile — and publish it. Sharing one fragment
// between bundles is a fair wish and not this: it needs somewhere to put shared parts, chosen on purpose.
if strings.Contains(name, "..") {
return nil, fmt.Errorf("include stays inside its bundle: %s", name)
}
origin, ok := render.OriginFrom(pc)
if !ok || origin.Files == nil {
return nil, fmt.Errorf("no site root to include from")
}
data, err := fs.ReadFile(origin.Files, path.Join(origin.Dir, name))
if err != nil {
return nil, err
}
inner := parser.NewContext()
render.WithOrigin(inner, origin)
inner.Set(nested, true)
inner.Set(includedAs, name)
var out bytes.Buffer
if err := in.md.Convert(data, &out, parser.WithContext(inner)); err != nil {
return nil, err
}
return out.Bytes(), nil
}
// pending lists the include calls in a tree.
func pending(doc *ast.Document) []*node {
var found []*node
err := ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
if call, is := n.(*node); is && call.name == "include" {
found = append(found, call)
}
return ast.WalkContinue, nil
})
if err != nil {
slog.Error("walking for includes", "err", err)
}
return found
}
// kind identifies a parsed call in the tree.
var kind = ast.NewNodeKind("Shortcode")
// node is one call: everything the renderer needs, and nothing from the source bytes.
type node struct {
ast.BaseBlock
name string
args map[string]string
// pictures are what the feature gathered at parse time, when it still knew which bundle this is.
pictures []render.Picture
// content is output the feature produced itself, written instead of a theme fragment. An included file
// is content, not decoration, so it has no template (ADR-0038).
content []byte
// isContent marks a call whose output is content, so a failure renders nothing rather than falling
// through to a fragment lookup and reporting a missing template that was never expected to exist.
isContent bool
}
func (n *node) Kind() ast.NodeKind { return kind }
func (n *node) Dump(source []byte, level int) { ast.DumpHelper(n, source, level, nil, nil) }
// blocks parses a line that is nothing but a call.
type blocks struct{}
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), opener)
if !ok {
return nil, parser.NoChildren
}
reader.Advance(seg.Len() - 1)
n := &node{name: name, args: args}
switch name {
case "gallery":
// Reading the filesystem happens here, where the parse context says which bundle this is; the
// renderer never gets one, so anything gathered has to be gathered now.
n.pictures = gallery(pc)
case "figure":
if origin, ok := render.OriginFrom(pc); ok {
if p, isPicture := picture(origin, args["src"]); isPicture {
n.pictures = []render.Picture{p}
}
}
case "include":
// Filled in by the transformer, which runs once this parse is complete.
n.isContent = true
}
return n, parser.NoChildren
}
// gallery lists the pictures sitting beside the bundle being rendered, sorted by filename.
//
// Sorted because the sparse numeric-prefix convention orders entries without putting numbers in URLs
// (ADR-0016), and because a directory read has no order worth relying on. A renderer without a site root
// gathers nothing rather than guessing.
func gallery(pc parser.Context) []render.Picture {
origin, ok := render.OriginFrom(pc)
if !ok || origin.Files == nil {
return nil
}
entries, err := fs.ReadDir(origin.Files, origin.Dir)
if err != nil {
slog.Error("gallery cannot read its bundle directory", "dir", origin.Dir, "err", err)
return nil
}
var names []string
for _, e := range entries {
if !e.IsDir() && showable(e.Name()) {
names = append(names, e.Name())
}
}
sort.Strings(names)
var found []render.Picture
for _, name := range names {
if p, ok := picture(origin, name); ok {
found = append(found, p)
}
}
return found
}
// Continue never runs: a call is one line, closed as soon as it opens.
func (blocks) Continue(n ast.Node, reader text.Reader, pc parser.Context) parser.State {
return parser.Close
}
func (blocks) Close(n ast.Node, reader text.Reader, pc parser.Context) {}
func (blocks) CanInterruptParagraph() bool { return true }
func (blocks) CanAcceptIndentedLine() bool { return false }
// fragments renders a parsed call through the theme.
type fragments struct {
partial render.Partial
}
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.
//
// A missing or broken template logs and renders nothing: a shortcode is content decoration, and one typo
// in a bundle must not take a page down (extensions.md rule 5, ADR-0029).
func (f fragments) render(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
call := n.(*node)
if call.isContent {
if _, err := w.Write(call.content); err != nil {
return ast.WalkStop, err
}
return ast.WalkContinue, nil
}
out, err := f.partial(call.name, render.Fragment{Args: call.args, Pictures: call.pictures})
if err != nil {
slog.Error("skipping shortcode", "name", call.name, "err", err)
return ast.WalkContinue, nil
}
if _, err := w.Write(out); err != nil {
return ast.WalkStop, err
}
return ast.WalkContinue, nil
}
// parse reads one line as a call, reporting false for anything else.
//
// 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, prefix string) (name string, args map[string]string, ok bool) {
body, found := strings.CutPrefix(strings.TrimSpace(line), prefix)
if !found {
return "", nil, false
}
name, rest, hasArgs := strings.Cut(strings.TrimSpace(body), "{")
name = strings.TrimSpace(name)
// A leading colon means three of them, which opens a container directive this parser does not claim.
if name == "" || strings.ContainsAny(name, `:="{} `) {
return "", nil, false
}
args = map[string]string{}
if !hasArgs {
return name, args, true
}
rest, found = strings.CutSuffix(strings.TrimSpace(rest), "}")
if !found {
return "", nil, false
}
for rest = strings.TrimSpace(rest); rest != ""; {
key, value, remainder, valid := argument(rest)
if !valid {
return "", nil, false
}
args[key] = value
rest = remainder
}
return name, args, true
}
// argument reads one `key=value` pair and returns what follows it.
//
// Quotes are needed only for a value containing spaces, which is what makes the short form short: most
// arguments are a filename or a word. The closing brace is already gone by the time this runs, so an
// unquoted value cannot swallow it.
func argument(s string) (key, value, rest string, ok bool) {
key, after, found := strings.Cut(s, "=")
key = strings.TrimSpace(key)
if !found || key == "" || strings.ContainsAny(key, `" `) {
return "", "", "", false
}
if quoted, isQuoted := strings.CutPrefix(after, `"`); isQuoted {
value, rest, found = strings.Cut(quoted, `"`)
if !found {
return "", "", "", false
}
return key, value, strings.TrimSpace(rest), true
}
value, rest, _ = strings.Cut(after, " ")
if value == "" {
return "", "", "", false
}
return key, value, strings.TrimSpace(rest), true
}