diff --git a/cmd/khosra/example_test.go b/cmd/khosra/example_test.go index 419d16e..99de0a0 100644 --- a/cmd/khosra/example_test.go +++ b/cmd/khosra/example_test.go @@ -132,6 +132,14 @@ var exampleFeatures = []featureCase{ absent: []string{" +{{- end}} +{{- end}} diff --git a/examples/demo-site/templates/shortcodes/tally.html b/examples/demo-site/templates/shortcodes/tally.html new file mode 100644 index 0000000..986bfb9 --- /dev/null +++ b/examples/demo-site/templates/shortcodes/tally.html @@ -0,0 +1,6 @@ +{{/* A shortcode whose markup needs a stylesheet, demonstrating that the theme — not the engine — decides + which assets a call pulls in (ADR-0079). The engine renders `assets:tally` once however many times + ::tally is called, and not at all on a page that never calls it. */}} +{{define "tally"}}
{{.Args.label}}: {{.Args.count}}
{{end}} +{{define "assets:tally"}} +{{end}} diff --git a/internal/content/content.go b/internal/content/content.go index a689dc5..302d6c9 100644 --- a/internal/content/content.go +++ b/internal/content/content.go @@ -52,6 +52,13 @@ type Bundle struct { // Draft is true when frontmatter says so. A draft is not served at all until `-dev` reveals it, and // neither are the files inside its bundle (ADR-0024). Draft bool + // Styles and Scripts are this bundle's own CSS and JS files, named in frontmatter and living beside the + // body. Bundle-relative and nothing else: a name that climbs out is dropped at parse (ADR-0079). Empty + // for the overwhelming majority of bundles, which is the point — an ordinary page carries no script. + Styles, Scripts []string + // Use names theme assets this bundle wants without calling the shortcode that would pull them in — the + // frontmatter half of the same mechanism. These are names the theme resolves, never file paths. + Use []string // Order is this bundle's position in the series it is nested under, zero when frontmatter omits it. // The convention is sparse (10, 20, 30), so zero is not a position: an unordered member sorts by name // after every ordered one (ADR-0033). @@ -165,6 +172,12 @@ func Parse(name string, data []byte) (Bundle, error) { delete(b.Extra, "order") b.Draft, _ = b.Extra["draft"].(bool) delete(b.Extra, "draft") + b.Styles = bundleFiles(b.Extra["styles"], b.Key) + delete(b.Extra, "styles") + b.Scripts = bundleFiles(b.Extra["scripts"], b.Key) + delete(b.Extra, "scripts") + b.Use = terms(b.Extra["use"]) + delete(b.Extra, "use") if slug, isStr := b.Extra["slug"].(string); isStr { // One segment, normalised like every other identifier (ADR-0015). Slashes would let a slug move the // bundle to another section, which is a move, not a rename. @@ -250,6 +263,23 @@ func asInt(v any) int { } // terms reads a scalar or sequence of tag names, preserving case and script. +// bundleFiles reads a scalar-or-list of filenames that must stay inside the bundle. +// +// A name that climbs out is dropped and logged rather than fatal (ADR-0029), the same refusal an include +// and a code block's `file=` already make (ADR-0038): a page may ship its own stylesheet, never reach a +// template, a dotfile, or another bundle's files with one. +func bundleFiles(v any, where string) []string { + var out []string + for _, name := range terms(v) { + if strings.Contains(name, "..") || strings.HasPrefix(name, "/") { + slog.Warn("asset name leaves its bundle and is ignored", "name", name, "bundle", where) + continue + } + out = append(out, name) + } + return out +} + func terms(v any) []string { var out []string add := func(x any) { diff --git a/internal/content/content_test.go b/internal/content/content_test.go index 70e9f2d..1f64388 100644 --- a/internal/content/content_test.go +++ b/internal/content/content_test.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "sort" + "strings" "testing" "testing/fstest" ) @@ -208,11 +209,12 @@ func TestAnUnderscoreFileIsAPartialNotABundle(t *testing.T) { // 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\nkeeps: me\n---\nbody\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"} { + 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) } @@ -224,3 +226,41 @@ func TestALiftedKeyLeavesExtra(t *testing.T) { 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 + } + } + } +} diff --git a/internal/ext/shortcodes/shortcodes.go b/internal/ext/shortcodes/shortcodes.go index e517770..e0be226 100644 --- a/internal/ext/shortcodes/shortcodes.go +++ b/internal/ext/shortcodes/shortcodes.go @@ -219,6 +219,9 @@ func (blocks) Open(parent ast.Node, reader text.Reader, pc parser.Context) (ast. } reader.Advance(seg.Len() - 1) n := &node{name: name, args: args} + // The page carries whatever assets its own calls need, so the call is recorded here where the name is + // known. What a name *means* in assets is the theme's to say (ADR-0079). + render.RecordCall(pc, name) if origin, ok := render.OriginFrom(pc); ok { n.lang = origin.Lang } diff --git a/internal/render/render.go b/internal/render/render.go index 79951ea..a63d950 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -12,6 +12,8 @@ import ( "html/template" "io/fs" "path" + "slices" + "strings" "github.com/yuin/goldmark" "github.com/yuin/goldmark/extension" @@ -72,6 +74,38 @@ func WithOrigin(pc parser.Context, origin Origin) { pc.Set(originKey, origin) } +// callsKey identifies the set of shortcode names called during one parse. Same shape as originKey, and +// unexported for the same reason. +var callsKey = parser.NewContextKey() + +// RecordCall notes that a shortcode of this name was called while converting this page. +// +// A feature records the call; the renderer decides what it means. That split is what keeps the engine +// free of a table mapping shortcode names to the files they need — that mapping is the theme's, written +// as an `assets: