replace the shortcode syntax with generic directives

`::name{key=value}` alone on a line, quotes only where a value has spaces,
braces omitted when there are none. The old form cost eleven characters of
punctuation per call and could not carry a body, which admonitions will need.
Generic directives are an existing convention — remark-directive, MyST,
Docusaurus — so this is a syntax authors and tools already know rather than one
more invention, and it reserves `:::name` for containers and `:name[…]` for the
inline dynamic calls that come later.

Retired outright rather than aliased: two syntaxes is two parsers and two test
sets forever. `khosra check` reports every leftover call as fatal and names the
replacement, so migrating a site root is running it until it exits zero — proven
on an unmigrated root, which exits 1 with the file and the fix.

The trigger byte moves from `{` to `:`, which prose uses constantly, so the
parser refuses `3::4`, `: a definition` and `:::note`, each with a case. The
definition-list parser sits at priority 101 and this one at 100, so it gets
first refusal and everything it rejects falls through.

Two things the syntax change would have broken silently. check's alt-text regex
still matched the old form, so the one accessibility check the engine has would
have stopped finding anything — it moves with the syntax and keeps its case. And
a test asserted that hostile arguments fail at the syntax because quotes cannot
be expressed; unquoted values are legal now, so it asserts the property that
actually holds: the fragment escapes them.

