From f196e3420d67f45cc16126c8361183b94ae5ca0a Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Thu, 30 Jul 2026 10:39:37 +0600 Subject: [PATCH] add the gallery shortcode and the seam it needed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A feature now learns which bundle is rendering: render.Bundle puts an Origin — the bundle's directory plus the rooted fs.FS — on the parse context, and render.OriginFrom reads it back. Available while parsing, not while rendering, which decides where a feature does its filesystem work: goldmark hands the context to a block parser and not to a node renderer, so gallery gathers its filenames at parse time and carries them on the node. Reads stay inside the site root because Origin passes the fs.FS rather than a path to join (ADR-0031). Fragment{Args, Items} lands with it (ADR-0037), so figure's template now reads .Args.src. Authored arguments and engine-gathered items stay in separate fields: a src argument beside a src the engine found would otherwise silently pick one. A gallery is pictures beside the bundle, in filename order, skipping subdirectories and anything a browser cannot show. Filename order is what makes the sparse numeric-prefix convention work without numbers in URLs (ADR-0016). New latent row: the reference theme's images carry no width/height and a gallery's carry no alt, which is below the output floor conventions.md states. Nothing can supply either yet — dimensions need the image read, and a filename is not alt text. Queue 13 computes dimensions and brings structured items with it. --- docs/content-model.md | 12 +++-- docs/extensions.md | 5 ++ docs/state.md | 9 ++-- docs/theme-contract.md | 22 ++++++-- internal/ext/shortcodes/shortcodes.go | 48 ++++++++++++++++- internal/ext/shortcodes/shortcodes_test.go | 61 +++++++++++++++++++++- internal/render/render.go | 55 +++++++++++++++---- internal/render/templates/shortcodes.html | 16 ++++-- 8 files changed, 199 insertions(+), 29 deletions(-) diff --git a/docs/content-model.md b/docs/content-model.md index 9a51535..90101b1 100644 --- a/docs/content-model.md +++ b/docs/content-model.md @@ -281,10 +281,14 @@ and logs it — one typo does not take a page down (ADR-0029). Shortcodes run on site-root content only (ADR-0003), never on anything untrusted. -`figure` exists. `include` and `gallery` are `[spec]`: both need the including bundle's directory, which is -context the parser does not carry yet. File inclusion will resolve relative to the including bundle and may -not escape the site root. Transclusion of another bundle's body is Arc 4 and needs a cycle guard on the -first attempt. +`figure` and `gallery` exist. `{{< gallery >}}` takes no arguments: it lists the pictures sitting beside the +bundle, in filename order, which is why the sparse numeric-prefix convention orders a set without putting +numbers in URLs (ADR-0016). A subdirectory is not part of the gallery, and neither is a file the browser +cannot show. + +`include` is `[spec]`: file inclusion will resolve relative to the including bundle, may not escape the site +root, and needs a depth limit on the first attempt, since a file that includes itself is otherwise a crash. +Transclusion of another bundle's body is Arc 4 and needs a cycle guard too. ## Images `[spec]` diff --git a/docs/extensions.md b/docs/extensions.md index 0bb19ba..599a5e1 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -11,6 +11,11 @@ How a feature reaches the engine today: `cmd` builds the list, so nothing under features exist. A feature that must emit markup is handed `render.Partial` and renders through a theme template, because deciding markup is not a feature's job (ADR-0036). +A feature that needs to know *which bundle* is rendering reads `render.OriginFrom` off the parse context — +the bundle's directory plus the rooted `fs.FS`, so a path in a call resolves against the bundle and cannot +leave the site root (ADR-0031). It is available while parsing, not while rendering, so anything a feature +must read from disk it reads then. + ## The gate | Stage of growth | What a feature looks like | Trigger to advance | diff --git a/docs/state.md b/docs/state.md index da71ef7..4c8c57e 100644 --- a/docs/state.md +++ b/docs/state.md @@ -1,6 +1,6 @@ # State -**Verified against:** `820720d` on 2026-07-30 — update this line every change. +**Verified against:** `61ae5c9` on 2026-07-30 — update this line every change. If this file disagrees with the code, the code is right and this file is a bug. ## Inventory @@ -11,15 +11,15 @@ If this file disagrees with the code, the code is right and this file is a bug. | `internal/content/doc.go` | package comment | 5 | | `internal/content/content.go` | bundles: `os.Root` open, walk, frontmatter split, key/lang derivation, NFC, tag slugs, permalink building | 352 | | `internal/content/site.go` | the indexed site: lookup with language fallback, aliases, `Query` and `Run`, sections, `Sequence` | 286 | -| `internal/render/render.go` | goldmark with the typographer, per-kind template sets with site override, the `Partial` seam features render through, `Page`/`List`/`Sequence`/`head` | 345 | +| `internal/render/render.go` | goldmark with the typographer, per-kind template sets with site override, the `Partial`/`Origin` seams features render and resolve through, `Page`/`List`/`Sequence`/`head` | 380 | | `internal/render/chrome.go` | the engine's own words: phrase table, month names, digits, and the `t`/`num`/`day` template funcs (ADR-0034) | 105 | | `internal/render/templates/` | reference theme: `base.html`, `page.html`, `list.html`, `shortcodes.html`, `theme.css` (ADR-0026) | — | -| `internal/ext/shortcodes/` | first feature: `{{< name key="value" >}}` block parser and node renderer, rendering through a theme fragment (ADR-0036) | 168 | +| `internal/ext/shortcodes/` | first feature: `{{< name key="value" >}}` block parser and node renderer, rendering through a theme fragment (ADR-0036). `figure`, `gallery` | 212 | | `cmd/khosra/wire.go` | the only list of enabled features (`extensions.md`) | 19 | | `internal/web/resolve.go` | URL → (key, lang, page, tag) or a canonical redirect: language prefix, `/en/…` fork guard, pagination, tags, trailing slash | 112 | | `internal/web/web.go` | handler: resolve, look up with fallback, section and tag listings, sequence, `/static/` (misses and refusals alike answer 404), degrade on failure | 152 | | `cmd/khosra/main.go` | flags, wiring, startup — the only place things are assembled | 53 | -| `*_test.go` | table-driven, one file per source file; symlink escape (content and static), canonical paths, language fallback, aliases, pagination, tags, sequences, chrome, typography, shortcode escaping, 404 | 1288 | +| `*_test.go` | table-driven, one file per source file; symlink escape (content and static), canonical paths, language fallback, aliases, pagination, tags, sequences, chrome, typography, shortcode escaping, galleries, 404 | 1363 | Serves a bundle at `/{section}/{slug}/`, a paginated listing per section, tag listings global and section-narrowed, sequence navigation and a series archive on any nested bundle, and `static/` verbatim. @@ -63,6 +63,7 @@ with a stated reason. A list nothing drains is a graveyard of known defects. | No mechanical check that the counters are *correct* | Accepted at the Arc 1 boundary: the coupling gate makes forgetting them impossible, which is the real failure mode, and checking the values needs code to count | The first page-level transform (queue 15), now that the transform counter means something narrower | | No mechanical gate on the untrusted boundary (ADR-0003) | Scheduled to Arc 3: nothing untrusted is read yet. Half of it is now mechanical — `verify.sh` rejects `WithUnsafe`, so authored Markdown cannot become HTML — but there is still no check that a *future* untrusted source stays out of shortcode and template evaluation | The comment path — a test that untrusted input reaches no shortcode or template evaluation | | `date` stays in `Extra` after being lifted onto `Bundle.Date`, unlike `title`, `aliases`, `tags` and `order`, which are deleted | Spotted while adding `order`; the theme contract says `Extra` holds what the parser does not name, so one of the two is wrong. Harmless today — a template reading `.Extra.date` gets the raw YAML value | Whatever next reads `Extra` generically: feeds (queue 14) or `check` (17) | +| The reference theme's images carry no `width`/`height`, and a gallery's carry no `alt` — below the output floor `conventions.md` states | Nothing can supply them yet: dimensions need the image read, and a filename is not alt text. An empty `alt` is at least honest about a picture nothing describes | Image derivatives (queue 13) compute dimensions; structured gallery items with captions land with them (ADR-0037's revisit note) | | Sequence resolution rescans the index on every bundle request — two passes over every key, each doing a `Lookup` | No cache exists anywhere yet, and a site of this size resolves in microseconds. Measuring first is the rule (queue 16) | The page cache (queue 16), which is the thing that makes the cost visible | ## Open questions diff --git a/docs/theme-contract.md b/docs/theme-contract.md index 772363a..bd27561 100644 --- a/docs/theme-contract.md +++ b/docs/theme-contract.md @@ -76,15 +76,27 @@ block; site-supplied strings wait for the settings cascade (`ideas/deferred-deci ## Shortcode fragments `templates/shortcodes.html` holds one named template per shortcode, and that is where a shortcode's markup -lives — the engine parses the call and supplies its arguments, never any HTML (ADR-0036). +lives — the engine parses the call and supplies its data, never any HTML (ADR-0036). + +Every fragment receives the same two fields (ADR-0037): + +| Field | Contents | +|---|---| +| `.Args` | the call's `key="value"` pairs, exactly as the author wrote them | +| `.Items` | a list the *engine* gathered, empty unless that shortcode gathers one | | Shortcode | Template | Receives | |---|---|---| -| `{{< figure src="…" alt="…" caption="…" >}}` | `figure` | `.src`, `.alt`, `.caption` — every argument as written, escaped on output | +| `{{< figure src="…" alt="…" caption="…" >}}` | `figure` | `.Args.src`, `.Args.alt`, `.Args.caption` | +| `{{< gallery >}}` | `gallery` | `.Items` — the picture filenames beside the bundle, in filename order | -Arguments arrive as strings and are escaped by `html/template` like any other data, which is what keeps an -author's text out of the markup. A call whose template is missing renders nothing and logs; it never fails -the page. +Authored and engine-supplied data are kept apart so a `src` argument can never be confused with a `src` the +engine found. Arguments are escaped by `html/template` like any other data, in whichever context the +template puts them — which is what keeps an author's text out of the markup. A call whose template is +missing renders nothing and logs; it never fails the page. + +A gallery entry is a bare filename, relative to the bundle, so a template writes it straight into `src`. It +carries no alt text or dimensions, because nothing in a filename supplies either. The set is overlaid the same way as the page kinds: a site's `templates/shortcodes.html` is parsed after the embedded one, so redefining `figure` replaces it and any fragment left alone is inherited. Argument diff --git a/internal/ext/shortcodes/shortcodes.go b/internal/ext/shortcodes/shortcodes.go index a4bad95..70dfbdf 100644 --- a/internal/ext/shortcodes/shortcodes.go +++ b/internal/ext/shortcodes/shortcodes.go @@ -1,7 +1,11 @@ package shortcodes import ( + "io/fs" "log/slog" + "path" + "slices" + "sort" "strings" "github.com/yuin/goldmark" @@ -50,6 +54,8 @@ type node struct { ast.BaseBlock name string args map[string]string + // items are what the feature gathered at parse time, when it still knew which bundle this is. + items []string } func (n *node) Kind() ast.NodeKind { return kind } @@ -68,7 +74,45 @@ func (blocks) Open(parent ast.Node, reader text.Reader, pc parser.Context) (ast. return nil, parser.NoChildren } reader.Advance(seg.Len() - 1) - return &node{name: name, args: args}, parser.NoChildren + n := &node{name: name, args: args} + // A call that reads the filesystem does it here, where the parse context says which bundle this is. + // The renderer has no context, so anything gathered has to be gathered now. + if name == "gallery" { + n.items = images(pc) + } + return n, parser.NoChildren +} + +// pictures are the extensions a gallery treats as an image. A file the browser cannot show is not a +// gallery entry, and guessing by content would mean reading every file in the directory. +var pictures = []string{".avif", ".gif", ".jpeg", ".jpg", ".png", ".svg", ".webp"} + +// images lists the pictures sitting beside the bundle being rendered, sorted by filename. +// +// Sorted because the sparse numeric-prefix convention orders entries without putting numbers in URLs +// (ADR-0016), and because a directory read has no order worth relying on. A renderer without a site root +// gathers nothing rather than guessing. +func images(pc parser.Context) []string { + origin, ok := render.OriginFrom(pc) + if !ok || origin.Files == nil { + return nil + } + entries, err := fs.ReadDir(origin.Files, origin.Dir) + if err != nil { + slog.Error("gallery cannot read its bundle directory", "dir", origin.Dir, "err", err) + return nil + } + var found []string + for _, e := range entries { + if e.IsDir() { + continue + } + if slices.Contains(pictures, strings.ToLower(path.Ext(e.Name()))) { + found = append(found, e.Name()) + } + } + sort.Strings(found) + return found } // Continue never runs: a call is one line, closed as soon as it opens. @@ -100,7 +144,7 @@ func (f fragments) render(w util.BufWriter, source []byte, n ast.Node, entering return ast.WalkContinue, nil } call := n.(*node) - out, err := f.partial(call.name, call.args) + out, err := f.partial(call.name, render.Fragment{Args: call.args, Items: call.items}) if err != nil { slog.Error("skipping shortcode", "name", call.name, "err", err) return ast.WalkContinue, nil diff --git a/internal/ext/shortcodes/shortcodes_test.go b/internal/ext/shortcodes/shortcodes_test.go index c1efcc4..6c07208 100644 --- a/internal/ext/shortcodes/shortcodes_test.go +++ b/internal/ext/shortcodes/shortcodes_test.go @@ -118,9 +118,68 @@ func TestAnUnknownShortcodeDegradesToNothing(t *testing.T) { } } +// 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, "
") { + t.Errorf("an empty gallery should render nothing at all:\n%s", got) + } +} + func TestASiteRedefinesOneFragment(t *testing.T) { site := fstest.MapFS{ - "templates/shortcodes.html": {Data: []byte(`{{define "figure"}}
{{.src}}
{{end}}`)}, + "templates/shortcodes.html": {Data: []byte(`{{define "figure"}}
{{.Args.src}}
{{end}}`)}, } got := body(t, wired(t, site), "{{< figure src=\"cat.jpg\" >}}\n") if !strings.Contains(got, `
cat.jpg
`) { diff --git a/internal/render/render.go b/internal/render/render.go index e079f64..2259850 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -11,10 +11,12 @@ import ( "fmt" "html/template" "io/fs" + "path" "time" "github.com/yuin/goldmark" "github.com/yuin/goldmark/extension" + "github.com/yuin/goldmark/parser" "khosra/internal/content" ) @@ -113,15 +115,44 @@ type Renderer struct { partials *template.Template md goldmark.Markdown style template.CSS + // files is the site root, handed to features through Origin. Nil when there is none. + files fs.FS } -// Partial renders a named fragment with the arguments a feature parsed. A feature under internal/ext is -// handed one of these at wiring time, because markup belongs to the theme and a feature must not write any -// (ADR-0036). +// Partial renders a named fragment. A feature under internal/ext is handed one of these at wiring time, +// because markup belongs to the theme and a feature must not write any (ADR-0036). +type Partial func(name string, data Fragment) ([]byte, error) + +// Fragment is what a fragment template receives (ADR-0037). +type Fragment struct { + // Args are the call's key="value" pairs, exactly as written. Escaping is the template's. + Args map[string]string + // Items is a list the feature gathered rather than the author wrote — the filenames a gallery found. + // Kept apart from Args so a supplied value can never be mistaken for an authored one. + Items []string +} + +// Origin tells a feature which bundle is being rendered, so a path in a call can resolve relative to it. // -// Arguments are strings because that is what a shortcode call carries. A feature needing richer data is -// the reason to widen this, not a reason to have made it `any` in advance. -type Partial func(name string, args map[string]string) ([]byte, error) +// Features read it from the parser context with OriginFrom. It carries the site's fs.FS rather than a +// directory name alone, because every read goes through the rooted filesystem and never a joined path +// (ADR-0031). +type Origin struct { + // Dir is the bundle's directory, relative to the site root: "content/comics/the-long-monsoon". + Dir string + // Files is the site root. Nil when the renderer was built without one, in which case a feature that + // needs files degrades rather than guessing. + Files fs.FS +} + +// originKey identifies the Origin in a parse. Unexported, so the typed accessor is the only way in. +var originKey = parser.NewContextKey() + +// OriginFrom reports the bundle being rendered, and false outside a bundle render. +func OriginFrom(pc parser.Context) (Origin, bool) { + origin, ok := pc.Get(originKey).(Origin) + return origin, ok +} // New parses the theme and prepares the Markdown converter. // @@ -149,7 +180,7 @@ func New(siteFS fs.FS, extend func(Partial) []goldmark.Extender) (*Renderer, err if err != nil { return nil, err } - r := &Renderer{page: page, list: list, partials: partials, style: css} + r := &Renderer{page: page, list: list, partials: partials, style: css, files: siteFS} // The typographer smooths quotes, dashes and ellipses in authored prose and leaves code spans alone, // because it works on the parsed tree rather than the text. That is the only change the engine makes to // an author's words (ADR-0034), and it is a parser option rather than a render transform, so it does @@ -167,12 +198,12 @@ func New(siteFS fs.FS, extend func(Partial) []goldmark.Extender) (*Renderer, err // Partial renders one named fragment. A missing template is an error the caller degrades on, never a // failed request (extensions.md rule 5). -func (r *Renderer) Partial(name string, args map[string]string) ([]byte, error) { +func (r *Renderer) Partial(name string, data Fragment) ([]byte, error) { if r.partials.Lookup(name) == nil { return nil, fmt.Errorf("no template named %q", name) } var out bytes.Buffer - if err := r.partials.ExecuteTemplate(&out, name, args); err != nil { + if err := r.partials.ExecuteTemplate(&out, name, data); err != nil { return nil, fmt.Errorf("partial %s: %w", name, err) } return out.Bytes(), nil @@ -226,8 +257,12 @@ func readStyle(siteFS fs.FS) (template.CSS, error) { // exists in; both feed canonical and hreflang, which a theme must not construct itself. seq is the series // the bundle sits in, or nil. func (r *Renderer) Bundle(b content.Bundle, served string, variants []string, seq *content.Sequence) ([]byte, error) { + // The parse carries which bundle it is, so a feature can resolve a path in a call against the bundle's + // own directory (ADR-0031: through the rooted filesystem, never a joined path). + pc := parser.NewContext() + pc.Set(originKey, Origin{Dir: path.Dir(b.Path), Files: r.files}) var body bytes.Buffer - if err := r.md.Convert(b.Body, &body); err != nil { + if err := r.md.Convert(b.Body, &body, parser.WithContext(pc)); err != nil { return nil, fmt.Errorf("markdown %s: %w", b.Path, err) } title := b.Title diff --git a/internal/render/templates/shortcodes.html b/internal/render/templates/shortcodes.html index 6ba5188..4dbeb5d 100644 --- a/internal/render/templates/shortcodes.html +++ b/internal/render/templates/shortcodes.html @@ -1,8 +1,18 @@ {{define "figure" -}}
-{{.alt}} -{{- if .caption}} -
{{.caption}}
+{{.Args.alt}} +{{- if .Args.caption}} +
{{.Args.caption}}
{{- end}}
{{- end}} + +{{define "gallery" -}} +{{if .Items -}} + +{{- end}} +{{- end}}