Two decisions and one mechanism. The human wants demos, games and runnable
embeds to carry real CSS and JS while every ordinary page stays scriptless, and
wants adding an asset to be theme work rather than a rebuild.
The mechanism reuses what already had that property. A theme defines
`assets:<name>` beside its other fragments; shortcodes record their own name as
they are opened; after conversion the engine renders each matching fragment once
into Page.Assets. So a gallery calling one shortcode forty times carries its
stylesheet once, and a page that called nothing carries nothing. Frontmatter
`use:` reaches the same fragments without a call.
Considered and rejected: templates/assets.yaml, which reads more declaratively
and buys a parser, a contract shape and a rebuild for conditional markup; and a
table in Go mapping shortcode to files, which would hardcode exactly what was
deliberately made data-driven.
Collection is parse-phase, so no transform counter moves — goldmark's extender
list is already the ordered pipeline for parse work, which state.md's counter
says in its "does not count" column.
Separately, styles/scripts are lifted at last. They sat in content-model.md's
table unread, and the theme contract listed them under "what the engine
provides", which was aspirational rather than true. Both are bundle-relative: a
name with .. or a leading / is dropped and logged, the refusal ::include and a
code block's file= already make. The engine builds the URLs because a theme must
not construct an address.
ADR-0080 writes the antifeature list down, with its single exception inside it.
An antifeature nobody recorded does not bind anything, and each of these dies to
one reasonable-looking request at a time. The exception is author-invoked and
cannot fire by accident.
The reference theme emits the stylesheets and no script element at all. That was
the human's correction to a first attempt which had page.html emitting the tag
and verify.sh narrowed to permit it — narrowing the gate to fit the code was
backwards, and the narrowing was also wrong, passing a probe with a hardcoded src
because it filtered whole lines and every line carries {{define}}. verify.sh is
untouched. examples/demo-site redefines the head block instead, so the JavaScript
half is demonstrated by a site rather than built into the binary, which is a
better demonstration and a stronger property.
Evidence, against the demo site with a freshly built binary: the sandbox page
carries its own css and js at bundle-relative URLs; colophon calls ::tally twice
and carries tally.css once with zero scripts; about calls it never and carries
neither; listings unaffected. Plus a table test for the escape refusal, which
until now had only the running server behind it.
19 files, +355/-86. No counter moves. Demo is 31 bundles.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
267 lines
8.8 KiB
Go
267 lines
8.8 KiB
Go
package content
|
|
|
|
import (
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
"testing/fstest"
|
|
)
|
|
|
|
func TestSplitNameDerivesKeyAndLang(t *testing.T) {
|
|
cases := []struct {
|
|
name, key, lang string
|
|
ok bool
|
|
}{
|
|
{name: "pages/about.md", key: "pages/about", lang: "en", ok: true},
|
|
{name: "pages/about.en.md", key: "pages/about", lang: "en", ok: true},
|
|
{name: "pages/about.bn.md", key: "pages/about", lang: "bn", ok: true},
|
|
{name: "posts/hello/index.md", key: "posts/hello", lang: "en", ok: true},
|
|
{name: "posts/hello/index.bn.md", key: "posts/hello", lang: "bn", ok: true},
|
|
{name: "comics/monsoon/_index.md", key: "comics/monsoon", lang: "en", ok: true},
|
|
{name: "posts/my.post.md", key: "posts/my.post", lang: "en", ok: true},
|
|
{name: "posts/notes.txt", ok: false},
|
|
}
|
|
for _, c := range cases {
|
|
key, lang, ok := splitName(c.name)
|
|
if ok != c.ok {
|
|
t.Errorf("%s: ok = %v, want %v", c.name, ok, c.ok)
|
|
continue
|
|
}
|
|
if ok && (key != c.key || lang != c.lang) {
|
|
t.Errorf("%s: got %q/%q, want %q/%q", c.name, key, lang, c.key, c.lang)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseSplitsFrontmatterAndKeepsUnknownKeys(t *testing.T) {
|
|
b, err := Parse("posts/hello.md", []byte("---\ntitle: Hello\nmood: cheerful\ntags: [a, b]\n---\n\nBody text.\n"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if b.Title != "Hello" {
|
|
t.Errorf("title = %q", b.Title)
|
|
}
|
|
if got := string(b.Body); got != "Body text.\n" {
|
|
t.Errorf("body = %q", got)
|
|
}
|
|
if b.Extra["mood"] != "cheerful" {
|
|
t.Error("an unnamed frontmatter key did not land in Extra")
|
|
}
|
|
if len(b.Tags) != 2 || b.Tags[0] != "a" {
|
|
t.Errorf("tags = %v, want [a b] lifted into the named field", b.Tags)
|
|
}
|
|
if _, leaked := b.Extra["tags"]; leaked {
|
|
t.Error("tags should be lifted out of Extra, not duplicated")
|
|
}
|
|
if _, ok := b.Extra["title"]; ok {
|
|
t.Error("title should be lifted out of Extra, not duplicated")
|
|
}
|
|
}
|
|
|
|
func TestParseWithoutFrontmatterIsAllBody(t *testing.T) {
|
|
b, err := Parse("pages/now.md", []byte("Just prose.\n"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if b.Title != "" || string(b.Body) != "Just prose.\n" {
|
|
t.Errorf("got title %q body %q", b.Title, b.Body)
|
|
}
|
|
}
|
|
|
|
func TestParseRejectsBrokenFrontmatter(t *testing.T) {
|
|
if _, err := Parse("posts/bad.md", []byte("---\ntitle: [unclosed\n---\nbody\n")); err == nil {
|
|
t.Fatal("want an error for unparseable YAML")
|
|
}
|
|
}
|
|
|
|
func TestParseMissingTitleIsLegal(t *testing.T) {
|
|
b, err := Parse("status/note.md", []byte("---\ndate: 2026-07-30\n---\nhi\n"))
|
|
if err != nil {
|
|
t.Fatalf("a titleless bundle must parse: %v", err)
|
|
}
|
|
if b.Title != "" {
|
|
t.Errorf("title = %q, want empty", b.Title)
|
|
}
|
|
}
|
|
|
|
func TestScanSkipsBadBundlesAndUnderscoreDirs(t *testing.T) {
|
|
fsys := fstest.MapFS{
|
|
"content/pages/about.md": {Data: []byte("---\ntitle: About\n---\nx\n")},
|
|
"content/posts/hello/index.md": {Data: []byte("---\ntitle: Hello\n---\ny\n")},
|
|
"content/posts/broken.md": {Data: []byte("---\ntitle: [oops\n---\nz\n")},
|
|
"content/_drafts/secret.md": {Data: []byte("---\ntitle: Secret\n---\nq\n")},
|
|
"content/pages/notes.txt": {Data: []byte("not markdown")},
|
|
}
|
|
got, err := Scan(fsys)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
keys := map[string]bool{}
|
|
for _, b := range got {
|
|
keys[b.Key] = true
|
|
}
|
|
if len(got) != 2 || !keys["pages/about"] || !keys["posts/hello"] {
|
|
t.Fatalf("got %d bundles %v, want pages/about and posts/hello only", len(got), keys)
|
|
}
|
|
}
|
|
|
|
func TestScanDropsAmbiguousVariants(t *testing.T) {
|
|
fsys := fstest.MapFS{
|
|
"content/pages/about.md": {Data: []byte("---\ntitle: A\n---\n")},
|
|
"content/pages/about.en.md": {Data: []byte("---\ntitle: B\n---\n")},
|
|
"content/pages/now.md": {Data: []byte("---\ntitle: Now\n---\n")},
|
|
}
|
|
got, err := Scan(fsys)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(got) != 1 || got[0].Key != "pages/now" {
|
|
t.Fatalf("got %+v, want only pages/now: two spellings of one variant are ambiguous", got)
|
|
}
|
|
}
|
|
|
|
func TestNormaliseFoldsDecomposedBengali(t *testing.T) {
|
|
decomposed := "\u0995\u09c7\u09be" // ka + vowel sign e + vowel sign aa
|
|
composed := "\u0995\u09cb" // ka + vowel sign o
|
|
if decomposed == composed {
|
|
t.Skip("inputs are already identical; nothing to prove")
|
|
}
|
|
if Normalise(decomposed) != composed {
|
|
t.Errorf("NFC(%q) = %q, want %q", decomposed, Normalise(decomposed), composed)
|
|
}
|
|
}
|
|
|
|
// TestOpenSiteRefusesSymlinkEscape is the path-traversal guard's evidence: os.DirFS would happily
|
|
// follow this symlink, os.Root does not (ADR-0031).
|
|
func TestOpenSiteRefusesSymlinkEscape(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
site := filepath.Join(tmp, "site")
|
|
if err := os.MkdirAll(filepath.Join(site, "content"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
secret := filepath.Join(tmp, "secret.txt")
|
|
if err := os.WriteFile(secret, []byte("private"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Symlink("../secret.txt", filepath.Join(site, "escape.txt")); err != nil {
|
|
t.Skipf("symlinks unavailable: %v", err)
|
|
}
|
|
|
|
fsys, err := OpenSite(site)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if data, err := fs.ReadFile(fsys, "escape.txt"); err == nil {
|
|
t.Fatalf("read outside the site root succeeded with %q", data)
|
|
}
|
|
if _, err := fs.ReadFile(fsys, "../secret.txt"); err == nil {
|
|
t.Fatal("traversal with .. succeeded")
|
|
}
|
|
}
|
|
|
|
func TestTagSlugPreservesScriptAndFoldsCase(t *testing.T) {
|
|
cases := map[string]string{
|
|
"Long Monsoon": "long-monsoon",
|
|
"WATERCOLOUR": "watercolour",
|
|
"জলরঙ": "জলরঙ",
|
|
" spaced out ": "spaced-out",
|
|
}
|
|
for in, want := range cases {
|
|
if got := TagSlug(in); got != want {
|
|
t.Errorf("TagSlug(%q) = %q, want %q", in, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAnUnderscoreFileIsAPartialNotABundle(t *testing.T) {
|
|
// A file meant only to be included must not also be a bundle: it would take a URL, show up in its
|
|
// section's listing, and make its bundle look like a one-member series (content-model.md).
|
|
fsys := fstest.MapFS{
|
|
"content/pages/about/index.md": {Data: []byte("---\ntitle: About\n---\nx\n")},
|
|
"content/pages/about/_tools.md": {Data: []byte("A fragment.\n")},
|
|
"content/pages/about/_notes.bn.md": {Data: []byte("একটি অংশ।\n")},
|
|
"content/pages/_index.md": {Data: []byte("---\ntitle: Pages\n---\n")},
|
|
}
|
|
bundles, err := Scan(fsys)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var keys []string
|
|
for _, b := range bundles {
|
|
keys = append(keys, b.Key)
|
|
}
|
|
sort.Strings(keys)
|
|
want := []string{"pages", "pages/about"}
|
|
if len(keys) != len(want) {
|
|
t.Fatalf("scanned %v, want %v — _index still names its directory", keys, want)
|
|
}
|
|
for i := range want {
|
|
if keys[i] != want[i] {
|
|
t.Fatalf("scanned %v, want %v", keys, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Extra holds what the parser does not name (ADR-0002). Every lifted key is deleted from it, and `date` was
|
|
// the one exception — a template reading `.Extra.date` got the raw YAML value beside the parsed one.
|
|
func TestALiftedKeyLeavesExtra(t *testing.T) {
|
|
b, err := Parse("posts/x.md", []byte("---\ntitle: T\ndate: 2026-03-08\ntags: [a]\norder: 10\n"+
|
|
"aliases: [old/x]\nslug: s\ndraft: true\nstyles: [a.css]\nscripts: [a.js]\nuse: [lightbox]\n"+
|
|
"keeps: me\n---\nbody\n"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, lifted := range []string{"title", "date", "tags", "order", "aliases", "slug", "draft", "styles", "scripts", "use"} {
|
|
if _, still := b.Extra[lifted]; still {
|
|
t.Errorf("%q is on the Bundle, so it must not also be in Extra: %v", lifted, b.Extra)
|
|
}
|
|
}
|
|
if b.Date.IsZero() {
|
|
t.Error("the date should still have been lifted")
|
|
}
|
|
if b.Extra["keeps"] != "me" {
|
|
t.Errorf("a key the parser does not name stays: %v", b.Extra)
|
|
}
|
|
}
|
|
|
|
// A declared asset names a file beside the body and nothing else (ADR-0079). The refusal is the one
|
|
// `::include` and a code block's `file=` already make, so a page can ship a stylesheet without being able
|
|
// to publish a template, a dotfile, or another bundle's files. Dropped and logged, never fatal (ADR-0029).
|
|
func TestAnAssetNameCannotLeaveItsBundle(t *testing.T) {
|
|
for _, c := range []struct {
|
|
what string
|
|
front string
|
|
want []string
|
|
}{
|
|
{"a sibling file is kept", "styles: [ok.css]", []string{"ok.css"}},
|
|
{"a subdirectory is inside the bundle", "styles: [css/ok.css]", []string{"css/ok.css"}},
|
|
{"climbing out is dropped", "styles: [../../templates/theme.css]", nil},
|
|
{"an absolute path is dropped", "styles: [/etc/passwd]", nil},
|
|
{"the good one survives beside the bad", "styles: [../x.css, ok.css]", []string{"ok.css"}},
|
|
{"a scalar is a list of one", "styles: ok.css", []string{"ok.css"}},
|
|
{"scripts follow the same rule", "scripts: [../x.js]", nil},
|
|
} {
|
|
b, err := Parse("posts/x.md", []byte("---\ntitle: T\n"+c.front+"\n---\nbody\n"))
|
|
if err != nil {
|
|
t.Fatalf("%s: %v", c.what, err)
|
|
}
|
|
got := b.Styles
|
|
if strings.HasPrefix(c.front, "scripts") {
|
|
got = b.Scripts
|
|
}
|
|
if len(got) != len(c.want) {
|
|
t.Errorf("%s: got %v, want %v", c.what, got, c.want)
|
|
continue
|
|
}
|
|
for i := range got {
|
|
if got[i] != c.want[i] {
|
|
t.Errorf("%s: got %v, want %v", c.what, got, c.want)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|