From df9335df332e3d410bce91208d094069a940cdf7 Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Sat, 1 Aug 2026 21:54:28 +0600 Subject: [PATCH] parse :name: as an icon, and let the theme decide what one is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/content-model.md | 6 + docs/decisions.md | 22 +++ docs/state.md | 4 +- docs/surface.md | 68 ++++++---- docs/theme-contract.md | 7 + .../writing/notes-on-water/index.en.md | 3 + internal/ext/shortcodes/icons.go | 125 ++++++++++++++++++ internal/ext/shortcodes/shortcodes.go | 2 + internal/ext/shortcodes/shortcodes_test.go | 42 ++++++ internal/render/templates/shortcodes.html | 14 ++ internal/web/example_test.go | 2 + 11 files changed, 265 insertions(+), 30 deletions(-) create mode 100644 internal/ext/shortcodes/icons.go diff --git a/docs/content-model.md b/docs/content-model.md index ff48336..2d76e38 100644 --- a/docs/content-model.md +++ b/docs/content-model.md @@ -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. +**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 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 diff --git a/docs/decisions.md b/docs/decisions.md index d695320..d7fe837 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -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 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. + +## 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. diff --git a/docs/state.md b/docs/state.md index 85afc7e..ed80ac6 100644 --- a/docs/state.md +++ b/docs/state.md @@ -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/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/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/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/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, 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/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) | diff --git a/docs/surface.md b/docs/surface.md index 4692989..666ab5c 100644 --- a/docs/surface.md +++ b/docs/surface.md @@ -175,10 +175,22 @@ doc.go 8 · scaffold.go 94 - scaffold.go:76 func titleFrom(key string) string - 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:38 func Derive(siteFS fs.FS, 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:38 type extension struct - shortcodes.go:47 func (e extension) Extend(md goldmark.Markdown) -- shortcodes.go:57 var nested = parser.NewContextKey() -- shortcodes.go:62 var includedAs = parser.NewContextKey() -- shortcodes.go:64 const footnoteKey = "khosra footnote-prefix" -- shortcodes.go:72 func FootnotePrefix(n ast.Node) []byte -- shortcodes.go:90 type includes struct -- shortcodes.go:94 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:149 func pending(doc *ast.Document) []*node -- shortcodes.go:167 var kind = ast.NewNodeKind("Shortcode") -- shortcodes.go:170 type node struct -- shortcodes.go:184 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:189 type blocks struct{} -- shortcodes.go:191 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:224 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:255 func (blocks) Close(n ast.Node, reader text.Reader, pc parser.Context) {} -- shortcodes.go:257 func (blocks) CanInterruptParagraph() bool { return true } -- shortcodes.go:259 func (blocks) CanAcceptIndentedLine() bool { return false } -- shortcodes.go:262 type fragments struct -- shortcodes.go:266 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:301 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:58 var nested = parser.NewContextKey() +- shortcodes.go:63 var includedAs = parser.NewContextKey() +- shortcodes.go:65 const footnoteKey = "khosra footnote-prefix" +- shortcodes.go:73 func FootnotePrefix(n ast.Node) []byte +- shortcodes.go:91 type includes struct +- shortcodes.go:95 func (in includes) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) +- shortcodes.go:119 func (in includes) convert(name string, pc parser.Context) ([]byte, error) +- shortcodes.go:150 func pending(doc *ast.Document) []*node +- shortcodes.go:168 var kind = ast.NewNodeKind("Shortcode") +- shortcodes.go:171 type node struct +- shortcodes.go:185 func (n *node) Kind() ast.NodeKind { return kind } +- shortcodes.go:187 func (n *node) Dump(source []byte, level int) { ast.DumpHelper(n, source, level, nil, nil) } +- shortcodes.go:190 type blocks struct{} +- shortcodes.go:192 func (blocks) Trigger() []byte { return []byte{' '} } +- shortcodes.go:194 func (blocks) Open(parent ast.Node, reader text.Reader, pc parser.Context) (ast.Node, parser.State) +- shortcodes.go:225 func gallery(pc parser.Context) []render.Picture +- shortcodes.go:252 func (blocks) Continue(n ast.Node, reader text.Reader, pc parser.Context) parser.State +- shortcodes.go:256 func (blocks) Close(n ast.Node, reader text.Reader, pc parser.Context) {} +- shortcodes.go:258 func (blocks) CanInterruptParagraph() bool { return true } +- shortcodes.go:260 func (blocks) CanAcceptIndentedLine() bool { return false } +- shortcodes.go:263 type fragments struct +- shortcodes.go:267 func (f fragments) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) +- shortcodes.go:276 func (f fragments) render(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) +- shortcodes.go:303 func parse(line string) (name string, args map[string]string, ok bool) +- shortcodes.go:338 func argument(s string) (key, value, rest string, ok bool) ## 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: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 diff --git a/docs/theme-contract.md b/docs/theme-contract.md index 73c7b1f..8d40bcc 100644 --- a/docs/theme-contract.md +++ b/docs/theme-contract.md @@ -103,6 +103,13 @@ Each picture carries: |---|---|---| | `::figure{src=… alt="…" caption=…}` | `figure` | `.Args.src`, `.Args.alt`, `.Args.caption` | | `::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 `` from this fragment. `::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). diff --git a/examples/demo-site/content/writing/notes-on-water/index.en.md b/examples/demo-site/content/writing/notes-on-water/index.en.md index 100827e..af348cb 100644 --- a/examples/demo-site/content/writing/notes-on-water/index.en.md +++ b/examples/demo-site/content/writing/notes-on-water/index.en.md @@ -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 +: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 | |-------|---------|------| | North | 41 mm | steady | diff --git a/internal/ext/shortcodes/icons.go b/internal/ext/shortcodes/icons.go new file mode 100644 index 0000000..21d2635 --- /dev/null +++ b/internal/ext/shortcodes/icons.go @@ -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) } diff --git a/internal/ext/shortcodes/shortcodes.go b/internal/ext/shortcodes/shortcodes.go index fee893e..b80f1ed 100644 --- a/internal/ext/shortcodes/shortcodes.go +++ b/internal/ext/shortcodes/shortcodes.go @@ -48,6 +48,7 @@ func (e extension) Extend(md goldmark.Markdown) { md.Parser().AddOptions( parser.WithBlockParsers(util.Prioritized(blocks{}, 100)), parser.WithASTTransformers(util.Prioritized(includes{md: md}, 100)), + parser.WithInlineParsers(util.Prioritized(icons{}, 500)), ) md.Renderer().AddOptions(renderer.WithNodeRenderers( util.Prioritized(fragments{partial: e.partial}, 100))) @@ -265,6 +266,7 @@ type fragments struct { func (f fragments) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { reg.Register(kind, f.render) + reg.Register(iconKind, f.renderIcon) } // render writes the theme's fragment for this call. diff --git a/internal/ext/shortcodes/shortcodes_test.go b/internal/ext/shortcodes/shortcodes_test.go index 2de849f..b06727a 100644 --- a/internal/ext/shortcodes/shortcodes_test.go +++ b/internal/ext/shortcodes/shortcodes_test.go @@ -281,3 +281,45 @@ func TestASiteRedefinesOneFragment(t *testing.T) { 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) + } +} diff --git a/internal/render/templates/shortcodes.html b/internal/render/templates/shortcodes.html index ba856cf..33fc56b 100644 --- a/internal/render/templates/shortcodes.html +++ b/internal/render/templates/shortcodes.html @@ -21,3 +21,17 @@ {{- 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 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}} diff --git a/internal/web/example_test.go b/internal/web/example_test.go index 531b1fd..3e9a282 100644 --- a/internal/web/example_test.go +++ b/internal/web/example_test.go @@ -127,6 +127,8 @@ var exampleFeatures = []featureCase{ {what: "an abbreviation expands and its definition line renders nothing", path: "/writing/notes-on-water/", code: 200, expect: []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, expect: []string{"", "", "
", "
Monsoon
", "a struck phrase"}}, {what: "a fragment's footnote ids are namespaced, so the page's own keep working", path: "/writing/notes-on-water/", code: 200,
Gauge