Files
khosra/internal/ext/shortcodes/shortcodes.go
T
bdeshi d67cfd186f include another file from the bundle, one level deep
{{< include file="notes.md" >}} renders a file from the bundle as Markdown in
place. The included file is converted by the same goldmark instance that is
rendering the page — handed to the transformer in Extend — so its configuration
can never drift from the page's.

Three properties, each tested:

A name containing ".." is refused. os.Root would stop a path leaving the site
root, but path.Join collapses ".." long before the filesystem sees it, so without
this an include could read a template or a stray dotfile from the site root and
publish it. Verified the test fails without the guard: it took three levels of
".." from content/pages/d to reach the root, and the first version of the test
used two, so it passed either way and proved nothing.

An include inside an included file renders nothing and logs. The nested parse is
marked, so one level is all there is and a file including itself is a log line
rather than a stack overflow (ADR-0038, ADR-0029).

A gallery inside an included file still resolves, because the nested parse carries
the same Origin.

The node gained `content` for output a feature produced itself, plus `isContent`
so a failed include renders nothing instead of falling through to a fragment
lookup and complaining about a template that was never meant to exist.
2026-07-31 02:00:39 +06:00

309 lines
10 KiB
Go

package shortcodes
import (
"bytes"
"fmt"
"io/fs"
"log/slog"
"path"
"slices"
"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
// items are what the feature gathered at parse time, when it still knew which bundle this is.
items []string
// 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.items = images(pc)
case "include":
// Filled in by the transformer, which runs once this parse is complete.
n.isContent = true
}
return n, parser.NoChildren
}
// pictures are the extensions a gallery treats as an image. A file the browser cannot show is not a
// gallery entry, and guessing by content would mean reading every file in the directory.
var pictures = []string{".avif", ".gif", ".jpeg", ".jpg", ".png", ".svg", ".webp"}
// images 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 images(pc parser.Context) []string {
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 found []string
for _, e := range entries {
if e.IsDir() {
continue
}
if slices.Contains(pictures, strings.ToLower(path.Ext(e.Name()))) {
found = append(found, e.Name())
}
}
sort.Strings(found)
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, Items: call.items})
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
}