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 }