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 }