add the gallery shortcode and the seam it needed

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.
This commit is contained in:
Claude Opus 5
2026-07-30 10:39:37 +06:00
committed by bdeshi
parent 6ed3f62765
commit f196e3420d
8 changed files with 199 additions and 29 deletions
+8 -4
View File
@@ -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. 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 `figure` and `gallery` exist. `{{< gallery >}}` takes no arguments: it lists the pictures sitting beside the
context the parser does not carry yet. File inclusion will resolve relative to the including bundle and may bundle, in filename order, which is why the sparse numeric-prefix convention orders a set without putting
not escape the site root. Transclusion of another bundle's body is Arc 4 and needs a cycle guard on the numbers in URLs (ADR-0016). A subdirectory is not part of the gallery, and neither is a file the browser
first attempt. 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]` ## Images `[spec]`
+5
View File
@@ -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 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). 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 ## The gate
| Stage of growth | What a feature looks like | Trigger to advance | | Stage of growth | What a feature looks like | Trigger to advance |
+5 -4
View File
@@ -1,6 +1,6 @@
# State # 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. If this file disagrees with the code, the code is right and this file is a bug.
## Inventory ## 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/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/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/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/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/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 | | `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/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 | | `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 | | `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 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. 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 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 | | 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) | | `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 | | 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 ## Open questions
+17 -5
View File
@@ -76,15 +76,27 @@ block; site-supplied strings wait for the settings cascade (`ideas/deferred-deci
## Shortcode fragments ## Shortcode fragments
`templates/shortcodes.html` holds one named template per shortcode, and that is where a shortcode's markup `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 | | 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 Authored and engine-supplied data are kept apart so a `src` argument can never be confused with a `src` the
author's text out of the markup. A call whose template is missing renders nothing and logs; it never fails engine found. Arguments are escaped by `html/template` like any other data, in whichever context the
the page. 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 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 the embedded one, so redefining `figure` replaces it and any fragment left alone is inherited. Argument
+46 -2
View File
@@ -1,7 +1,11 @@
package shortcodes package shortcodes
import ( import (
"io/fs"
"log/slog" "log/slog"
"path"
"slices"
"sort"
"strings" "strings"
"github.com/yuin/goldmark" "github.com/yuin/goldmark"
@@ -50,6 +54,8 @@ type node struct {
ast.BaseBlock ast.BaseBlock
name string name string
args map[string]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 } 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 return nil, parser.NoChildren
} }
reader.Advance(seg.Len() - 1) 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. // 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 return ast.WalkContinue, nil
} }
call := n.(*node) 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 { if err != nil {
slog.Error("skipping shortcode", "name", call.name, "err", err) slog.Error("skipping shortcode", "name", call.name, "err", err)
return ast.WalkContinue, nil return ast.WalkContinue, nil
+60 -1
View File
@@ -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, "<div class=\"gallery\">") {
t.Errorf("an empty gallery should render nothing at all:\n%s", got)
}
}
func TestASiteRedefinesOneFragment(t *testing.T) { func TestASiteRedefinesOneFragment(t *testing.T) {
site := fstest.MapFS{ site := fstest.MapFS{
"templates/shortcodes.html": {Data: []byte(`{{define "figure"}}<div class="mine">{{.src}}</div>{{end}}`)}, "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") got := body(t, wired(t, site), "{{< figure src=\"cat.jpg\" >}}\n")
if !strings.Contains(got, `<div class="mine">cat.jpg</div>`) { if !strings.Contains(got, `<div class="mine">cat.jpg</div>`) {
+45 -10
View File
@@ -11,10 +11,12 @@ import (
"fmt" "fmt"
"html/template" "html/template"
"io/fs" "io/fs"
"path"
"time" "time"
"github.com/yuin/goldmark" "github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension" "github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"khosra/internal/content" "khosra/internal/content"
) )
@@ -113,15 +115,44 @@ type Renderer struct {
partials *template.Template partials *template.Template
md goldmark.Markdown md goldmark.Markdown
style template.CSS 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 // Partial renders a named fragment. A feature under internal/ext is handed one of these at wiring time,
// handed one of these at wiring time, because markup belongs to the theme and a feature must not write any // because markup belongs to the theme and a feature must not write any (ADR-0036).
// (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 // Features read it from the parser context with OriginFrom. It carries the site's fs.FS rather than a
// the reason to widen this, not a reason to have made it `any` in advance. // directory name alone, because every read goes through the rooted filesystem and never a joined path
type Partial func(name string, args map[string]string) ([]byte, error) // (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. // 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 { if err != nil {
return nil, err 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, // 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 // 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 // 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 // Partial renders one named fragment. A missing template is an error the caller degrades on, never a
// failed request (extensions.md rule 5). // 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 { if r.partials.Lookup(name) == nil {
return nil, fmt.Errorf("no template named %q", name) return nil, fmt.Errorf("no template named %q", name)
} }
var out bytes.Buffer 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 nil, fmt.Errorf("partial %s: %w", name, err)
} }
return out.Bytes(), nil 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 // exists in; both feed canonical and hreflang, which a theme must not construct itself. seq is the series
// the bundle sits in, or nil. // the bundle sits in, or nil.
func (r *Renderer) Bundle(b content.Bundle, served string, variants []string, seq *content.Sequence) ([]byte, error) { 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 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) return nil, fmt.Errorf("markdown %s: %w", b.Path, err)
} }
title := b.Title title := b.Title
+13 -3
View File
@@ -1,8 +1,18 @@
{{define "figure" -}} {{define "figure" -}}
<figure> <figure>
<img src="{{.src}}" alt="{{.alt}}"> <img src="{{.Args.src}}" alt="{{.Args.alt}}">
{{- if .caption}} {{- if .Args.caption}}
<figcaption>{{.caption}}</figcaption> <figcaption>{{.Args.caption}}</figcaption>
{{- end}} {{- end}}
</figure> </figure>
{{- end}} {{- end}}
{{define "gallery" -}}
{{if .Items -}}
<div class="gallery">
{{- range .Items}}
<figure><img src="{{.}}" alt=""></figure>
{{- end}}
</div>
{{- end}}
{{- end}}