A pass over the content at startup writes three widths per picture into a cache outside the site root, named by the source's content hash and the width (ADR-0042). Idempotent by construction: a rerun stats and skips, an edited picture takes a new name, and nothing stale can be served under an old one. Restarting the evidence site made 0 derivatives the second time, as it should. Ahead of the request rather than during it, because resampling is felt and there is no page cache yet to hide it. Outside the site root, because the engine reads that directory and must not leave generated files in somebody's content git — a lost cache costs one startup pass and no correctness. Markup now carries the original as src, the derivatives as srcset closed by the original at its own width, and width/height from the original — which retires most of the latent row about the output floor; only a gallery's alt is still empty, and a filename cannot supply that. Two things the work itself decided: `Fragment.Items` became `Fragment.Pictures`, ADR-0037's own revisit trigger. Items had one consumer, so widening it beat adding a second list beside it. "A browser can show it" and "we can resample it" are different questions, and conflating them nearly deleted content: an SVG has no decoder here, so a single predicate would have dropped SVGs from galleries silently. Undecodable and unsupported pictures are now rendered as they are, without a size or a srcset.
313 lines
10 KiB
Go
313 lines
10 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/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, 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(util.Prioritized(blocks{}, 100)),
|
|
parser.WithASTTransformers(util.Prioritized(includes{md: md}, 100)),
|
|
)
|
|
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()
|
|
|
|
// 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
|
|
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)
|
|
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))
|
|
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)
|
|
}
|
|
|
|
// 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 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
|
|
}
|