chroma at render time, emitting CSS classes rather than inline colour, handed to a `code` theme fragment. Highlighting works with scripting off, in a feed reader, in a browser that never runs JavaScript. No lighter pure-Go option exists — every "alternative to chroma" is JavaScript, which the reference theme is gated against. A fence's info string carries the rest: title, numbers, start, hl=3,7-9, and file=name lines=A-B, which reads the snippet out of a file beside the bundle and numbers it by that file's own lines. So a post quotes several parts of one program without the copies drifting from it, and a reader can find what they are looking at. Verified on the real binary: the same file at lines 5-10 and 12-14, each numbered as it really is, with different lines tinted. Not a new package: a new one could not import the key=value parser this repo already has, because ADR-0069 forbids a feature importing its sibling, and a second parser for the same syntax is what §6 stops. Two costs, both stated in the ADR rather than buried. The binary goes from ~15MB to 19MB, for a project whose story is one small binary. And the reference theme now carries a token palette — the first thing in it that is a taste rather than a demonstration — kept to eight classes for that reason. The demo quotes a shell file, not a Go one: a .go file under examples/ joins the module and has to compile, which the build gate caught before it shipped. 6 of 9 modules, ext 2188/3500.
383 lines
14 KiB
Go
383 lines
14 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/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),
|
|
util.Prioritized(tables{}, 160),
|
|
util.Prioritized(code{}, 170),
|
|
),
|
|
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
|
|
}
|
|
// The parent's id set, so a heading repeated across fragments is suffixed rather than duplicated: two
|
|
// `## Description`s become `#description` and `#description-1` (ADR-0066). Without this each fragment
|
|
// numbers from scratch and the page carries the same id three times.
|
|
inner := parser.NewContext(parser.WithIDs(pc.IDs()))
|
|
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
|
|
// headings are the document's, gathered for a `::toc` call (ADR-0065).
|
|
headings []render.Heading
|
|
// lang is captured at parse time, because a fragment localises its own words and a node renderer has no
|
|
// parse context to ask (ADR-0067).
|
|
lang 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), opener)
|
|
if !ok {
|
|
return nil, parser.NoChildren
|
|
}
|
|
reader.Advance(seg.Len() - 1)
|
|
n := &node{name: name, args: args}
|
|
if origin, ok := render.OriginFrom(pc); ok {
|
|
n.lang = origin.Lang
|
|
}
|
|
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)
|
|
reg.Register(codeKind, f.renderCode)
|
|
}
|
|
|
|
// 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, Headings: call.headings, Lang: call.lang})
|
|
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
|
|
}
|