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:
@@ -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) }
|
||||
Reference in New Issue
Block a user