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
+45 -10
View File
@@ -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
+13 -3
View File
@@ -1,8 +1,18 @@
{{define "figure" -}}
<figure>
<img src="{{.src}}" alt="{{.alt}}">
{{- if .caption}}
<figcaption>{{.caption}}</figcaption>
<img src="{{.Args.src}}" alt="{{.Args.alt}}">
{{- if .Args.caption}}
<figcaption>{{.Args.caption}}</figcaption>
{{- end}}
</figure>
{{- end}}
{{define "gallery" -}}
{{if .Items -}}
<div class="gallery">
{{- range .Items}}
<figure><img src="{{.}}" alt=""></figure>
{{- end}}
</div>
{{- end}}
{{- end}}