split the theme's fragments into a directory, keeping the file form

One file held every fragment, and it gained one per feature all session: figure,
gallery, icon, three admonitions, details, aside, contents. A theme author
overriding one had to copy the file or redefine into it, and a diff of the theme
became a diff of everything.

Both forms are supported, because a small theme is happier with one file and the
contract should not force a directory on it. Parse order is embedded file,
embedded directory, site file, site directory, and the last definition wins — so
the directory overrides the file within one source and a site overrides the
binary either way. Verified with a site root using both at once: its
shortcodes.html supplied `icon`, its shortcodes/note.html supplied `note`, the
embedded directory supplied the rest, and with the same name in both the
directory won.

The embedded theme ships the directory only, seven files, so nothing is defined
twice.

parseSet takes globs now and lost a branch doing it. Its old guard — a literal
embedded name must exist — had to go, since shortcodes.html is deliberately
absent; the replacement is stronger, failing at startup when a set matches
nothing anywhere, which also catches a renamed base.html.
This commit is contained in:
Claude Opus 5
2026-08-01 23:39:12 +06:00
committed by bdeshi
parent 4f706f1c35
commit 1b898fdfc3
15 changed files with 143 additions and 110 deletions
+2 -1
View File
@@ -14,7 +14,8 @@ whatever builds it. What exists is `state.md`'s job. Never build a `[spec]` sect
The engine is pointed at a site root outside this repository (`-site <dir>`, or `KHOSRA_SITE`), with
its own git history (ADR-0011). Nothing in the engine repo is content. `templates/` in the site root
overrides the defaults the binary embeds, so a bare root still renders.
overrides the defaults the binary embeds, so a bare root still renders. Shortcode fragments may be one
`templates/shortcodes.html` or a `templates/shortcodes/` directory, or both (ADR-0071).
```
<site>/
+20
View File
@@ -1166,3 +1166,23 @@ person meets the counter rather than in an ADR they would have to find. Expensiv
sentence that may be genuinely hard to write, which is the point and will still feel like friction.
Revisit if: a counter is added whose exclusion is honestly "nothing" — then the column is wrong, or the
counter is.
## ADR-0071 — Shortcode fragments may be a file or a directory; the directory wins
Date: 2026-08-01 · Status: accepted (extends ADR-0019's parse-order mechanism to a glob)
Decision: fragments are parsed from `templates/shortcodes.html` and then `templates/shortcodes/*.html`,
embedded first and the site's after, so within one source the directory overrides the file and a site
overrides the binary. The embedded reference theme now ships the directory only — seven files, no
`shortcodes.html` — so nothing is defined twice. `parseSet` takes globs, and a set that matches nothing
anywhere is a startup failure.
Why: one file held every fragment, and it grew a fragment per feature all session — figure, gallery, icon,
three admonitions, details, aside, contents. A theme author overriding one of them had to copy the file or
redefine into it, and a diff of the theme became a diff of everything. Both forms stay supported because a
small theme is happier with one file and the contract should not force a directory on it; the directory wins
because it is the more specific statement, the same way a site override beats the embedded set.
Consequence: cheap — the embedded theme is now seven readable files, `parseSet` grew a glob and lost a
branch, and a site may use either form or both at once, which is tested. Expensive — fragments now live in
two possible places, so "where is `figure` defined" has two answers; and the old guard, that a literal
embedded name must exist, had to go, since `shortcodes.html` is deliberately absent. Its replacement is
stronger: a set that matches nothing at all fails at startup, which also catches a renamed `base.html`.
Revisit if: the page and listing sets want the same treatment, which they do not yet — each is one file with
one block.
+1 -1
View File
@@ -24,7 +24,7 @@ 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, declared or derived (ADR-0058, ADR-0066), and this is the one renderer that enables raw HTML (ADR-0060). `Compose` is the seam a merging bundle's splice arrives through |
| `internal/render/view.go` | the theme contract in Go, and now actually all of it: `Page`, `List`, `Sequence`, `Extras`, `Item`, `Partial`, `Fragment` (with `Body`, `Headings` and `Lang` — ADR-0064, ADR-0065, ADR-0067), `Heading`, `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), including the words a shortcode fragment supplies when the author gives none (ADR-0067) |
| `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` (figure, gallery, icon, note/warn/tip, details, aside, toc — with the `sizes` its own layout implies, ADR-0068), `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/` — seven fragment files rather than one, and a site may use either form (ADR-0071) — with the `sizes` its own layout implies (ADR-0068), `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), `containers.go`: `:::name{…}``:::` wrapping a rendered body (ADR-0064), and `toc.go`: the document's headings for a `::toc` call, to a depth the call may set (ADR-0065, ADR-0066). `Merge` splices includes before the parse for a bundle that asks for it (ADR-0066) |
| `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 |
+13 -13
View File
@@ -266,9 +266,9 @@ doc.go 8 · watch.go 125
- watch.go:90 func record(sum hash.Hash, p string, d fs.DirEntry, err error) error
- watch.go:113 func dropping(name string) bool
## internal/render — 749 lines + 489 test
## internal/render — 756 lines + 489 test
chrome.go 115 · render.go 449 · view.go 185
chrome.go 115 · render.go 456 · view.go 185
- chrome.go:19 var chrome = map[string]map[string]string{
- chrome.go:38 var months = map[string][]string{
@@ -293,16 +293,16 @@ chrome.go 115 · render.go 449 · view.go 185
- render.go:190 func (r *Renderer) Refresh() error
- render.go:201 func (r *Renderer) Partial(name string, data Fragment) ([]byte, error)
- render.go:220 func parseSet(siteFS fs.FS, names ...string) (*template.Template, error)
- render.go:242 func readStyle(siteFS fs.FS) (template.CSS, error)
- render.go:260 func (r *Renderer) Extras(b content.Bundle, served string, entries []content.Entry, selected *Selected) ([]byte, error)
- render.go:281 func (r *Renderer) RenderText(kind string, data []byte) (template.HTML, error)
- render.go:299 func (r *Renderer) Bundle(b content.Bundle, served string, variants []string, seq *content.Sequence) ([]byte, error)
- render.go:343 func (r *Renderer) Listing(section, lang string, all []content.Bundle, page int) ([]byte, error)
- render.go:362 func (r *Renderer) Tag(section, slug, lang string, all []content.Bundle, page int) ([]byte, error)
- render.go:389 func (r *Renderer) sequence(seq *content.Sequence, lang string) *Sequence
- render.go:416 func (r *Renderer) item(b content.Bundle, lang string) Item
- render.go:421 func (r *Renderer) paginate(title, lang, canonical string, all []content.Bundle, page int, url func(int) string) (List, []content.Bundle)
- render.go:443 func (r *Renderer) execute(set *template.Template, data any, what string) ([]byte, error)
- render.go:249 func readStyle(siteFS fs.FS) (template.CSS, error)
- render.go:267 func (r *Renderer) Extras(b content.Bundle, served string, entries []content.Entry, selected *Selected) ([]byte, error)
- render.go:288 func (r *Renderer) RenderText(kind string, data []byte) (template.HTML, error)
- render.go:306 func (r *Renderer) Bundle(b content.Bundle, served string, variants []string, seq *content.Sequence) ([]byte, error)
- render.go:350 func (r *Renderer) Listing(section, lang string, all []content.Bundle, page int) ([]byte, error)
- render.go:369 func (r *Renderer) Tag(section, slug, lang string, all []content.Bundle, page int) ([]byte, error)
- render.go:396 func (r *Renderer) sequence(seq *content.Sequence, lang string) *Sequence
- render.go:423 func (r *Renderer) item(b content.Bundle, lang string) Item
- render.go:428 func (r *Renderer) paginate(title, lang, canonical string, all []content.Bundle, page int, url func(int) string) (List, []content.Bundle)
- render.go:450 func (r *Renderer) execute(set *template.Template, data any, what string) ([]byte, error)
- view.go:17 type head struct
- view.go:37 type Page struct
- view.go:56 type Sequence struct
@@ -318,7 +318,7 @@ chrome.go 115 · render.go 449 · view.go 185
- view.go:160 type Picture struct
- view.go:177 type Origin struct
## internal/web — 734 lines + 1631 test
## internal/web — 734 lines + 1633 test
asset.go 58 · discover.go 71 · extras.go 93 · feed.go 125 · resolve.go 170 · web.go 217
+18 -7
View File
@@ -81,8 +81,21 @@ block; site-supplied strings wait for the settings cascade (`ideas/deferred-deci
## Shortcode fragments
`templates/shortcodes.html` holds one named template per shortcode, and that is where a shortcode's markup
lives — the engine parses the call and supplies its data, never any HTML (ADR-0036).
Fragments live in `templates/shortcodes.html`, or in `templates/shortcodes/*.html`, or both — one named
template per shortcode, and that is where a shortcode's markup lives. The engine parses the call and supplies
its data, never any HTML (ADR-0036).
Parse order is embedded file, embedded directory, site file, site directory, and the last definition of a name
wins (ADR-0071). So **the directory overrides the file** within one source, and a site overrides the binary
either way. The reference theme ships the directory: `figure`, `gallery`, `icon`, `admonitions` (note, warn,
tip), `details`, `aside`, `toc`. Override one by defining that name anywhere the engine looks; everything you
leave alone is inherited.
Templates in the same set may call each other — `{{template "picture" .}}` from `figure.html` reaches a block
defined in `gallery.html`, because both are parsed into the partials set. The pipeline is all that passes, and
there is no way to build a value in a template, so inclusion is useful where the callee needs exactly what the
caller has. Across sets it does not work: `base`, `page`, `list`, `extras` and the fragments are five separate
sets, which is what stops a listing's `main` leaking into a bundle page (ADR-0019).
Every fragment receives the same two fields (ADR-0037):
@@ -146,9 +159,7 @@ missing renders nothing and logs; it never fails the page.
A gallery entry has no alt text: nothing in a filename supplies one, and inventing it would be worse than an
empty `alt` on a picture the page has already introduced. A figure's alt is the author's, from `.Args.alt`.
The set is overlaid the same way as the page kinds: a site's `templates/shortcodes.html` is parsed after
the embedded one, so redefining `figure` replaces it and any fragment left alone is inherited. Argument
names are contract, added but never renamed.
Argument names are contract, added but never renamed.
## Feed discovery
@@ -259,8 +270,8 @@ two drift together.
A site root's `templates/` is parsed **after** the embedded set, and the last definition of a name wins, so
a theme redefines one named block and inherits the document (ADR-0019). Each set is built from named files
and only those are overlaid — `base.html` plus that kind's block file (`page.html` or `list.html`), and
`shortcodes.html` on its own. Overlaying every site template into every set would let a listing's `main`
and only those are overlaid — `base.html` plus that kind's block file (`page.html` or `list.html`), and the
fragments on their own (`shortcodes.html` and `shortcodes/*.html`, ADR-0071). Overlaying every site template into every set would let a listing's `main`
leak into bundle pages, which is the collision per-kind sets exist to prevent.
`templates/theme.css` in the site root replaces the reference stylesheet entirely; there is no merging.
+20 -13
View File
@@ -125,7 +125,7 @@ func parseTheme(siteFS fs.FS) (*parsedTheme, error) {
if err != nil {
return nil, fmt.Errorf("listing templates: %w", err)
}
partials, err := parseSet(siteFS, "templates/shortcodes.html")
partials, err := parseSet(siteFS, "templates/shortcodes.html", "templates/shortcodes/*.html")
if err != nil {
return nil, fmt.Errorf("partial templates: %w", err)
}
@@ -219,22 +219,29 @@ func (r *Renderer) Partial(name string, data Fragment) ([]byte, error) {
// which is the collision per-kind sets exist to prevent.
func parseSet(siteFS fs.FS, names ...string) (*template.Template, error) {
// Funcs are attached before anything is parsed, so the chrome helpers are available to a site
// override's blocks as well as the embedded ones (ADR-0034).
set, err := template.New("theme").Funcs(funcs).ParseFS(themeFS, names...)
if err != nil {
return nil, fmt.Errorf("parse embedded: %w", err)
}
if siteFS == nil {
return set, nil
}
for _, name := range names {
if _, err := fs.Stat(siteFS, name); err != nil {
// override's blocks as well as the embedded ones (ADR-0034). A name may be a glob, which is how a
// directory of fragments is parsed after the single file it may replace (ADR-0071).
set, parsed := template.New("theme").Funcs(funcs), false
for _, from := range []fs.FS{themeFS, siteFS} {
if from == nil {
continue
}
if set, err = set.ParseFS(siteFS, name); err != nil {
return nil, fmt.Errorf("parse site override %s: %w", name, err)
for _, name := range names {
if matches, _ := fs.Glob(from, name); len(matches) == 0 {
continue
}
var err error
if set, err = set.ParseFS(from, name); err != nil {
return nil, fmt.Errorf("parse %s: %w", name, err)
}
parsed = true
}
}
// Nothing matched anywhere, which means a name the binary embeds has been renamed. A startup failure,
// because the alternative is an empty set and a template error on the first request.
if !parsed {
return nil, fmt.Errorf("no template matched %v", names)
}
return set, nil
}
-75
View File
@@ -1,75 +0,0 @@
{{/* `sizes` is layout knowledge, so it belongs to whoever wrote the layout. Without it a browser assumes
100vw and fetches the widest variant for a thumbnail, which makes the derivative pass cost bandwidth
rather than save it. These values describe *this* stylesheet — a 32rem measure, two gallery columns
above 36rem — and a theme that changes the layout must change them with it (ADR-0068). */}}
{{define "figure" -}}
<figure>
{{- range .Pictures}}
<img src="{{.Src}}"{{if .Srcset}} srcset="{{.Srcset}}" sizes="(min-width: 36rem) 32rem, 100vw"{{end}}{{if .Width}} width="{{.Width}}" height="{{.Height}}"{{end}} alt="{{$.Args.alt}}">
{{- end}}
{{- if not .Pictures}}
<img src="{{.Args.src}}" alt="{{.Args.alt}}">
{{- end}}
{{- if .Args.caption}}
<figcaption>{{.Args.caption}}</figcaption>
{{- end}}
</figure>
{{- end}}
{{define "gallery" -}}
{{if .Pictures -}}
<div class="gallery">
{{- range .Pictures}}
<figure><img src="{{.Src}}"{{if .Srcset}} srcset="{{.Srcset}}" sizes="(min-width: 36rem) 16rem, 100vw"{{end}}{{if .Width}} width="{{.Width}}" height="{{.Height}}"{{end}} loading="lazy" decoding="async" alt=""></figure>
{{- end}}
</div>
{{- end}}
{{- end}}
{{/* A kind with no title of its own is labelled by the engine's own words, which are localised: the reader
of a Bengali page should not be told "Warning" in English (ADR-0067). */}}
{{/* 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}}
{{/* Admonitions. One template per kind, because a call renders through a template of its own name — the
markup is the theme's and the engine supplies only the name, the arguments and the rendered body
(ADR-0064). `.Body` is already HTML. A theme with no template for a kind renders nothing, and the
engine writes the body out unwrapped rather than losing it. */}}
{{define "note"}}<aside class="admonition note"><p class="admonition-title">{{with .Args.title}}{{.}}{{else}}{{t $.Lang "note"}}{{end}}</p>{{.Body}}</aside>{{end}}
{{define "warn"}}<aside class="admonition warn"><p class="admonition-title">{{with .Args.title}}{{.}}{{else}}{{t $.Lang "warn"}}{{end}}</p>{{.Body}}</aside>{{end}}
{{define "tip"}}<aside class="admonition tip"><p class="admonition-title">{{with .Args.title}}{{.}}{{else}}{{t $.Lang "tip"}}{{end}}</p>{{.Body}}</aside>{{end}}
{{/* A flat list with a level class per entry, so nesting is a CSS decision rather than a markup one: the
engine supplies depth and the theme decides whether to indent (ADR-0065). An entry with no id is
skipped rather than linked nowhere. */}}
{{define "toc"}}
{{- if .Headings}}<nav class="toc" aria-label="{{t .Lang "contents"}}">
<p class="toc-title">{{t .Lang "contents"}}</p>
<ol>
{{- range .Headings}}{{if .ID}}
<li class="toc-{{.Level}}"><a href="#{{.ID}}">{{.Text}}</a></li>
{{- end}}{{end}}
</ol></nav>
{{- end}}
{{- end}}
{{/* An expandable section, and tabs, from one template. `<details>` needs no script; siblings sharing a
`group` become mutually exclusive through the native `name` attribute, which is what tabs are. A
browser too old for grouping simply opens them independently — the content is never hidden. */}}
{{define "details"}}<details{{with .Args.group}} name="{{.}}"{{end}}{{if .Args.open}} open{{end}}>
<summary>{{with .Args.summary}}{{.}}{{else}}{{with $.Args.title}}{{.}}{{else}}{{t $.Lang "details"}}{{end}}{{end}}</summary>
{{.Body}}</details>{{end}}
{{/* A margin note: beside the text where there is room, in the flow where there is not. CSS only. */}}
{{define "aside"}}<aside class="side">{{with .Args.title}}<p class="side-title">{{.}}</p>{{end}}{{.Body}}</aside>{{end}}
@@ -0,0 +1,7 @@
{{/* Admonitions. One template per kind, because a call renders through a template of its own name — the
markup is the theme's and the engine supplies only the name, the arguments and the rendered body
(ADR-0064). `.Body` is already HTML. A theme with no template for a kind renders nothing, and the
engine writes the body out unwrapped rather than losing it. */}}
{{define "note"}}<aside class="admonition note"><p class="admonition-title">{{with .Args.title}}{{.}}{{else}}{{t $.Lang "note"}}{{end}}</p>{{.Body}}</aside>{{end}}
{{define "warn"}}<aside class="admonition warn"><p class="admonition-title">{{with .Args.title}}{{.}}{{else}}{{t $.Lang "warn"}}{{end}}</p>{{.Body}}</aside>{{end}}
{{define "tip"}}<aside class="admonition tip"><p class="admonition-title">{{with .Args.title}}{{.}}{{else}}{{t $.Lang "tip"}}{{end}}</p>{{.Body}}</aside>{{end}}
@@ -0,0 +1,2 @@
{{/* A margin note: beside the text where there is room, in the flow where there is not. CSS only. */}}
{{define "aside"}}<aside class="side">{{with .Args.title}}<p class="side-title">{{.}}</p>{{end}}{{.Body}}</aside>{{end}}
@@ -0,0 +1,6 @@
{{/* An expandable section, and tabs, from one template. `<details>` needs no script; siblings sharing a
`group` become mutually exclusive through the native `name` attribute, which is what tabs are. A
browser too old for grouping simply opens them independently — the content is never hidden. */}}
{{define "details"}}<details{{with .Args.group}} name="{{.}}"{{end}}{{if .Args.open}} open{{end}}>
<summary>{{with .Args.summary}}{{.}}{{else}}{{with $.Args.title}}{{.}}{{else}}{{t $.Lang "details"}}{{end}}{{end}}</summary>
{{.Body}}</details>{{end}}
@@ -0,0 +1,17 @@
{{/* `sizes` is layout knowledge, so it belongs to whoever wrote the layout. Without it a browser assumes
100vw and fetches the widest variant for a thumbnail, which makes the derivative pass cost bandwidth
rather than save it. These values describe *this* stylesheet — a 32rem measure, two gallery columns
above 36rem — and a theme that changes the layout must change them with it (ADR-0068). */}}
{{define "figure" -}}
<figure>
{{- range .Pictures}}
<img src="{{.Src}}"{{if .Srcset}} srcset="{{.Srcset}}" sizes="(min-width: 36rem) 32rem, 100vw"{{end}}{{if .Width}} width="{{.Width}}" height="{{.Height}}"{{end}} alt="{{$.Args.alt}}">
{{- end}}
{{- if not .Pictures}}
<img src="{{.Args.src}}" alt="{{.Args.alt}}">
{{- end}}
{{- if .Args.caption}}
<figcaption>{{.Args.caption}}</figcaption>
{{- end}}
</figure>
{{- end}}
@@ -0,0 +1,9 @@
{{define "gallery" -}}
{{if .Pictures -}}
<div class="gallery">
{{- range .Pictures}}
<figure><img src="{{.Src}}"{{if .Srcset}} srcset="{{.Srcset}}" sizes="(min-width: 36rem) 16rem, 100vw"{{end}}{{if .Width}} width="{{.Width}}" height="{{.Height}}"{{end}} loading="lazy" decoding="async" alt=""></figure>
{{- end}}
</div>
{{- end}}
{{- end}}
@@ -0,0 +1,13 @@
{{/* 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}}
@@ -0,0 +1,13 @@
{{/* A flat list with a level class per entry, so nesting is a CSS decision rather than a markup one: the
engine supplies depth and the theme decides whether to indent (ADR-0065). An entry with no id is
skipped rather than linked nowhere. */}}
{{define "toc"}}
{{- if .Headings}}<nav class="toc" aria-label="{{t .Lang "contents"}}">
<p class="toc-title">{{t .Lang "contents"}}</p>
<ol>
{{- range .Headings}}{{if .ID}}
<li class="toc-{{.Level}}"><a href="#{{.ID}}">{{.Text}}</a></li>
{{- end}}{{end}}
</ol></nav>
{{- end}}
{{- end}}
+2
View File
@@ -135,6 +135,8 @@ var exampleFeatures = []featureCase{
expect: []string{"<kbd>Shift</kbd>"}, absent: []string{"raw HTML omitted"}},
{what: "a margin note is a container the theme places", path: "/writing/typography/", code: 200,
expect: []string{`<aside class="side">`, `<p class="side-title">On line breaking</p>`}},
{what: "the reference theme's fragments come from a directory, not one file", path: "/pages/colophon/", code: 200,
expect: []string{`<details name="run" open>`, "💡"}},
{what: "an icon renders through the theme, and an unknown one keeps its text", path: "/pages/colophon/", code: 200,
expect: []string{"💡", ":nosuchicon:"}},
{what: "grouped panels are tabs, and need no script", path: "/pages/colophon/", code: 200,