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:
@@ -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>`) {
|
||||
|
||||
Reference in New Issue
Block a user