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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+2
-2
@@ -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) |
|
||||
|
||||
+40
-28
@@ -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
|
||||
|
||||
|
||||
@@ -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 `<use>` 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).
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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) }
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,3 +21,17 @@
|
||||
</div>
|
||||
{{- 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}}
|
||||
|
||||
@@ -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{`<abbr title="National Institute of Water and Atmospheric Research">NIWA</abbr>`},
|
||||
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{"<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,
|
||||
|
||||
Reference in New Issue
Block a user