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, content.Settings{}, 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{ "
", `A cat`, "
Sleeping
", "
", } { if !strings.Contains(got, want) { t.Errorf("missing %q:\n%s", want, got) } } if strings.Contains(got, "

") || 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="" >}}`+"\n") if strings.Contains(got, "\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, "
") { t.Errorf("an empty gallery should render nothing at all:\n%s", got) } } 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/more.md": {Data: []byte("## Included\n\nWith *emphasis* and a [link](/posts/).\n")}, } got := bundle(t, fsys, "pages/about") // Converted as Markdown, not pasted as text: the heading, emphasis and link prove it. for _, want := range []string{"

Included

", "emphasis", `href="/posts/"`} { if !strings.Contains(got, want) { t.Errorf("missing %q — an include is Markdown, not a string:\n%s", want, got) } } first, included, last := strings.Index(got, "First."), strings.Index(got, "Included"), strings.Index(got, "Last.") if first < 0 || included < first || last < included { t.Errorf("included content belongs where the call was:\n%s", got) } } 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")}, } got := bundle(t, fsys, "pages/loop") for _, want := range []string{"Before.", "Round.", "After."} { if !strings.Contains(got, want) { t.Errorf("missing %q — one level must still render:\n%s", want, got) } } if n := strings.Count(got, "Round."); n != 1 { t.Errorf("expanded %d times, want exactly one level", n) } if strings.Contains(got, "{{<") { t.Errorf("the ignored nested call renders nothing, it is not printed:\n%s", got) } } 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/one.jpg": {Data: []byte("x")}, "content/art/set/two.png": {Data: []byte("x")}, } got := bundle(t, fsys, "art/set") if !strings.Contains(got, "one.jpg") || !strings.Contains(got, "two.png") { t.Errorf("a gallery inside an include should resolve against the same bundle:\n%s", got) } } 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")}, "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")}, } for _, key := range []string{"pages/a", "pages/b", "pages/c", "pages/d"} { got := bundle(t, fsys, key) if !strings.Contains(got, "Survived.") { t.Errorf("%s: the page must survive a bad include:\n%s", key, got) } if strings.Contains(got, "root:") || strings.Contains(got, "passwd") || strings.Contains(got, "NOT CONTENT") { t.Fatalf("%s: an include read something it must not:\n%s", key, got) } } } func TestASiteRedefinesOneFragment(t *testing.T) { site := fstest.MapFS{ "templates/shortcodes.html": {Data: []byte(`{{define "figure"}}
{{.Args.src}}
{{end}}`)}, } got := body(t, wired(t, site), "{{< figure src=\"cat.jpg\" >}}\n") if !strings.Contains(got, `
cat.jpg
`) { t.Errorf("the site's fragment should win:\n%s", got) } if strings.Contains(got, "
") { t.Error("the embedded fragment should have been replaced, not appended") } }