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
+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)
}