parse :name: as an icon, and let the theme decide what one is

The engine's half is one call: parse the name, hand it to a single `icon`
fragment, decide nothing else. No icon table in Go, ever. The surveyed engines
split three ways — Unicode (Hugo, Pandoc), a remote image per icon (Jekyll's
jemoji, which fails the sovereignty test outright), and inlined SVG from a
bundled set (MkDocs Material) — and all three are decisions about markup, which
ADR-0036 puts in the theme. A theme wanting Font Awesome ships a sprite in its
own base.html and redefines one fragment: no webfont, no request, no script.

The boundary rules are the whole difficulty, because the colon is the commonest
punctuation in technical prose. A name must start with a letter, hold only
letters, digits, hyphens and underscores, and neither colon may touch an
alphanumeric. Verified on the real binary that 10:30:15, key:value:pair,
"Note: this", a URL and a code span all come through untouched — each of them
would otherwise be a silent edit to someone's sentence.

The literal fallback closes the same hole from the other side: when the theme
renders nothing, the engine writes the author's `:name:` back, so an
unrecognised icon is never deleted from the middle of a paragraph.

An icon needed its own inline node rather than the block one — goldmark
distinguishes the two by type — which also avoids the type switch CLAUDE.md §6
forbids: two kinds, two renderer functions.

The reference theme maps six names to Unicode and ships no sprite, font or
asset. core 2794/2800, ext 1615/2000, 34 gates green.
This commit is contained in:
Claude Opus 5
2026-08-01 21:54:28 +06:00
committed by bdeshi
parent 661fb469e8
commit df9335df33
11 changed files with 265 additions and 30 deletions
+125
View File
@@ -0,0 +1,125 @@
package shortcodes
import (
"bytes"
"log/slog"
"unicode"
"unicode/utf8"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/util"
"khosra/internal/render"
)
// iconFragment is the theme template every icon renders through. One template for the whole set, not one per
// icon: which names exist is the theme's business, and the engine holds no list of them (ADR-0063).
const iconFragment = "icon"
// icons parses `:name:` into a call on the icon fragment.
//
// The colon is the commonest punctuation in technical prose, so this is deliberately hard to trigger: the
// name must start with a letter and hold only letters, digits and hyphens, and neither side of the pair may
// touch an alphanumeric. That leaves `10:30:15`, `key:value:pair` and `Note: this` alone, which is the whole
// difficulty of the syntax (ADR-0063).
type icons struct{}
func (icons) Trigger() []byte { return []byte{':'} }
func (icons) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node {
if before := block.PrecendingCharacter(); isWordRune(before) {
return nil
}
line, _ := block.PeekLine()
name, width, ok := iconName(line)
if !ok {
return nil
}
// The source text is kept so a name the theme does not know can be written back exactly as the author
// typed it, rather than vanishing from the middle of a sentence.
n := &iconNode{name: name, literal: string(line[:width])}
block.Advance(width)
return n
}
// iconKind is one inline call. Its own node rather than the block one because goldmark distinguishes the two
// by type, and an icon sits inside a sentence.
var iconKind = ast.NewNodeKind("ShortcodeIcon")
type iconNode struct {
ast.BaseInline
name string
literal string
}
func (n *iconNode) Kind() ast.NodeKind { return iconKind }
func (n *iconNode) Dump(source []byte, level int) { ast.DumpHelper(n, source, level, nil, nil) }
// renderIcon writes the theme's icon fragment, or the author's own text when the theme does not know the
// name. Rendering nothing is how a theme says so, and losing a word out of a sentence is never the answer.
func (f fragments) renderIcon(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
call := n.(*iconNode)
out, err := f.partial(iconFragment, render.Fragment{Args: map[string]string{"name": call.name}})
if err != nil {
slog.Error("skipping icon", "name", call.name, "err", err)
out = nil
}
if len(bytes.TrimSpace(out)) == 0 {
out = []byte(call.literal)
}
if _, err := w.Write(out); err != nil {
return ast.WalkStop, err
}
return ast.WalkContinue, nil
}
// iconName reads `:name:` from the start of a line and reports how many bytes it spans.
func iconName(line []byte) (string, int, bool) {
if len(line) < 3 || line[0] != ':' {
return "", 0, false
}
end := 0
for i := 1; i < len(line); i++ {
c := line[i]
if c == ':' {
end = i
break
}
if !isNameByte(c, i == 1) {
return "", 0, false
}
}
if end < 2 {
return "", 0, false
}
// Whatever follows the closing colon must not be part of a word either, or `:a:b` would be an icon.
if after := end + 1; after < len(line) {
r, _ := utf8.DecodeRune(line[after:])
if isWordRune(r) {
return "", 0, false
}
}
return string(line[1:end]), end + 1, true
}
// isNameByte reports whether c may appear in an icon name; first is true for the opening character, which
// must be a letter so a time like `10:30:15` cannot become one.
func isNameByte(c byte, first bool) bool {
switch {
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z':
return true
case first:
return false
case c >= '0' && c <= '9', c == '-', c == '_':
return true
}
return false
}
func isWordRune(r rune) bool { return unicode.IsLetter(r) || unicode.IsDigit(r) }