A feature now learns which bundle is rendering: render.Bundle puts an Origin —
the bundle's directory plus the rooted fs.FS — on the parse context, and
render.OriginFrom reads it back. Available while parsing, not while rendering,
which decides where a feature does its filesystem work: goldmark hands the
context to a block parser and not to a node renderer, so gallery gathers its
filenames at parse time and carries them on the node.
Reads stay inside the site root because Origin passes the fs.FS rather than a
path to join (ADR-0031).
Fragment{Args, Items} lands with it (ADR-0037), so figure's template now reads
.Args.src. Authored arguments and engine-gathered items stay in separate fields:
a src argument beside a src the engine found would otherwise silently pick one.
A gallery is pictures beside the bundle, in filename order, skipping
subdirectories and anything a browser cannot show. Filename order is what makes
the sparse numeric-prefix convention work without numbers in URLs (ADR-0016).
New latent row: the reference theme's images carry no width/height and a
gallery's carry no alt, which is below the output floor conventions.md states.
Nothing can supply either yet — dimensions need the image read, and a filename is
not alt text. Queue 13 computes dimensions and brings structured items with it.
192 lines
6.7 KiB
Go
192 lines
6.7 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, "<script>") {
|
|
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, """) {
|
|
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)
|
|
}
|
|
}
|
|
|
|
// 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/20-second.jpg": {Data: []byte("x")},
|
|
"content/art/monsoon/10-first.PNG": {Data: []byte("x")},
|
|
"content/art/monsoon/30-third.webp": {Data: []byte("x")},
|
|
"content/art/monsoon/notes.md": {Data: []byte("not a picture")},
|
|
"content/art/monsoon/sketches/a.jpg": {Data: []byte("x")},
|
|
"content/art/elsewhere.jpg": {Data: []byte("x")},
|
|
}
|
|
}
|
|
|
|
// bundle renders the named bundle out of fsys, the way the server does.
|
|
func bundle(t *testing.T, fsys fstest.MapFS, name string) string {
|
|
t.Helper()
|
|
bundles, err := content.Scan(fsys)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
site := content.NewSite(bundles)
|
|
b, served, ok := site.Lookup(name, "en")
|
|
if !ok {
|
|
t.Fatalf("no bundle %q", name)
|
|
}
|
|
out, err := wired(t, fsys).Bundle(b, served, nil, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return string(out)
|
|
}
|
|
|
|
func TestGalleryListsThePicturesBesideItsBundle(t *testing.T) {
|
|
got := bundle(t, galleryFS(), "art/monsoon")
|
|
first := strings.Index(got, "10-first.PNG")
|
|
second := strings.Index(got, "20-second.jpg")
|
|
third := strings.Index(got, "30-third.webp")
|
|
if first < 0 || second < first || third < second {
|
|
t.Errorf("pictures should list in filename order, case-insensitively recognised:\n%s", got)
|
|
}
|
|
for _, absent := range []string{"notes.md", "sketches", "elsewhere.jpg"} {
|
|
if strings.Contains(got, absent) {
|
|
t.Errorf("a gallery is pictures beside the bundle only, but %q appeared:\n%s", absent, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
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")
|
|
if !strings.Contains(got, "Still here.") {
|
|
t.Errorf("the page must survive a gallery with nothing to show:\n%s", got)
|
|
}
|
|
if strings.Contains(got, "<div class=\"gallery\">") {
|
|
t.Errorf("an empty gallery should render nothing at all:\n%s", got)
|
|
}
|
|
}
|
|
|
|
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")
|
|
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")
|
|
}
|
|
}
|