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:
@@ -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]`
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
+5
-4
@@ -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
|
||||
|
||||
+17
-5
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
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")
|
||||
if !strings.Contains(got, `<div class="mine">cat.jpg</div>`) {
|
||||
|
||||
+45
-10
@@ -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
|
||||
|
||||
@@ -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}}
|
||||
|
||||
Reference in New Issue
Block a user