Files
khosra/internal/ext/shortcodes/shortcodes_test.go
T
bdeshi 820720de08 add shortcodes as the first internal/ext feature
A call is `{{< name key="value" >}}` alone on a line, parsed by a goldmark block
parser into an AST node and rendered by executing a theme template of that name
(ADR-0036). `figure` ships; `include` and `gallery` need the including bundle's
directory, which the parser does not carry yet, so they wait.

The layering did the design work here. internal/render may not import
internal/ext, so render.New takes a callback that receives a Partial and returns
Markdown extensions, and cmd/khosra/wire.go holds the only list of enabled
features. Empty that list and the engine still builds and serves — which is the
property extensions.md says the contract should have.

Raw HTML stays disabled. An author's text reaches a page only as arguments that
html/template escapes in context, which the real binary shows: a hostile alt
becomes &lt;script&gt; and src="javascript:…" becomes #ZgotmplZ. Getting
contextual escaping from the standard library rather than writing it is the whole
reason a fragment renders this instead of the feature.

parseSet became variadic so the fragment set reuses it rather than growing a
second copy of the overlay logic; `Partial` takes map[string]string after the
advisory correctly flagged `any` as generality nothing had asked for.
2026-07-30 10:29:00 +06:00

133 lines
4.5 KiB
Go

package shortcodes
import (
"strings"
"testing"
"testing/fstest"
"github.com/yuin/goldmark"
"khosra/internal/content"
"khosra/internal/render"
)
func TestParseAcceptsOnlyAWholeLineCall(t *testing.T) {
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")
}
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
} {
if _, _, ok := parse(line); ok {
t.Errorf("parse accepted %q", line)
}
}
}
// wired builds a real Renderer wired to this extension, the way cmd does.
func wired(t *testing.T, siteFS fstest.MapFS) *render.Renderer {
t.Helper()
var fsys fstest.MapFS
if siteFS != nil {
fsys = siteFS
}
r, err := render.New(fsys, func(p render.Partial) []goldmark.Extender {
return []goldmark.Extender{New(p)}
})
if err != nil {
t.Fatal(err)
}
return r
}
func body(t *testing.T, r *render.Renderer, markdown string) string {
t.Helper()
b, err := content.Parse("posts/x.md", []byte("---\ntitle: X\n---\n"+markdown))
if err != nil {
t.Fatal(err)
}
out, err := r.Bundle(b, "en", nil, nil)
if err != nil {
t.Fatal(err)
}
return string(out)
}
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")
for _, want := range []string{
"<figure>", `<img src="cat.jpg" alt="A cat">`, "<figcaption>Sleeping</figcaption>", "</figure>",
} {
if !strings.Contains(got, want) {
t.Errorf("missing %q:\n%s", want, got)
}
}
if strings.Contains(got, "<p><figure>") || strings.Contains(got, "{{<") {
t.Errorf("a call on its own line is a block, not paragraph text:\n%s", got)
}
}
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")
if strings.Contains(got, "<script>") {
t.Fatalf("an argument became markup:\n%s", got)
}
if !strings.Contains(got, "&lt;script&gt;") {
t.Errorf("the hostile alt text should survive as escaped text:\n%s", got)
}
// 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")
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")
if strings.Contains(got, "onerror") && !strings.Contains(got, "&quot;") {
t.Errorf("a malformed call must stay escaped text, not markup:\n%s", got)
}
if !strings.Contains(got, "raw HTML omitted") {
t.Errorf("authored raw HTML must still be dropped:\n%s", got)
}
}
func TestAnUnknownShortcodeDegradesToNothing(t *testing.T) {
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)
}
if strings.Contains(got, "nosuchthing") {
t.Errorf("a missing fragment renders nothing, not its own name:\n%s", got)
}
}
func TestASiteRedefinesOneFragment(t *testing.T) {
site := fstest.MapFS{
"templates/shortcodes.html": {Data: []byte(`{{define "figure"}}<div class="mine">{{.src}}</div>{{end}}`)},
}
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)
}
if strings.Contains(got, "<figure>") {
t.Error("the embedded fragment should have been replaced, not appended")
}
}