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
+6
View File
@@ -385,6 +385,12 @@ and logs it — one typo does not take a page down (ADR-0029).
Shortcodes run on site-root content only (ADR-0003), never on anything untrusted. Shortcodes run on site-root content only (ADR-0003), never on anything untrusted.
**Icons** are the one inline call: `:warn:` renders through the theme's `icon` fragment (ADR-0063). A name
starts with a letter and holds letters, digits, hyphens and underscores, and neither colon may touch an
alphanumeric — so `10:30:15`, `key:value:pair`, `Note: this` and a URL are all left alone, and a code span is
never touched. Which names exist is the **theme's** decision; a name it does not know is written back exactly
as typed rather than disappearing.
`figure` and `gallery` exist. `::gallery` takes no arguments: it lists the pictures sitting beside the `figure` and `gallery` exist. `::gallery` takes no arguments: it lists the pictures sitting beside the
bundle, in filename order, which is why the sparse numeric-prefix convention orders a set without putting bundle, in filename order, which is why the sparse numeric-prefix convention orders a set without putting
numbers in URLs (ADR-0016). A subdirectory is not part of the gallery, and neither is a file the browser numbers in URLs (ADR-0016). A subdirectory is not part of the gallery, and neither is a file the browser
+22
View File
@@ -1006,3 +1006,25 @@ Expensive — the replacement walks every text node in the document, which is wo
rather than to the number of definitions; and definitions are document-scoped, so a term defined in a page rather than to the number of definitions; and definitions are document-scoped, so a term defined in a page
does not reach an included fragment, which is parsed on its own bytes (ADR-0038) exactly as footnotes are. does not reach an included fragment, which is parsed on its own bytes (ADR-0038) exactly as footnotes are.
Revisit if: definitions want to be site-wide, which is a settings-cascade question and not this mechanism. Revisit if: definitions want to be site-wide, which is a settings-cascade question and not this mechanism.
## ADR-0063 — `:name:` is an icon, and the theme owns what one is
Date: 2026-08-01 · Status: accepted
Decision: `:name:` inline parses to a call on one theme fragment, `icon`, receiving `.Args.name` and nothing
else. The engine holds no list of icon names. A name must start with a letter and hold only letters, digits,
hyphens and underscores, and neither colon may touch an alphanumeric. When the fragment renders nothing, the
engine writes the author's original `:name:` text back. The reference theme maps six names to Unicode
characters and ships no sprite, font or asset.
Why: 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,
which is the one worth copying). All three are decisions about *markup*, which ADR-0036 puts in the theme, so
the engine's half is identical whichever a theme picks: parse the name, hand it over. A theme wanting Font
Awesome or Material ships a sprite in its own `base.html` and redefines one fragment; no webfont, no request,
no script. The boundary rules are the hard part, because the colon is the commonest punctuation in technical
prose: without them `10:30:15`, `key:value:pair` and `Note: this` all become icon calls, which is a silent
edit to someone's sentence. The literal fallback closes the same hole from the other side — an unrecognised
name is left on the page rather than deleted from the middle of a paragraph.
Consequence: cheap — no icon table in Go, ever; the set is swapped by editing one template; and the two ways
this could damage prose are tests. Expensive — a theme cannot tell the engine which names it knows, so an
unknown name costs a fragment execution before falling back; and the reference theme's Unicode set is a
`{{if}}` chain, which is fine at six names and would not be at sixty.
Revisit if: a theme wants to declare its set to the engine — which is the settings cascade, not this.
+2 -2
View File
@@ -24,8 +24,8 @@ table owns.
| `internal/render/render.go` | goldmark with the typographer, per-kind template sets with site override, the render methods. The parsed sets plus the stylesheet are one snapshot behind an `atomic.Pointer`; `Refresh` is the only thing that replaces it, so every page serves one theme (ADR-0055, ADR-0056). Heading ids are a parser option set here (ADR-0058), and this is the one renderer that enables raw HTML (ADR-0060) | | `internal/render/render.go` | goldmark with the typographer, per-kind template sets with site override, the render methods. The parsed sets plus the stylesheet are one snapshot behind an `atomic.Pointer`; `Refresh` is the only thing that replaces it, so every page serves one theme (ADR-0055, ADR-0056). Heading ids are a parser option set here (ADR-0058), and this is the one renderer that enables raw HTML (ADR-0060) |
| `internal/render/view.go` | the theme contract in Go: `Page`, `List`, `Sequence`, `Extras`, `Item`, `Fragment`, `Picture`, `Origin` | | `internal/render/view.go` | the theme contract in Go: `Page`, `List`, `Sequence`, `Extras`, `Item`, `Fragment`, `Picture`, `Origin` |
| `internal/render/chrome.go` | the engine's own words: phrase table, month names, digits, and the `t`/`num`/`day` template funcs (ADR-0034) | | `internal/render/chrome.go` | the engine's own words: phrase table, month names, digits, and the `t`/`num`/`day` template funcs (ADR-0034) |
| `internal/render/templates/` | reference theme, complete: `base.html` (shell, navigation, language links, feed and OpenGraph), `page.html` (bundle, sequence, tags, extras), `list.html`, `extras.html`, `shortcodes.html`, `theme.css` (ADR-0026, ADR-0049) | | `internal/render/templates/` | reference theme, complete (six icon names map to Unicode, no assets — ADR-0063): `base.html` (shell, navigation, language links, feed and OpenGraph), `page.html` (bundle, sequence, tags, extras), `list.html`, `extras.html`, `shortcodes.html`, `theme.css` (ADR-0026, ADR-0049) |
| `internal/ext/shortcodes/` | first feature: `::name{key=value}` block parser and node renderer, rendering through a theme fragment (ADR-0036). `figure`, `gallery`, `include`, plus the derivative pass and remembered picture inspection (ADR-0042, ADR-0044). `FootnotePrefix` namespaces an included file's footnote ids (ADR-0058). Directive syntax since ADR-0059 | | `internal/ext/shortcodes/` | first feature: `::name{key=value}` block parser and node renderer, rendering through a theme fragment (ADR-0036). `figure`, `gallery`, `include`, plus the derivative pass and remembered picture inspection (ADR-0042, ADR-0044). `FootnotePrefix` namespaces an included file's footnote ids (ADR-0058). Directive syntax since ADR-0059, plus `icons.go`: `:name:` inline, rendered by the theme's one `icon` fragment (ADR-0063) |
| `internal/ext/notation/` | the inline marks CommonMark lacks: `~sub~`, `^sup^`, `==mark==`, and `~~strike~~`, which it owns so a single tilde can mean subscript (ADR-0061). `abbr.go` adds `*[TERM]:` definitions and the pass that expands them (ADR-0062) | | `internal/ext/notation/` | the inline marks CommonMark lacks: `~sub~`, `^sup^`, `==mark==`, and `~~strike~~`, which it owns so a single tilde can mean subscript (ADR-0061). `abbr.go` adds `*[TERM]:` definitions and the pass that expands them (ADR-0062) |
| `internal/ext/scaffold/` | writes one draft directory bundle into a site root through `os.Root`: never an overwrite | | `internal/ext/scaffold/` | writes one draft directory bundle into a site root through `os.Root`: never an overwrite |
| `internal/ext/watch/` | polls `content/` and `templates/` on an interval it is given, ignores editor droppings, and reports a settled change (ADR-0022, ADR-0048, ADR-0056). `site.yaml` is deliberately not fingerprinted (ADR-0055) | | `internal/ext/watch/` | polls `content/` and `templates/` on an interval it is given, ignores editor droppings, and reports a settled change (ADR-0022, ADR-0048, ADR-0056). `site.yaml` is deliberately not fingerprinted (ADR-0055) |
+40 -28
View File
@@ -175,10 +175,22 @@ doc.go 8 · scaffold.go 94
- scaffold.go:76 func titleFrom(key string) string - scaffold.go:76 func titleFrom(key string) string
- scaffold.go:85 func mkdirAll(root *os.Root, dir string) error - scaffold.go:85 func mkdirAll(root *os.Root, dir string) error
## internal/ext/shortcodes — 611 lines + 435 test ## internal/ext/shortcodes — 738 lines + 477 test
doc.go 7 · images.go 250 · shortcodes.go 354 doc.go 7 · icons.go 125 · images.go 250 · shortcodes.go 356
- icons.go:19 const iconFragment = "icon"
- icons.go:27 type icons struct{}
- icons.go:29 func (icons) Trigger() []byte { return []byte{' '} }
- icons.go:31 func (icons) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node
- icons.go:49 var iconKind = ast.NewNodeKind("ShortcodeIcon")
- icons.go:51 type iconNode struct
- icons.go:57 func (n *iconNode) Kind() ast.NodeKind { return iconKind }
- icons.go:59 func (n *iconNode) Dump(source []byte, level int) { ast.DumpHelper(n, source, level, nil, nil) }
- icons.go:63 func (f fragments) renderIcon(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error)
- icons.go:83 func iconName(line []byte) (string, int, bool)
- icons.go:113 func isNameByte(c byte, first bool) bool
- icons.go:125 func isWordRune(r rune) bool { return unicode.IsLetter(r) || unicode.IsDigit(r) }
- images.go:31 var widths = []int{480, 960, 1440} - images.go:31 var widths = []int{480, 960, 1440}
- images.go:38 func Derive(siteFS fs.FS, cacheDir string) (int, error) - images.go:38 func Derive(siteFS fs.FS, cacheDir string) (int, error)
- images.go:76 func derive(data []byte, name, cacheDir string) (int, error) - images.go:76 func derive(data []byte, name, cacheDir string) (int, error)
@@ -195,31 +207,31 @@ doc.go 7 · images.go 250 · shortcodes.go 354
- shortcodes.go:34 func New(partial render.Partial) goldmark.Extender - shortcodes.go:34 func New(partial render.Partial) goldmark.Extender
- shortcodes.go:38 type extension struct - shortcodes.go:38 type extension struct
- shortcodes.go:47 func (e extension) Extend(md goldmark.Markdown) - shortcodes.go:47 func (e extension) Extend(md goldmark.Markdown)
- shortcodes.go:57 var nested = parser.NewContextKey() - shortcodes.go:58 var nested = parser.NewContextKey()
- shortcodes.go:62 var includedAs = parser.NewContextKey() - shortcodes.go:63 var includedAs = parser.NewContextKey()
- shortcodes.go:64 const footnoteKey = "khosra footnote-prefix" - shortcodes.go:65 const footnoteKey = "khosra footnote-prefix"
- shortcodes.go:72 func FootnotePrefix(n ast.Node) []byte - shortcodes.go:73 func FootnotePrefix(n ast.Node) []byte
- shortcodes.go:90 type includes struct - shortcodes.go:91 type includes struct
- shortcodes.go:94 func (in includes) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) - shortcodes.go:95 func (in includes) Transform(doc *ast.Document, reader text.Reader, pc parser.Context)
- shortcodes.go:118 func (in includes) convert(name string, pc parser.Context) ([]byte, error) - shortcodes.go:119 func (in includes) convert(name string, pc parser.Context) ([]byte, error)
- shortcodes.go:149 func pending(doc *ast.Document) []*node - shortcodes.go:150 func pending(doc *ast.Document) []*node
- shortcodes.go:167 var kind = ast.NewNodeKind("Shortcode") - shortcodes.go:168 var kind = ast.NewNodeKind("Shortcode")
- shortcodes.go:170 type node struct - shortcodes.go:171 type node struct
- shortcodes.go:184 func (n *node) Kind() ast.NodeKind { return kind } - shortcodes.go:185 func (n *node) Kind() ast.NodeKind { return kind }
- shortcodes.go:186 func (n *node) Dump(source []byte, level int) { ast.DumpHelper(n, source, level, nil, nil) } - shortcodes.go:187 func (n *node) Dump(source []byte, level int) { ast.DumpHelper(n, source, level, nil, nil) }
- shortcodes.go:189 type blocks struct{} - shortcodes.go:190 type blocks struct{}
- shortcodes.go:191 func (blocks) Trigger() []byte { return []byte{' '} } - shortcodes.go:192 func (blocks) Trigger() []byte { return []byte{' '} }
- shortcodes.go:193 func (blocks) Open(parent ast.Node, reader text.Reader, pc parser.Context) (ast.Node, parser.State) - shortcodes.go:194 func (blocks) Open(parent ast.Node, reader text.Reader, pc parser.Context) (ast.Node, parser.State)
- shortcodes.go:224 func gallery(pc parser.Context) []render.Picture - shortcodes.go:225 func gallery(pc parser.Context) []render.Picture
- shortcodes.go:251 func (blocks) Continue(n ast.Node, reader text.Reader, pc parser.Context) parser.State - shortcodes.go:252 func (blocks) Continue(n ast.Node, reader text.Reader, pc parser.Context) parser.State
- shortcodes.go:255 func (blocks) Close(n ast.Node, reader text.Reader, pc parser.Context) {} - shortcodes.go:256 func (blocks) Close(n ast.Node, reader text.Reader, pc parser.Context) {}
- shortcodes.go:257 func (blocks) CanInterruptParagraph() bool { return true } - shortcodes.go:258 func (blocks) CanInterruptParagraph() bool { return true }
- shortcodes.go:259 func (blocks) CanAcceptIndentedLine() bool { return false } - shortcodes.go:260 func (blocks) CanAcceptIndentedLine() bool { return false }
- shortcodes.go:262 type fragments struct - shortcodes.go:263 type fragments struct
- shortcodes.go:266 func (f fragments) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) - shortcodes.go:267 func (f fragments) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer)
- shortcodes.go:274 func (f fragments) render(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) - shortcodes.go:276 func (f fragments) render(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error)
- shortcodes.go:301 func parse(line string) (name string, args map[string]string, ok bool) - shortcodes.go:303 func parse(line string) (name string, args map[string]string, ok bool)
- shortcodes.go:336 func argument(s string) (key, value, rest string, ok bool) - shortcodes.go:338 func argument(s string) (key, value, rest string, ok bool)
## internal/ext/watch — 133 lines + 114 test ## internal/ext/watch — 133 lines + 114 test
@@ -281,7 +293,7 @@ chrome.go 110 · render.go 473 · view.go 130
- view.go:111 type Item struct - view.go:111 type Item struct
- view.go:122 type Alternate struct - view.go:122 type Alternate struct
## internal/web — 734 lines + 1608 test ## internal/web — 734 lines + 1610 test
asset.go 58 · discover.go 71 · extras.go 93 · feed.go 125 · resolve.go 170 · web.go 217 asset.go 58 · discover.go 71 · extras.go 93 · feed.go 125 · resolve.go 170 · web.go 217
+7
View File
@@ -103,6 +103,13 @@ Each picture carries:
|---|---|---| |---|---|---|
| `::figure{src=… alt="…" caption=…}` | `figure` | `.Args.src`, `.Args.alt`, `.Args.caption` | | `::figure{src=… alt="…" caption=…}` | `figure` | `.Args.src`, `.Args.alt`, `.Args.caption` |
| `::gallery` | `gallery` | `.Pictures` — every picture beside the bundle, in filename order | | `::gallery` | `gallery` | `.Pictures` — every picture beside the bundle, in filename order |
| `:name:` | `icon` | `.Args.name` — the name as written, nothing else |
The `icon` fragment is one template for the whole set: **which names exist is the theme's decision**, and the
engine holds no list of them (ADR-0063). Render nothing for a name you do not know — the engine then writes
the author's original `:name:` text back, so an unrecognised icon is never dropped out of a sentence. The
reference theme maps six names to Unicode characters and ships no sprite, font or asset; a theme wanting drawn
icons defines a sprite in its own `base.html` and emits `<use>` from this fragment.
`::include{file=…}` has **no fragment**: an included file is content, so it renders as Markdown in `::include{file=…}` has **no fragment**: an included file is content, so it renders as Markdown in
place and a theme has nothing to style about it (ADR-0038). place and a theme has nothing to style about it (ADR-0038).
@@ -23,6 +23,9 @@ dropped. Readings go to the NIWA archive, and the definition below expands every
*[NIWA]: National Institute of Water and Atmospheric Research *[NIWA]: National Institute of Water and Atmospheric Research
:warn: The south gauge reads low after a storm — the theme decides what that icon is, and an unknown one like
:nosuchicon: keeps its text. Times like 10:30:15 and pairs like key:value:pair are left alone.
| Gauge | Reading | Note | | Gauge | Reading | Note |
|-------|---------|------| |-------|---------|------|
| North | 41 mm | steady | | North | 41 mm | steady |
+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) }
+2
View File
@@ -48,6 +48,7 @@ func (e extension) Extend(md goldmark.Markdown) {
md.Parser().AddOptions( md.Parser().AddOptions(
parser.WithBlockParsers(util.Prioritized(blocks{}, 100)), parser.WithBlockParsers(util.Prioritized(blocks{}, 100)),
parser.WithASTTransformers(util.Prioritized(includes{md: md}, 100)), parser.WithASTTransformers(util.Prioritized(includes{md: md}, 100)),
parser.WithInlineParsers(util.Prioritized(icons{}, 500)),
) )
md.Renderer().AddOptions(renderer.WithNodeRenderers( md.Renderer().AddOptions(renderer.WithNodeRenderers(
util.Prioritized(fragments{partial: e.partial}, 100))) util.Prioritized(fragments{partial: e.partial}, 100)))
@@ -265,6 +266,7 @@ type fragments struct {
func (f fragments) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { func (f fragments) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
reg.Register(kind, f.render) reg.Register(kind, f.render)
reg.Register(iconKind, f.renderIcon)
} }
// render writes the theme's fragment for this call. // render writes the theme's fragment for this call.
@@ -281,3 +281,45 @@ func TestASiteRedefinesOneFragment(t *testing.T) {
t.Error("the embedded fragment should have been replaced, not appended") t.Error("the embedded fragment should have been replaced, not appended")
} }
} }
func TestIconsRenderThroughTheThemeAndNeverEatProse(t *testing.T) {
got := body(t, wired(t, nil), "Careful :warn: and :note: here.\n")
for _, want := range []string{"⚠️", "️"} {
if !strings.Contains(got, want) {
t.Errorf("missing %q:\n%s", want, got)
}
}
// The colon is the commonest punctuation in technical prose. Each of these would be a silent edit to
// someone's sentence (ADR-0063).
for _, prose := range []string{
"Times 10:30:15 exactly.",
"Pairs key:value:pair here.",
"Note: this matters.",
"See https://x.example/ for more.",
"A ratio of 3:4:5.",
} {
out := body(t, wired(t, nil), prose+"\n")
if !strings.Contains(out, prose) {
t.Errorf("prose was edited: %q became:\n%s", prose, out)
}
}
}
// A theme that does not know a name renders nothing, and the engine puts the author's text back rather than
// dropping a word out of the middle of a sentence.
func TestAnUnknownIconKeepsItsText(t *testing.T) {
got := body(t, wired(t, nil), "Before :nosuchicon: after.\n")
if !strings.Contains(got, ":nosuchicon:") {
t.Errorf("an unknown icon must keep its literal text:\n%s", got)
}
if !strings.Contains(got, "Before") || !strings.Contains(got, "after.") {
t.Errorf("the sentence around it must survive:\n%s", got)
}
}
func TestAnIconInCodeIsLiteral(t *testing.T) {
got := body(t, wired(t, nil), "Write `:warn:` to get one.\n")
if strings.Contains(got, "⚠️") {
t.Errorf("a code span is the author's literal text:\n%s", got)
}
}
+14
View File
@@ -21,3 +21,17 @@
</div> </div>
{{- end}} {{- end}}
{{- end}} {{- end}}
{{/* One template for the whole set: which names exist is the theme's business, never the engine's
(ADR-0063). Unicode here, so the reference theme ships no sprite, no font and no asset — a theme
wanting drawn icons redefines this block and emits <use> against its own sprite. An unknown name
renders nothing, which is how a theme says so, and the engine puts the author's text back. */}}
{{define "icon"}}
{{- if eq .Args.name "warn"}}⚠️
{{- else if eq .Args.name "note"}}
{{- else if eq .Args.name "tip"}}💡
{{- else if eq .Args.name "star"}}⭐
{{- else if eq .Args.name "check"}}✅
{{- else if eq .Args.name "cross"}}❌
{{- end}}
{{- end}}
+2
View File
@@ -127,6 +127,8 @@ var exampleFeatures = []featureCase{
{what: "an abbreviation expands and its definition line renders nothing", path: "/writing/notes-on-water/", code: 200, {what: "an abbreviation expands and its definition line renders nothing", path: "/writing/notes-on-water/", code: 200,
expect: []string{`<abbr title="National Institute of Water and Atmospheric Research">NIWA</abbr>`}, expect: []string{`<abbr title="National Institute of Water and Atmospheric Research">NIWA</abbr>`},
absent: []string{"*[NIWA]"}}, absent: []string{"*[NIWA]"}},
{what: "an icon renders through the theme, and prose colons are untouched", path: "/writing/notes-on-water/", code: 200,
expect: []string{"⚠️", ":nosuchicon:", "10:30:15", "key:value:pair"}},
{what: "the dialect renders tables, definition lists and strikethrough", path: "/writing/notes-on-water/", code: 200, {what: "the dialect renders tables, definition lists and strikethrough", path: "/writing/notes-on-water/", code: 200,
expect: []string{"<table>", "<th>Gauge</th>", "<dl>", "<dt>Monsoon</dt>", "<del>a struck phrase</del>"}}, expect: []string{"<table>", "<th>Gauge</th>", "<dl>", "<dt>Monsoon</dt>", "<del>a struck phrase</del>"}},
{what: "a fragment's footnote ids are namespaced, so the page's own keep working", path: "/writing/notes-on-water/", code: 200, {what: "a fragment's footnote ids are namespaced, so the page's own keep working", path: "/writing/notes-on-water/", code: 200,