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{"`, "Serve your own"}, absent: []string{"`, `class="tally"`}}, + {what: "a page that calls no such shortcode carries none of its assets", path: "/pages/about/", code: 200, + absent: []string{"tally.css", "` fragments, never file paths — a name the theme does not define contributes nothing | | `lang` | string | Explicit language when the filename cannot carry it | | `include` | string | `embed` makes each `::include` file its own document, with its footnote ids namespaced and its notes rendered where it sits. Absent — the default — splices the files in before parsing, so the page is one document: one footnote list at the end, abbreviations reaching every part, every heading in the contents list (ADR-0066, ADR-0076) | diff --git a/docs/decisions.md b/docs/decisions.md index 8991a0d..5d204de 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -1324,3 +1324,57 @@ checkbox is `disabled`, so a reader cannot tick it and nothing is stored; a them read as prose unbullets it in CSS, which the reference theme now does. Revisit if: authors start using task lists for working notes inside published bundles, in which case the answer is `extras/`, which already renders Markdown and is excluded from every listing — not a change here. + +## ADR-0079 — A page carries the assets its own content asked for, named by the theme +Date: 2026-08-02 · Status: accepted +Decision: a page's CSS and JS come from two places and nowhere else. The theme defines `assets:` +fragments beside its other fragments; the engine records which shortcodes a conversion actually called, +adds the bundle's `use:` list, and renders each matching fragment **once** into `Page.Assets` for the +theme's `head` block. Separately, a bundle names its own files in `styles:` and `scripts:`, which are +bundle-relative — a name containing `..` or starting with `/` is dropped and logged, the refusal +`::include` and a code block's `file=` already make (ADR-0038). The engine builds those URLs, because a +theme must not construct an address. +Why: the human wants demos, games and runnable embeds to carry real assets while ordinary pages stay +scriptless, and wants adding an asset to be theme work rather than a rebuild. Naming the fragment +`assets:` reuses the mechanism that already gives that property to shortcodes, so no new file +format, no manifest parser, and no table in Go mapping a shortcode to the files it wants — which would +have hardcoded exactly what was deliberately made data-driven. The alternative considered was +`templates/assets.yaml`; it reads more declaratively and buys a parser, a contract shape and a rebuild +for conditional markup. +Collection is parse-phase: shortcodes record their own name as they are opened, so this moves no render +transform counter — goldmark's extender list is already the ordered pipeline for parse work +(`state.md` counters). Deduplication is first-call order, so a gallery calling one shortcode forty times +carries its stylesheet once. +Consequence: `base.html` gains an empty `head` block that only `page.html` fills, because a listing has no +such field to read. A theme that defines no `assets:` fragment behaves exactly as before, and the +reference theme still ships none — ADR-0063's "no assets" holds for what the engine embeds, and this is a +mechanism for a theme rather than a decision to use it. The reference theme emits `.Assets` and `.Styles` +but **not** `.Scripts`: it contains no ` +{{- 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:` fragment (theme-contract.md, ADR-0079), so a theme adds an asset without a +// rebuild exactly as it adds a shortcode. +// +// First-call order is preserved and repeats collapse, so a page calling one shortcode five times carries +// its asset once. +func RecordCall(pc parser.Context, name string) { + seen, _ := pc.Get(callsKey).(*[]string) + if seen == nil { + seen = &[]string{} + pc.Set(callsKey, seen) + } + if !slices.Contains(*seen, name) { + *seen = append(*seen, name) + } +} + +// callsFrom returns the shortcode names recorded during a parse, in first-call order. +func callsFrom(pc parser.Context) []string { + if seen, ok := pc.Get(callsKey).(*[]string); ok { + return *seen + } + return nil +} + // New parses the theme and prepares the Markdown converter. // // siteFS may be nil, in which case only the embedded reference theme is used. A malformed template is a @@ -186,6 +220,29 @@ func (r *Renderer) Partial(name string, data Fragment) ([]byte, error) { return out.Bytes(), nil } +// assets renders the theme's `assets:` fragment once for each asset this page uses. +// +// Sources are the shortcodes the conversion actually called and the bundle's own `use:` list, so a page +// carries the CSS and JS its own content needs and an ordinary page carries none (ADR-0079). A name the +// theme defines no fragment for contributes nothing and is not an error: most shortcodes need no asset, +// and asking the theme to declare that emptiness would be ceremony. +func (r *Renderer) assets(names []string) template.HTML { + var out strings.Builder + var done []string + for _, name := range names { + if slices.Contains(done, name) { + continue + } + done = append(done, name) + fragment, err := r.Partial("assets:"+name, Fragment{}) + if err != nil { + continue + } + out.Write(fragment) + } + return template.HTML(out.String()) +} + // parseSet builds one set from the named embedded templates, then the site's versions of exactly those // files parsed after them. // @@ -306,6 +363,15 @@ func (r *Renderer) Bundle(b content.Bundle, served string, variants []string, se HTML: template.HTML(body.String()), Extra: b.Extra, Sequence: r.sequence(seq, served), + Assets: r.assets(append(callsFrom(pc), b.Use...)), + } + // A bundle's own files are already served under its URL (ADR-0024), so the engine builds the address + // and the theme never constructs one — the same rule canonical and hreflang follow. + for _, name := range b.Styles { + p.Styles = append(p.Styles, content.URL(b.Route, served)+name) + } + for _, name := range b.Scripts { + p.Scripts = append(p.Scripts, content.URL(b.Route, served)+name) } for _, l := range variants { path := content.URL(b.Route, l) diff --git a/internal/render/templates/base.html b/internal/render/templates/base.html index c3d2fa7..70b9337 100644 --- a/internal/render/templates/base.html +++ b/internal/render/templates/base.html @@ -20,6 +20,9 @@ {{- end}} +{{- /* Per-kind head additions. Empty here because only a bundle has assets to declare, and a listing + would have no field to read — page.html defines this block, every other kind inherits nothing. */}} +{{- block "head" .}}{{end}} {{- if or .Sections .Site.Title}} diff --git a/internal/render/templates/page.html b/internal/render/templates/page.html index 10df3b2..4581ac4 100644 --- a/internal/render/templates/page.html +++ b/internal/render/templates/page.html @@ -1,3 +1,19 @@ +{{/* A page carries only what its own content asked for: assets the theme defines for the shortcodes this + page actually called or its `use:` named, then the bundle's own stylesheets (ADR-0079). An ordinary + page reaches none of these. + + `.Scripts` is deliberately not emitted here. The reference theme contains no script element at all — + it is a contract demonstration, not a design (ADR-0026), and verify.sh holds it to that by grepping + for the literal tag, which is why this comment does not spell one either. A theme that wants the + JavaScript half of ADR-0080's exception redefines this block and adds the tag; that is exactly what + examples/demo-site/templates/page.html does. */}} +{{define "head" -}} +{{.Assets}} +{{- range .Styles}} + +{{- end}} +{{- end}} + {{define "main" -}}
{{if .Title}}

{{.Title}}

{{end}} diff --git a/internal/render/view.go b/internal/render/view.go index 9c5c1f5..1b04926 100644 --- a/internal/render/view.go +++ b/internal/render/view.go @@ -49,6 +49,13 @@ type Page struct { // ExtrasURL links this bundle's supporting files, empty when it has none — so a theme can offer them // without guessing whether they exist (ADR-0047). ExtrasURL string + // Assets is the theme's own markup for everything this page's shortcodes and `use:` asked for, already + // rendered and deduplicated. Emit it in ``. Empty for a page that asked for nothing, which is + // nearly all of them (ADR-0079). + Assets template.HTML + // Styles and Scripts are URLs of this bundle's own CSS and JS, built by the engine because a theme must + // not construct an address. Empty unless frontmatter named files beside the body. + Styles, Scripts []string } // Sequence is a series as a page sees it: its members in reading order, and where this page is in them