Files
khosra/internal/ext/shortcodes/shortcodes_test.go
T
Claude Opus 5andbdeshi 7136f6e2d9 let a shortcode fragment speak the reader's language
Three fragments added this session needed a word the author did not write: an
untitled :::warn told the reader nothing about being a warning, an untitled
panel fell back to whatever the browser calls <details>, and the contents list
had no label at all — an accessibility gap as much as an untranslated one. None
of them could be fixed, because `t` needs a language and Fragment had none, so
those words could only ever have been English on a site that serves Bengali.

Fragment gains Lang, captured on each call at parse time — a node renderer never
receives the parse context, the same constraint that put pictures and headings
on the node. Five phrase keys follow, and a Bengali page now reads সূচিপত্র,
সতর্কতা and বিস্তারিত where an English one reads Contents, Warning and Details.

The demo's own list.html was the better example of the problem and now shows the
answer: a site's own sentences are not in the engine's phrase table, so a
template needing its own words branches on the language it was given. That is
what theme-contract.md has always told a theme to do, demonstrated rather than
asserted, and a case proves the Bengali listing carries no English.

Two files crossed the size advisory on the way. render.go shed the contract
types to view.go, where state.md already claimed they lived and where the file's
own header said they belonged; shortcodes_test.go split to mirror its sources,
which the one-file-per-source convention already asked for. Both are pure moves.
2026-08-01 23:09:03 +06:00

323 lines
13 KiB
Go

package shortcodes
import (
"strings"
"testing"
"testing/fstest"
"github.com/yuin/goldmark"
gmext "github.com/yuin/goldmark/extension"
"khosra/internal/content"
"khosra/internal/render"
)
func TestParseAcceptsOnlyAWholeLineCall(t *testing.T) {
// 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"} `, opener)
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 name, args, ok := parse("::gallery", opener); !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",
"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, which has its own parser: the leaf one must never claim
// them as a call named ":note".
":::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, opener); 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 {
// Footnotes too: how they land is half of what the merge flag decides (ADR-0066).
return []goldmark.Extender{
gmext.NewFootnote(gmext.WithFootnoteIDPrefixFunction(FootnotePrefix)),
New(p),
}
})
if err != nil {
t.Fatal(err)
}
r.Compose(Merge)
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, "::figure") {
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)
}
// 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")
if strings.Contains(got, " onerror=") {
t.Errorf("an argument must not become an attribute of its own:\n%s", got)
}
// Authored raw HTML *does* render now, because the site root is trusted (ADR-0060). That is a decision
// about the author's own words, and it changes nothing here: a shortcode argument is data the template
// places, so it is escaped in whichever context it lands in, whatever the page around it is allowed to do.
got = body(t, wired(t, nil), "<span class=\"live\">rendered</span>\n\n::figure{src=a.jpg alt=<b>bold</b>}\n")
if !strings.Contains(got, `<span class="live">rendered</span>`) {
t.Errorf("authored HTML should reach the page intact:\n%s", got)
}
if strings.Contains(got, "<b>bold</b>") || !strings.Contains(got, "&lt;b&gt;") {
t.Errorf("an argument stays data even where raw HTML is allowed:\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 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{`<h2 id="included">Included</h2>`, "<em>emphasis</em>", `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, "::include") {
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"}}<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")
}
}
// A fragment supplies words of its own when the author gives none, and those words are the engine's, so they
// localise (ADR-0067). A Bengali page must not be told "Warning" in English.
func TestAFragmentLocalisesItsOwnWords(t *testing.T) {
src := "::toc\n\n## One\n\n:::warn\nNo title.\n:::\n\n:::details\nNo summary.\n:::\n"
fsys := fstest.MapFS{
"content/posts/p.en.md": {Data: []byte("---\ntitle: EN\n---\n" + src)},
"content/posts/p.bn.md": {Data: []byte("---\ntitle: BN\n---\n" + src)},
}
for lang, want := range map[string][]string{
"en": {"Contents", "Warning", "Details"},
"bn": {"সূচিপত্র", "সতর্কতা", "বিস্তারিত"},
} {
bundles, err := content.Scan(fsys)
if err != nil {
t.Fatal(err)
}
site := content.NewSite(bundles)
b, served, ok := site.Lookup("posts/p", lang)
if !ok || served != lang {
t.Fatalf("no %s variant of posts/p", lang)
}
out, err := wired(t, fsys).Bundle(b, served, nil, nil)
if err != nil {
t.Fatal(err)
}
for _, phrase := range want {
if !strings.Contains(string(out), phrase) {
t.Errorf("the %s page is missing %q:\n%s", lang, phrase, out)
}
}
}
}