Files
khosra/internal/ext/shortcodes/shortcodes.go
T
Claude Opus 5andbdeshi 67defae912 highlight code server-side, and let a block quote a file
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.
2026-08-02 00:16:32 +06:00

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
}