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{
"
`, "
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"}}