Demo migrated. core 2790/2800, ext 1077/2000, 34 gates green, 0 warnings.
This commit is contained in:
Claude Opus 5
2026-08-01 20:52:17 +06:00
committed by bdeshi
parent 0465785e81
commit a893ab1821
16 changed files with 175 additions and 94 deletions
+16 -6
View File
@@ -351,9 +351,19 @@ author's prose to influence it (ADR-0045). The reference theme's stylesheet does
## Shortcodes
A shortcode is `{{< name key="value" >}}` **alone on a line** — the whole line, or it is prose. Every
argument is `key="value"`; there is one spelling, so nothing is guessed and a malformed call stays visible
as text instead of half-working.
A shortcode is `::name{key=value}` **alone on a line** — the whole line, or it is prose. This is the leaf
form of the generic directive syntax the wider Markdown world uses, not an invention of this engine
(ADR-0059). Quotes are needed only where a value contains spaces: `::figure{src=cover.jpg alt="A red moon"}`.
A call with no arguments needs no braces: `::gallery`. A malformed call stays visible as text instead of
half-working.
Three colons open a **container** directive — `:::name{…}`, body, `:::` — reserved for calls that wrap
content. Nothing parses that form yet; it arrives with the first feature that needs a body, and until then a
`:::` line is prose.
**Migrating from the retired form.** `{{< name key="value" >}}` is no longer a call and renders as literal
text. `khosra check` reports every one as fatal and names the replacement, so a site root is migrated by
running it until it exits zero. The engine cannot rewrite a site root — that is the author's data (ADR-0011).
Arguments are data, never markup: the call renders through a theme template of the same name
(`theme-contract.md`), and raw HTML in a body remains dropped, so the only HTML on a page came from a
@@ -362,12 +372,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.
`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
numbers in URLs (ADR-0016). A subdirectory is not part of the gallery, and neither is a file the browser
cannot show.
`{{< include file="notes.md" >}}` renders another file from the bundle as Markdown, in place. Three rules,
`::include{file=notes.md}` renders another file from the bundle as Markdown, in place. Three rules,
all of them consequences of ADR-0038:
- The name is relative to the bundle and **stays inside it**. A name containing `..` is refused, so an
@@ -379,7 +389,7 @@ all of them consequences of ADR-0038:
A `gallery` inside an included file still resolves against the same bundle.
Name an included file with a leading underscore — `{{< include file="_tools.md" >}}` — or it is a bundle too,
Name an included file with a leading underscore — `::include{file=_tools.md}` — or it is a bundle too,
with its own URL.
Sharing one fragment between bundles is deliberately not possible yet: it needs somewhere to keep shared
+21
View File
@@ -926,3 +926,24 @@ reference theme now has to style markup it never emitted before; and enabling ta
content renders, since a `|` line that used to come out as prose is now parsed.
Revisit if: a sixth extension is wanted — which is a change to this decision and gets an ADR of its own,
not a quiet line in the list.
## ADR-0059 — Shortcodes are generic directives: `::name{key=value}`
Date: 2026-08-01 · Status: accepted (replaces the call syntax of ADR-0036 and ADR-0038; everything else
those decide — that a call renders through a theme fragment, that an include is content and stays inside
its bundle — is untouched)
Decision: a call is `::name{key=value}` alone on a line, quotes only where a value contains spaces, braces
omitted when there are none. `:::name{…}``:::` is reserved for container directives and is deliberately
not parsed until the first feature needs a body. The old `{{< name key="value" >}}` is retired outright:
`khosra check` reports it as fatal and names the replacement, and the demo is migrated in this change.
Why: the old form cost eleven characters of punctuation per call and could not carry a body, which
admonitions need. Generic directives are an existing convention — remark-directive, MyST, Docusaurus — so
authors and other tools already know the shape, and the inline form `:name[text]{…}` is there when the
dynamic shortcodes arrive. Retiring rather than aliasing, because two syntaxes for one thing is two parsers
and two sets of tests forever, and `check` makes the migration mechanical: run it until it exits zero.
Consequence: cheap — shorter calls, a body form reserved without building it, and unquoted values, which is
most of the saving. Expensive — a disk-contract change, so every site root written against the old form
must be migrated by hand; the trigger byte moves from `{` to `:`, which prose uses far more often, so the
parser must refuse `:::`, `: definition` and `3::4` and does; and the alt-text check's regex had to move
with the syntax, which is exactly the kind of silent breakage a syntax change causes.
Revisit if: the container or inline forms need to differ from the directive convention — which would be a
change to this decision, not a quiet addition.
+2 -2
View File
@@ -25,10 +25,10 @@ table owns.
| `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) |
| `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/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/check/` | third feature: validates a site root — what the engine worked around, broken internal links, missing titles and alt text, mixed series ordering |
| `internal/ext/check/` | third feature: validates a site root — what the engine worked around, broken internal links, missing titles and alt text, mixed series ordering, and calls left in the retired shortcode form (ADR-0059) |
| `cmd/khosra/wire.go` | the only list of enabled features (`extensions.md`), and the Markdown dialect with it — tables, footnotes, definition lists, strikethrough (ADR-0058) |
| `internal/web/resolve.go` | URL → (key, lang, page, tag, feed, extras) or a canonical redirect |
| `internal/web/extras.go` | the extras route: listing, one entry selected, or `?raw` bytes, all behind the bundle lookup |
+20 -20
View File
@@ -95,23 +95,23 @@ clock.go 12 · content.go 450 · doc.go 5 · extras.go 92 · settings.go 59 · s
- site.go:394 func (s *Site) Everything() []Bundle
- site.go:407 func (s *Site) Sections() []string
## internal/ext/check — 223 lines + 147 test
## internal/ext/check — 231 lines + 168 test
check.go 215 · doc.go 8
check.go 223 · doc.go 8
- check.go:15 type Finding struct
- check.go:29 func Run(fsys fs.FS, bundles []content.Bundle, site *content.Site, problems []content.Problem) []Finding
- check.go:52 func Fatal(found []Finding) bool
- check.go:62 func inspect(fsys fs.FS, b content.Bundle, site *content.Site) []Finding
- check.go:78 var figureCall = regexp.MustCompile(`(?m)^\s*\{\{<\s*figure\s+([^>]*)>\}\}\s*$`)
- check.go:81 var altArg = regexp.MustCompile(`alt="[^"]+"`)
- check.go:87 func checkFigures(b content.Bundle) []Finding
- check.go:98 var internalLink = regexp.MustCompile(`\]\((/[^)\s"]*)`)
- check.go:104 func checkLinks(fsys fs.FS, b content.Bundle, site *content.Site) []Finding
- check.go:121 func engineOwned(target string) bool
- check.go:138 func resolves(fsys fs.FS, target string, from content.Bundle, site *content.Site) bool
- check.go:164 func asset(fsys fs.FS, trimmed string, site *content.Site) bool
- check.go:192 func mixedOrdering(bundles []content.Bundle, site *content.Site) []Finding
- check.go:16 type Finding struct
- check.go:30 func Run(fsys fs.FS, bundles []content.Bundle, site *content.Site, problems []content.Problem) []Finding
- check.go:53 func Fatal(found []Finding) bool
- check.go:63 func inspect(fsys fs.FS, b content.Bundle, site *content.Site) []Finding
- check.go:85 var figureCall = regexp.MustCompile(`(?m)^\s* figure\{([^}]*)\}\s*$`)
- check.go:89 var altArg = regexp.MustCompile(`alt=("[^"]+"|[^"\s}]+)`)
- check.go:95 func checkFigures(b content.Bundle) []Finding
- check.go:106 var internalLink = regexp.MustCompile(`\]\((/[^)\s"]*)`)
- check.go:112 func checkLinks(fsys fs.FS, b content.Bundle, site *content.Site) []Finding
- check.go:129 func engineOwned(target string) bool
- check.go:146 func resolves(fsys fs.FS, target string, from content.Bundle, site *content.Site) bool
- check.go:172 func asset(fsys fs.FS, trimmed string, site *content.Site) bool
- check.go:200 func mixedOrdering(bundles []content.Bundle, site *content.Site) []Finding
## internal/ext/scaffold — 102 lines + 89 test
@@ -122,9 +122,9 @@ 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 — 600 lines + 417 test
## internal/ext/shortcodes — 611 lines + 427 test
doc.go 7 · images.go 250 · shortcodes.go 343
doc.go 7 · images.go 250 · shortcodes.go 354
- images.go:31 var widths = []int{480, 960, 1440}
- images.go:38 func Derive(siteFS fs.FS, cacheDir string) (int, error)
@@ -138,7 +138,7 @@ doc.go 7 · images.go 250 · shortcodes.go 343
- images.go:224 func lossless(source string) bool
- images.go:234 func showable(name string) bool
- images.go:244 func derivable(name string) bool
- shortcodes.go:25 const
- shortcodes.go:28 const opener = " "
- 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)
@@ -155,7 +155,7 @@ doc.go 7 · images.go 250 · shortcodes.go 343
- 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: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
@@ -166,7 +166,7 @@ doc.go 7 · images.go 250 · shortcodes.go 343
- 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:328 func argument(s string) (key, value, rest string, ok bool)
- shortcodes.go:336 func argument(s string) (key, value, rest string, ok bool)
## internal/ext/watch — 133 lines + 114 test
+4 -4
View File
@@ -88,7 +88,7 @@ Every fragment receives the same two fields (ADR-0037):
| Field | Contents |
|---|---|
| `.Args` | the call's `key="value"` pairs, exactly as the author wrote them |
| `.Args` | the call's `key=value` pairs, exactly as the author wrote them |
| `.Pictures` | images the *engine* gathered: one for a figure, many for a gallery, none when the call names nothing it recognises (ADR-0042) |
Each picture carries:
@@ -101,10 +101,10 @@ Each picture carries:
| Shortcode | Template | Receives |
|---|---|---|
| `{{< figure src="…" alt="…" caption="…" >}}` | `figure` | `.Args.src`, `.Args.alt`, `.Args.caption` |
| `{{< gallery >}}` | `gallery` | `.Pictures` — every picture beside the bundle, in filename order |
| `::figure{src= alt="…" caption=}` | `figure` | `.Args.src`, `.Args.alt`, `.Args.caption` |
| `::gallery` | `gallery` | `.Pictures` — every picture beside the bundle, in filename order |
`{{< 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).
Authored and engine-supplied data are kept apart so a `src` argument can never be confused with a `src` the
@@ -6,7 +6,7 @@ tags: [monsoon]
Three studies and a diagram. The gallery below is every picture in this directory, in filename order — the
numeric prefixes do the ordering without appearing in any URL.
{{< gallery >}}
::gallery
The three JPEGs carry generated widths in a `srcset`. The SVG does not: nothing here can resample it, so it is
rendered exactly as it was drawn rather than dropped for being inconvenient.
@@ -6,6 +6,6 @@ tags: [monsoon]
---
Chapter two, written as a directory bundle so it can own the picture below.
{{< figure src="water.jpg" alt="A wall of grey water" caption="Day three" >}}
::figure{src=water.jpg alt="A wall of grey water" caption="Day three"}
It has a chapter on either side, so the sequence nav shows both, plus its position in the series.
@@ -8,6 +8,6 @@ to point at. Its Bengali twin is `index.bn.md` beside this file.
It also owns a picture, which only a *directory* bundle can do:
{{< figure src="cover.jpg" alt="A grey-green gradient standing in for a photograph" caption="A caption, rendered by the theme's figure fragment" >}}
::figure{src=cover.jpg alt="A grey-green gradient standing in for a photograph" caption="A caption, rendered by the theme's figure fragment"}
The picture above is served from this bundle's own directory, and the `srcset` on it names generated widths.
@@ -5,7 +5,7 @@ tags: [monsoon, journal]
---
The finished piece. Below, a part of it lives in a separate file and is included here:
{{< include file="_method.md" >}}
::include{file=_method.md}
That fragment starts with an underscore, so the scanner never treats it as a bundle: it has no URL of its own
and appears in no listing. An included file cannot itself include — one level, deliberately.
+12 -4
View File
@@ -1,6 +1,7 @@
package check
import (
"bytes"
"fmt"
"io/fs"
"path"
@@ -65,6 +66,12 @@ func inspect(fsys fs.FS, b content.Bundle, site *content.Site) []Finding {
// Not fatal: the page renders with its key as a title. Still almost never what anyone wants.
found = append(found, Finding{b.Path, "no title, so the page is titled by its key", false})
}
if bytes.Contains(b.Body, []byte("{{<")) {
// Fatal, because the old call is not a call any more: it renders as literal text in the page
// (ADR-0059). This is how a site root written against the old syntax is found and migrated.
found = append(found, Finding{b.Path,
`shortcode in the retired "{{< name key=\"value\" >}}" form; write ::name{key=value} instead (ADR-0059)`, true})
}
if norm := content.Normalise(b.Path); norm != b.Path {
found = append(found, Finding{b.Path,
"filename is not in NFC, so two visually identical names could take different keys (ADR-0015)", true})
@@ -74,11 +81,12 @@ func inspect(fsys fs.FS, b content.Bundle, site *content.Site) []Finding {
return found
}
// figureCall matches a whole figure shortcode, so its arguments can be examined.
var figureCall = regexp.MustCompile(`(?m)^\s*\{\{<\s*figure\s+([^>]*)>\}\}\s*$`)
// figureCall matches a whole figure call, so its arguments can be examined (ADR-0059).
var figureCall = regexp.MustCompile(`(?m)^\s*::figure\{([^}]*)\}\s*$`)
// altArg matches a non-empty alt argument.
var altArg = regexp.MustCompile(`alt="[^"]+"`)
// altArg matches a non-empty alt argument, quoted or not. An empty `alt=""` matches neither branch, which
// is the point: it is the same absence as leaving the argument out.
var altArg = regexp.MustCompile(`alt=("[^"]+"|[^"\s}]+)`)
// checkFigures looks for pictures nobody described.
//
+23 -2
View File
@@ -100,7 +100,7 @@ func TestTitlesAltTextAndMixedOrderingAreWarnings(t *testing.T) {
found := run(t, fstest.MapFS{
"content/posts/untitled.md": {Data: []byte("no frontmatter at all\n")},
"content/posts/pics.md": {Data: []byte("---\ntitle: Pics\n---\n" +
"{{< figure src=\"a.jpg\" alt=\"A described picture\" >}}\n\n{{< figure src=\"b.jpg\" >}}\n")},
"::figure{src=a.jpg alt=\"A described picture\"}\n\n::figure{src=b.jpg}\n")},
"content/comics/s/_index.md": {Data: []byte("---\ntitle: S\n---\nx\n")},
"content/comics/s/one.md": {Data: []byte("---\ntitle: One\norder: 10\n---\nx\n")},
"content/comics/s/two.md": {Data: []byte("---\ntitle: Two\n---\nx\n")},
@@ -134,11 +134,32 @@ func findingsAbout(found []Finding, fragment string) string {
return out.String()
}
// The retired syntax renders as literal text rather than a call, so a site root written against it is wrong
// in a way only a reader would notice. This is how it gets found (ADR-0059).
func TestTheRetiredShortcodeFormIsFatal(t *testing.T) {
found := run(t, fstest.MapFS{
"content/posts/old.md": {Data: []byte("---\ntitle: Old\n---\n{{< figure src=\"a.jpg\" alt=\"A cat\" >}}\n")},
"content/posts/a.jpg": {Data: []byte("bytes")},
})
if !Fatal(found) {
t.Errorf("an unmigrated call must be fatal, got:\n%v", found)
}
var said bool
for _, f := range found {
if strings.Contains(f.What, "::name{key=value}") {
said = true
}
}
if !said {
t.Errorf("the finding should name the replacement, got:\n%v", found)
}
}
func TestACleanSiteHasNothingToSay(t *testing.T) {
found := run(t, fstest.MapFS{
"content/posts/one.md": {Data: []byte("---\ntitle: One\ndate: 2026-01-01\n---\nSee [two](/posts/two/).\n")},
"content/posts/two.md": {Data: []byte("---\ntitle: Two\ndate: 2026-01-02\n---\nx\n")},
"content/art/set/index.md": {Data: []byte("---\ntitle: Set\n---\n{{< figure src=\"one.jpg\" alt=\"Described\" >}}\n")},
"content/art/set/index.md": {Data: []byte("---\ntitle: Set\n---\n::figure{src=one.jpg alt=Described}\n")},
"content/art/set/one.jpg": {Data: []byte("bytes")},
})
if len(found) != 0 {
+1 -1
View File
@@ -1,4 +1,4 @@
// Package shortcodes expands `{{< name key="value" >}}` on its own line into a theme fragment.
// Package shortcodes expands `::name{key=value}` on its own line into a theme fragment.
//
// Contributes: a Markdown block parser and node renderer (PhaseParse).
// Cascade keys: none.
+31 -20
View File
@@ -20,12 +20,12 @@ import (
"khosra/internal/render"
)
// open and close delimit a call. Chosen to be something no Markdown construct claims and no author types
// by accident; the syntax is a disk contract, so it does not change (ADR-0036).
const (
opener = "{{<"
closer = ">}}"
)
// opener begins a call: `::name`, alone on a line, with optional `{key=value}` attributes.
//
// The leaf form of the generic directive syntax the wider Markdown world already uses, rather than an
// invention of this engine (ADR-0059). Three colons open a container directive and are deliberately not
// parsed here — that form arrives with the first feature that needs a body. The syntax is a disk contract.
const opener = "::"
// New returns the Markdown extension, rendering each call through partial.
//
@@ -188,7 +188,7 @@ func (n *node) Dump(source []byte, level int) { ast.DumpHelper(n, source, level,
// blocks parses a line that is nothing but a call.
type blocks struct{}
func (blocks) Trigger() []byte { return []byte{'{'} }
func (blocks) Trigger() []byte { return []byte{':'} }
func (blocks) Open(parent ast.Node, reader text.Reader, pc parser.Context) (ast.Node, parser.State) {
line, seg := reader.PeekLine()
@@ -303,16 +303,20 @@ func parse(line string) (name string, args map[string]string, ok bool) {
if !found {
return "", nil, false
}
body, found = strings.CutSuffix(strings.TrimSpace(body), closer)
if !found {
return "", nil, false
}
body = strings.TrimSpace(body)
name, rest, _ := strings.Cut(body, " ")
if name == "" || strings.ContainsAny(name, `="`) {
name, rest, hasArgs := strings.Cut(strings.TrimSpace(body), "{")
name = strings.TrimSpace(name)
// A leading colon means three of them, which opens a container directive this parser does not claim.
if name == "" || strings.ContainsAny(name, `:="{} `) {
return "", nil, false
}
args = map[string]string{}
if !hasArgs {
return name, args, true
}
rest, found = strings.CutSuffix(strings.TrimSpace(rest), "}")
if !found {
return "", nil, false
}
for rest = strings.TrimSpace(rest); rest != ""; {
key, value, remainder, valid := argument(rest)
if !valid {
@@ -324,19 +328,26 @@ func parse(line string) (name string, args map[string]string, ok bool) {
return name, args, true
}
// argument reads one key="value" pair and returns what follows it.
// argument reads one `key=value` pair and returns what follows it.
//
// Quotes are needed only for a value containing spaces, which is what makes the short form short: most
// arguments are a filename or a word. The closing brace is already gone by the time this runs, so an
// unquoted value cannot swallow it.
func argument(s string) (key, value, rest string, ok bool) {
key, after, found := strings.Cut(s, "=")
key = strings.TrimSpace(key)
if !found || key == "" || strings.ContainsAny(key, `" `) {
return "", "", "", false
}
quoted, found := strings.CutPrefix(after, `"`)
if !found {
return "", "", "", false
if quoted, isQuoted := strings.CutPrefix(after, `"`); isQuoted {
value, rest, found = strings.Cut(quoted, `"`)
if !found {
return "", "", "", false
}
return key, value, strings.TrimSpace(rest), true
}
value, rest, found = strings.Cut(quoted, `"`)
if !found {
value, rest, _ = strings.Cut(after, " ")
if value == "" {
return "", "", "", false
}
return key, value, strings.TrimSpace(rest), true
+39 -29
View File
@@ -12,23 +12,32 @@ import (
)
func TestParseAcceptsOnlyAWholeLineCall(t *testing.T) {
name, args, ok := parse(` {{< figure src="a.jpg" alt="A cat" >}} `)
// Quotes only where a value has spaces: that is what makes the directive form shorter than the one it
// replaced (ADR-0059).
name, args, ok := parse(` ::figure{src=a.jpg alt="A cat"} `)
if !ok || name != "figure" {
t.Fatalf("parse gave %q %v ok=%v", name, args, ok)
}
if args["src"] != "a.jpg" || args["alt"] != "A cat" {
t.Errorf("args = %v", args)
}
if _, _, ok := parse(`{{< figure src="a.jpg" >}} and then prose`); ok {
t.Error("a call must be the whole line, so trailing prose is not a call")
if name, args, ok := parse("::gallery"); !ok || name != "gallery" || len(args) != 0 {
t.Errorf("a call with no attributes needs no braces: %q %v ok=%v", name, args, ok)
}
for _, line := range []string{
"plain prose",
"{{< figure", // unterminated
`{{< src="a.jpg" >}}`, // no name
`{{< figure src=a.jpg >}}`, // unquoted value
`{{< figure src="unclosed >}}`, // unbalanced quote
"{{<>}}", // empty
"a ratio of 3::4 in prose",
"::figure{src=a.jpg} and then prose", // a call is the whole line
"::figure{src=a.jpg", // unterminated
"::{src=a.jpg}", // no name
`::figure{src="unclosed}`, // unbalanced quote
"::", // empty
// Three colons open a container directive. Nothing parses it yet, and this parser must not claim
// it as a leaf named ":note", or the form is spent before its first user arrives.
":::note",
":::note{title=Careful}",
// A definition list description shares the trigger byte and must fall through to its own parser.
": a definition",
} {
if _, _, ok := parse(line); ok {
t.Errorf("parse accepted %q", line)
@@ -66,7 +75,7 @@ func body(t *testing.T, r *render.Renderer, markdown string) string {
}
func TestFigureRendersThroughTheThemeFragment(t *testing.T) {
got := body(t, wired(t, nil), "Before.\n\n{{< figure src=\"cat.jpg\" alt=\"A cat\" caption=\"Sleeping\" >}}\n\nAfter.\n")
got := body(t, wired(t, nil), "Before.\n\n::figure{src=cat.jpg alt=\"A cat\" caption=Sleeping}\n\nAfter.\n")
for _, want := range []string{
"<figure>", `<img src="cat.jpg" alt="A cat">`, "<figcaption>Sleeping</figcaption>", "</figure>",
} {
@@ -74,7 +83,7 @@ func TestFigureRendersThroughTheThemeFragment(t *testing.T) {
t.Errorf("missing %q:\n%s", want, got)
}
}
if strings.Contains(got, "<p><figure>") || strings.Contains(got, "{{<") {
if strings.Contains(got, "<p><figure>") || strings.Contains(got, "::figure") {
t.Errorf("a call on its own line is a block, not paragraph text:\n%s", got)
}
}
@@ -82,7 +91,7 @@ func TestFigureRendersThroughTheThemeFragment(t *testing.T) {
func TestAnAuthorsArgumentCannotBecomeMarkup(t *testing.T) {
// The security property of ADR-0036, on a call that really parses: output is a template's, so hostile
// argument text arrives as escaped data in whichever context it lands in.
got := body(t, wired(t, nil), `{{< figure src="ok.jpg" alt="<script>alert(1)</script>" >}}`+"\n")
got := body(t, wired(t, nil), `::figure{src=ok.jpg alt=<script>alert(1)</script>}`+"\n")
if strings.Contains(got, "<script>") {
t.Fatalf("an argument became markup:\n%s", got)
}
@@ -92,14 +101,15 @@ func TestAnAuthorsArgumentCannotBecomeMarkup(t *testing.T) {
// A javascript: URL in an attribute the template uses as a URL is html/template's job, and getting it
// for free is the reason a fragment renders this rather than the feature (ADR-0036).
got = body(t, wired(t, nil), `{{< figure src="javascript:alert(1)" alt="x" >}}`+"\n")
got = body(t, wired(t, nil), `::figure{src=javascript:alert(1) alt=x}`+"\n")
if strings.Contains(got, "javascript:alert(1)") {
t.Errorf("a javascript: URL should not survive into src:\n%s", got)
}
// A quote cannot even be expressed in an argument, so attribute breakout fails at the syntax before it
// reaches escaping: the call is not a call, and the line stays prose.
got = body(t, wired(t, nil), `{{< figure src="x.jpg\" onerror=\"alert(1)" >}}`+"\n\n<script>alert(2)</script>\n")
// An unquoted value is legal now (ADR-0059), so a hostile attribute name parses cleanly rather than
// failing at the syntax. It still cannot become markup: the fragment decides where a value lands, and
// html/template escapes it for that context. Escaping is the property, not a syntax accident.
got = body(t, wired(t, nil), `::figure{src=x.jpg onerror=alert(1)}`+"\n\n<script>alert(2)</script>\n")
if strings.Contains(got, "onerror") && !strings.Contains(got, "&quot;") {
t.Errorf("a malformed call must stay escaped text, not markup:\n%s", got)
}
@@ -109,7 +119,7 @@ func TestAnAuthorsArgumentCannotBecomeMarkup(t *testing.T) {
}
func TestAnUnknownShortcodeDegradesToNothing(t *testing.T) {
got := body(t, wired(t, nil), "{{< nosuchthing key=\"v\" >}}\n\nStill here.\n")
got := body(t, wired(t, nil), "::nosuchthing{key=v}\n\nStill here.\n")
if !strings.Contains(got, "Still here.") {
t.Errorf("the rest of the page must survive:\n%s", got)
}
@@ -121,7 +131,7 @@ func TestAnUnknownShortcodeDegradesToNothing(t *testing.T) {
// galleryFS is a directory bundle with pictures, a non-picture, and a subdirectory that is not one.
func galleryFS() fstest.MapFS {
return fstest.MapFS{
"content/art/monsoon/index.md": {Data: []byte("---\ntitle: Monsoon\n---\n{{< gallery >}}\n")},
"content/art/monsoon/index.md": {Data: []byte("---\ntitle: Monsoon\n---\n::gallery\n")},
"content/art/monsoon/20-second.jpg": {Data: []byte("x")},
"content/art/monsoon/10-first.PNG": {Data: []byte("x")},
"content/art/monsoon/30-third.webp": {Data: []byte("x")},
@@ -168,7 +178,7 @@ func TestGalleryListsThePicturesBesideItsBundle(t *testing.T) {
func TestGalleryWithoutASiteRootRendersNothing(t *testing.T) {
// wired(t, nil) has no files, which is how a unit test or a bare renderer is built. Gathering nothing
// must not become a broken page.
got := body(t, wired(t, nil), "{{< gallery >}}\n\nStill here.\n")
got := body(t, wired(t, nil), "::gallery\n\nStill here.\n")
if !strings.Contains(got, "Still here.") {
t.Errorf("the page must survive a gallery with nothing to show:\n%s", got)
}
@@ -179,7 +189,7 @@ func TestGalleryWithoutASiteRootRendersNothing(t *testing.T) {
func TestIncludeRendersTheFileBesideTheBundle(t *testing.T) {
fsys := fstest.MapFS{
"content/pages/about/index.md": {Data: []byte("---\ntitle: About\n---\nFirst.\n\n{{< include file=\"more.md\" >}}\n\nLast.\n")},
"content/pages/about/index.md": {Data: []byte("---\ntitle: About\n---\nFirst.\n\n::include{file=more.md}\n\nLast.\n")},
"content/pages/about/more.md": {Data: []byte("## Included\n\nWith *emphasis* and a [link](/posts/).\n")},
}
got := bundle(t, fsys, "pages/about")
@@ -199,8 +209,8 @@ func TestAnIncludedFileCannotItselfInclude(t *testing.T) {
// One level, by design (ADR-0038). A file including itself is the case that would otherwise recurse
// until the stack gave out — a crash caused by content, which ADR-0029 forbids.
fsys := fstest.MapFS{
"content/pages/loop/index.md": {Data: []byte("---\ntitle: Loop\n---\nBefore.\n\n{{< include file=\"self.md\" >}}\n\nAfter.\n")},
"content/pages/loop/self.md": {Data: []byte("Round.\n\n{{< include file=\"self.md\" >}}\n")},
"content/pages/loop/index.md": {Data: []byte("---\ntitle: Loop\n---\nBefore.\n\n::include{file=self.md}\n\nAfter.\n")},
"content/pages/loop/self.md": {Data: []byte("Round.\n\n::include{file=self.md}\n")},
}
got := bundle(t, fsys, "pages/loop")
for _, want := range []string{"Before.", "Round.", "After."} {
@@ -211,7 +221,7 @@ func TestAnIncludedFileCannotItselfInclude(t *testing.T) {
if n := strings.Count(got, "Round."); n != 1 {
t.Errorf("expanded %d times, want exactly one level", n)
}
if strings.Contains(got, "{{<") {
if strings.Contains(got, "::include") {
t.Errorf("the ignored nested call renders nothing, it is not printed:\n%s", got)
}
}
@@ -219,8 +229,8 @@ func TestAnIncludedFileCannotItselfInclude(t *testing.T) {
func TestAGalleryInsideAnIncludedFileStillResolves(t *testing.T) {
// The nested parse carries the same Origin, which is what makes this work.
fsys := fstest.MapFS{
"content/art/set/index.md": {Data: []byte("---\ntitle: Set\n---\n{{< include file=\"body.md\" >}}\n")},
"content/art/set/body.md": {Data: []byte("Studies:\n\n{{< gallery >}}\n")},
"content/art/set/index.md": {Data: []byte("---\ntitle: Set\n---\n::include{file=body.md}\n")},
"content/art/set/body.md": {Data: []byte("Studies:\n\n::gallery\n")},
"content/art/set/one.jpg": {Data: []byte("x")},
"content/art/set/two.png": {Data: []byte("x")},
}
@@ -234,11 +244,11 @@ func TestIncludeCannotEscapeTheSiteRootAndDegradesOnMisses(t *testing.T) {
fsys := fstest.MapFS{
// `..` is refused outright: path.Join would collapse it to a real path inside the site root, which
// would let an include publish a template or a dotfile that is not content (ADR-0038).
"content/pages/a/index.md": {Data: []byte("---\ntitle: A\n---\n{{< include file=\"../../../etc/passwd\" >}}\n\nSurvived.\n")},
"content/pages/d/index.md": {Data: []byte("---\ntitle: D\n---\n{{< include file=\"../../../secret.md\" >}}\n\nSurvived.\n")},
"content/pages/a/index.md": {Data: []byte("---\ntitle: A\n---\n::include{file=../../../etc/passwd}\n\nSurvived.\n")},
"content/pages/d/index.md": {Data: []byte("---\ntitle: D\n---\n::include{file=../../../secret.md}\n\nSurvived.\n")},
"secret.md": {Data: []byte("NOT CONTENT\n")},
"content/pages/b/index.md": {Data: []byte("---\ntitle: B\n---\n{{< include file=\"nothing.md\" >}}\n\nSurvived.\n")},
"content/pages/c/index.md": {Data: []byte("---\ntitle: C\n---\n{{< include >}}\n\nSurvived.\n")},
"content/pages/b/index.md": {Data: []byte("---\ntitle: B\n---\n::include{file=nothing.md}\n\nSurvived.\n")},
"content/pages/c/index.md": {Data: []byte("---\ntitle: C\n---\n::include\n\nSurvived.\n")},
}
for _, key := range []string{"pages/a", "pages/b", "pages/c", "pages/d"} {
got := bundle(t, fsys, key)
@@ -255,7 +265,7 @@ func TestASiteRedefinesOneFragment(t *testing.T) {
site := fstest.MapFS{
"templates/shortcodes.html": {Data: []byte(`{{define "figure"}}<div class="mine">{{.Args.src}}</div>{{end}}`)},
}
got := body(t, wired(t, site), "{{< figure src=\"cat.jpg\" >}}\n")
got := body(t, wired(t, site), "::figure{src=cat.jpg}\n")
if !strings.Contains(got, `<div class="mine">cat.jpg</div>`) {
t.Errorf("the site's fragment should win:\n%s", got)
}
+1 -1
View File
@@ -15,7 +15,7 @@ func assetHandler(t *testing.T) http.Handler {
t.Helper()
fsys := fstest.MapFS{
// A directory bundle owns the files beside it.
"content/art/monsoon/index.md": {Data: []byte("---\ntitle: Monsoon\n---\n{{< gallery >}}\n")},
"content/art/monsoon/index.md": {Data: []byte("---\ntitle: Monsoon\n---\n::gallery\n")},
// A Bengali variant, so /bn/ is a language prefix at all: the engine treats a leading segment as a
// language only when some bundle is written in it (content-model.md).
"content/art/monsoon/index.bn.md": {Data: []byte("---\ntitle: বর্ষা\n---\nx\n")},
+1 -1
View File
@@ -37,7 +37,7 @@ func benchHandler(b *testing.B, pictures int) http.Handler {
b.Helper()
fsys := fstest.MapFS{
"content/posts/plain.md": {Data: []byte("---\ntitle: Plain\ndate: 2026-01-01\n---\nJust prose, several words of it.\n")},
"content/art/set/index.md": {Data: []byte("---\ntitle: Set\ndate: 2026-01-02\n---\n{{< gallery >}}\n")},
"content/art/set/index.md": {Data: []byte("---\ntitle: Set\ndate: 2026-01-02\n---\n::gallery\n")},
}
shot := photo(b, 1600)
for i := range pictures {