Files
khosra/internal/ext/shortcodes/shortcodes.go
T
bdeshi 820720de08 add shortcodes as the first internal/ext feature
A call is `{{< name key="value" >}}` alone on a line, parsed by a goldmark block
parser into an AST node and rendered by executing a theme template of that name
(ADR-0036). `figure` ships; `include` and `gallery` need the including bundle's
directory, which the parser does not carry yet, so they wait.

The layering did the design work here. internal/render may not import
internal/ext, so render.New takes a callback that receives a Partial and returns
Markdown extensions, and cmd/khosra/wire.go holds the only list of enabled
features. Empty that list and the engine still builds and serves — which is the
property extensions.md says the contract should have.

Raw HTML stays disabled. An author's text reaches a page only as arguments that
html/template escapes in context, which the real binary shows: a hostile alt
becomes &lt;script&gt; and src="javascript:…" becomes #ZgotmplZ. Getting
contextual escaping from the standard library rather than writing it is the whole
reason a fragment renders this instead of the feature.

parseSet became variadic so the fragment set reuses it rather than growing a
second copy of the overlay logic; `Partial` takes map[string]string after the
advisory correctly flagged `any` as generality nothing had asked for.
2026-07-30 10:29:00 +06:00

162 lines
4.9 KiB
Go

package shortcodes
import (
"log/slog"
"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/render"
)
// open and close delimit a call. Chosen to be something no Markdown construct claims and no author types
// by accident; the syntax is a disk contract, so it does not change (ADR-0036).
const (
opener = "{{<"
closer = ">}}"
)
// 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 and the node renderer. Priorities sit above goldmark's paragraph
// parser so a line that is only a call never becomes a paragraph.
func (e extension) Extend(md goldmark.Markdown) {
md.Parser().AddOptions(parser.WithBlockParsers(
util.Prioritized(blocks{}, 100)))
md.Renderer().AddOptions(renderer.WithNodeRenderers(
util.Prioritized(fragments{partial: e.partial}, 100)))
}
// 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
}
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))
if !ok {
return nil, parser.NoChildren
}
reader.Advance(seg.Len() - 1)
return &node{name: name, args: args}, parser.NoChildren
}
// 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)
}
// 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)
out, err := f.partial(call.name, call.args)
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 string) (name string, args map[string]string, ok bool) {
body, found := strings.CutPrefix(strings.TrimSpace(line), opener)
if !found {
return "", nil, false
}
body, found = strings.CutSuffix(strings.TrimSpace(body), closer)
if !found {
return "", nil, false
}
body = strings.TrimSpace(body)
name, rest, _ := strings.Cut(body, " ")
if name == "" || strings.ContainsAny(name, `="`) {
return "", nil, false
}
args = map[string]string{}
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.
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
}
quoted, found := strings.CutPrefix(after, `"`)
if !found {
return "", "", "", false
}
value, rest, found = strings.Cut(quoted, `"`)
if !found {
return "", "", "", false
}
return key, value, strings.TrimSpace(rest), true
}