generate sized derivatives ahead of the request

A pass over the content at startup writes three widths per picture into a cache
outside the site root, named by the source's content hash and the width (ADR-0042).
Idempotent by construction: a rerun stats and skips, an edited picture takes a new
name, and nothing stale can be served under an old one. Restarting the evidence
site made 0 derivatives the second time, as it should.

Ahead of the request rather than during it, because resampling is felt and there is
no page cache yet to hide it. Outside the site root, because the engine reads that
directory and must not leave generated files in somebody's content git — a lost
cache costs one startup pass and no correctness.

Markup now carries the original as src, the derivatives as srcset closed by the
original at its own width, and width/height from the original — which retires most
of the latent row about the output floor; only a gallery's alt is still empty, and
a filename cannot supply that.

Two things the work itself decided:

`Fragment.Items` became `Fragment.Pictures`, ADR-0037's own revisit trigger. Items
had one consumer, so widening it beat adding a second list beside it.

"A browser can show it" and "we can resample it" are different questions, and
conflating them nearly deleted content: an SVG has no decoder here, so a single
predicate would have dropped SVGs from galleries silently. Undecodable and
unsupported pictures are now rendered as they are, without a size or a srcset.
This commit is contained in:
Claude Opus 5
2026-07-31 03:57:52 +06:00
committed by bdeshi
parent 98d0e52936
commit 282093fb55
18 changed files with 502 additions and 59 deletions
+215
View File
@@ -0,0 +1,215 @@
package shortcodes
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"fmt"
"image"
_ "image/gif"
"image/jpeg"
"image/png"
"io/fs"
"log/slog"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"golang.org/x/image/draw"
_ "golang.org/x/image/webp"
"khosra/internal/content"
"khosra/internal/render"
)
// widths are the derivative sizes offered to a browser. Three, spanning phone to desktop: enough for srcset
// to matter and few enough that the startup pass stays quick. They become a setting when someone wants a
// different set, not before.
var widths = []int{480, 960, 1440}
// Derive writes every missing derivative for the pictures under content/, returning how many it made.
//
// Ahead of the request, never during one: resampling takes long enough to be felt, and there is no page cache
// to hide it (ADR-0042). Idempotent, because a derivative is named after its source's content — so a rerun
// stats and skips, and an edited picture simply has a different name. Originals are only ever read.
func Derive(siteFS fs.FS, cacheDir string) (int, error) {
if siteFS == nil || cacheDir == "" {
return 0, nil
}
if err := os.MkdirAll(cacheDir, 0o755); err != nil {
return 0, fmt.Errorf("derivative cache %s: %w", cacheDir, err)
}
made := 0
err := fs.WalkDir(siteFS, "content", func(p string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() || !derivable(p) {
return err
}
data, err := fs.ReadFile(siteFS, p)
if err != nil {
slog.Error("skipping unreadable picture", "path", p, "err", err)
return nil
}
n, err := derive(data, p, cacheDir)
if err != nil {
// One unreadable picture must not stop a site from starting (ADR-0029).
slog.Error("skipping picture", "path", p, "err", err)
return nil
}
made += n
return nil
})
if err != nil {
return made, fmt.Errorf("walk for pictures: %w", err)
}
return made, nil
}
// derive writes the derivatives one picture is missing.
func derive(data []byte, name, cacheDir string) (int, error) {
src, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return 0, err
}
made := 0
for _, w := range widths {
if src.Bounds().Dx() <= w {
// Never upscale: a bigger file that looks worse is not a derivative worth having.
continue
}
file := filepath.Join(cacheDir, derivedName(data, name, w))
if _, err := os.Stat(file); err == nil {
continue
}
if err := write(file, scale(src, w), name); err != nil {
return made, err
}
made++
}
return made, nil
}
// scale resamples to a target width, keeping the aspect ratio.
//
// CatmullRom because the alternative in the standard library is nearest neighbour, which is visibly wrong on
// photographs — the reason ADR-0040 accepted a dependency at all.
func scale(src image.Image, width int) image.Image {
b := src.Bounds()
height := b.Dy() * width / b.Dx()
out := image.NewRGBA(image.Rect(0, 0, width, height))
draw.CatmullRom.Scale(out, out.Bounds(), src, b, draw.Over, nil)
return out
}
// write encodes an image beside its siblings in the cache, atomically.
//
// Through a temporary file and a rename, so a derivative is either absent or complete: a half-written one
// would be served as a broken image and, being content-named, never regenerated.
func write(file string, img image.Image, source string) error {
tmp, err := os.CreateTemp(filepath.Dir(file), ".khosra-*")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
if err := encode(tmp, img, source); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmp.Name(), file)
}
// encode writes PNG for sources that may carry transparency and JPEG for the rest, which is also what
// derivedName spells in the extension.
func encode(w *os.File, img image.Image, source string) error {
if lossless(source) {
return png.Encode(w, img)
}
return jpeg.Encode(w, img, &jpeg.Options{Quality: 82})
}
// Picture describes one image for a fragment: where to fetch it, what a browser may choose instead, and the
// intrinsic size, so a page reserves the right box before any bytes arrive.
//
// Src stays the original: a derivative is an optimisation, and a browser that ignores srcset still gets the
// picture the author put there (ADR-0042).
func picture(origin render.Origin, file string) (render.Picture, bool) {
if origin.Files == nil || !showable(file) {
return render.Picture{}, false
}
// The author's file is the picture, whatever the engine can make of it. An SVG has no decoder here and an
// AVIF has none anywhere, so both are rendered as they are, without a size or a srcset — dropping them
// would delete content because the engine cannot optimise it.
p := render.Picture{Src: file}
if !derivable(file) {
return p, true
}
name := path.Join(origin.Dir, file)
data, err := fs.ReadFile(origin.Files, name)
if err != nil {
slog.Error("picture unreadable", "path", name, "err", err)
return p, true
}
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil {
slog.Warn("picture kept but not sized: cannot decode", "path", name, "err", err)
return p, true
}
p.Width, p.Height = cfg.Width, cfg.Height
var sources []string
for _, w := range widths {
if cfg.Width <= w {
continue
}
sources = append(sources, content.DerivedURL(derivedName(data, name, w))+" "+strconv.Itoa(w)+"w")
}
if len(sources) > 0 {
// The original closes the set at its own width, so a wide viewport still has the best file to pick.
sources = append(sources, file+" "+strconv.Itoa(cfg.Width)+"w")
p.Srcset = strings.Join(sources, ", ")
}
return p, true
}
// derivedName is the cache filename for one source at one width: the source's content hash, so an edit
// changes the name and nothing stale is ever served, plus the width and the encoding's extension.
func derivedName(data []byte, source string, width int) string {
sum := sha256.Sum256(data)
ext := ".jpg"
if lossless(source) {
ext = ".png"
}
return hex.EncodeToString(sum[:8]) + "-w" + strconv.Itoa(width) + ext
}
// lossless reports whether a source may carry transparency, which decides the derivative's encoding.
func lossless(source string) bool {
switch strings.ToLower(path.Ext(source)) {
case ".png", ".gif", ".webp":
return true
}
return false
}
// showable reports whether a browser can display the file: what belongs in a gallery, whether or not this
// engine can do anything clever with it.
func showable(name string) bool {
switch strings.ToLower(path.Ext(name)) {
case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg", ".avif":
return true
}
return false
}
// derivable reports whether the file can be decoded and resampled here. SVG needs no resizing and AVIF has no
// decoder available, so neither gets derivatives (ADR-0040).
func derivable(name string) bool {
switch strings.ToLower(path.Ext(name)) {
case ".jpg", ".jpeg", ".png", ".gif", ".webp":
return true
}
return false
}
+120
View File
@@ -0,0 +1,120 @@
package shortcodes
import (
"bytes"
"image"
"image/color"
"image/jpeg"
"image/png"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"testing/fstest"
)
// wide builds a real encoded picture of a given width, so the tests exercise decoding rather than a stub.
func wide(t *testing.T, width int, asPNG bool) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, width, width/2))
for x := range width {
for y := range width / 2 {
img.Set(x, y, color.RGBA{uint8(x % 256), uint8(y % 256), 128, 255})
}
}
var out bytes.Buffer
var err error
if asPNG {
err = png.Encode(&out, img)
} else {
err = jpeg.Encode(&out, img, nil)
}
if err != nil {
t.Fatal(err)
}
return out.Bytes()
}
func TestDeriveMakesEachMissingWidthAndIsIdempotent(t *testing.T) {
cache := t.TempDir()
big := wide(t, 2000, false)
fsys := fstest.MapFS{
"content/art/set/index.md": {Data: []byte("---\ntitle: Set\n---\n")},
"content/art/set/big.jpg": {Data: big},
"content/art/set/small.png": {Data: wide(t, 300, true)}, // narrower than every target
"content/art/set/notes.md": {Data: []byte("not a picture")},
}
made, err := Derive(fsys, cache)
if err != nil {
t.Fatal(err)
}
if made != len(widths) {
t.Errorf("made %d derivatives, want %d — one per width below the original's", made, len(widths))
}
// Never upscale: nothing is made for a picture already narrower than the targets.
entries, err := os.ReadDir(cache)
if err != nil {
t.Fatal(err)
}
if len(entries) != len(widths) {
t.Errorf("cache holds %d files, want %d", len(entries), len(widths))
}
// Each is a real image of the width it claims.
for _, e := range entries {
data, err := os.ReadFile(filepath.Join(cache, e.Name()))
if err != nil {
t.Fatal(err)
}
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil {
t.Fatalf("%s is not a decodable image: %v", e.Name(), err)
}
if !strings.Contains(e.Name(), "-w"+strconv.Itoa(cfg.Width)) {
t.Errorf("%s decodes to width %d, which its name does not claim", e.Name(), cfg.Width)
}
if cfg.Height != cfg.Width/2 {
t.Errorf("%s is %dx%d — the aspect ratio was not kept", e.Name(), cfg.Width, cfg.Height)
}
}
// A second pass writes nothing: the name is the source's content, so there is nothing new to make.
again, err := Derive(fsys, cache)
if err != nil {
t.Fatal(err)
}
if again != 0 {
t.Errorf("second pass made %d, want 0 — the pass must be idempotent (ADR-0042)", again)
}
}
func TestAnEditedPictureGetsADifferentName(t *testing.T) {
// Content-addressed, so a stale derivative can never be served under a name that now means something else.
first := derivedName(wide(t, 1000, false), "content/a/x.jpg", 480)
second := derivedName(wide(t, 1200, false), "content/a/x.jpg", 480)
if first == second {
t.Error("two different pictures must not share a derivative name")
}
if derivedName([]byte("same"), "a.png", 480) == derivedName([]byte("same"), "a.png", 960) {
t.Error("two widths of one picture must not share a name either")
}
if !strings.HasSuffix(derivedName([]byte("x"), "a.png", 480), ".png") {
t.Error("a source that may carry transparency keeps a lossless derivative")
}
}
func TestOriginalsAreNeverTouched(t *testing.T) {
cache := t.TempDir()
big := wide(t, 2000, false)
original := append([]byte(nil), big...)
fsys := fstest.MapFS{"content/art/set/big.jpg": {Data: big}}
if _, err := Derive(fsys, cache); err != nil {
t.Fatal(err)
}
after, err := fsys.ReadFile("content/art/set/big.jpg")
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(original, after) {
t.Error("the author's own file was modified")
}
}
+21 -17
View File
@@ -6,7 +6,6 @@ import (
"io/fs"
"log/slog"
"path"
"slices"
"sort"
"strings"
@@ -141,8 +140,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
// pictures are what the feature gathered at parse time, when it still knew which bundle this is.
pictures []render.Picture
// content is output the feature produced itself, written instead of a theme fragment. An included file
// is content, not decoration, so it has no template (ADR-0038).
content []byte
@@ -172,7 +171,13 @@ func (blocks) Open(parent ast.Node, reader text.Reader, pc parser.Context) (ast.
case "gallery":
// Reading the filesystem happens here, where the parse context says which bundle this is; the
// renderer never gets one, so anything gathered has to be gathered now.
n.items = images(pc)
n.pictures = gallery(pc)
case "figure":
if origin, ok := render.OriginFrom(pc); ok {
if p, isPicture := picture(origin, args["src"]); isPicture {
n.pictures = []render.Picture{p}
}
}
case "include":
// Filled in by the transformer, which runs once this parse is complete.
n.isContent = true
@@ -180,16 +185,12 @@ func (blocks) Open(parent ast.Node, reader text.Reader, pc parser.Context) (ast.
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.
// gallery 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 {
func gallery(pc parser.Context) []render.Picture {
origin, ok := render.OriginFrom(pc)
if !ok || origin.Files == nil {
return nil
@@ -199,16 +200,19 @@ func images(pc parser.Context) []string {
slog.Error("gallery cannot read its bundle directory", "dir", origin.Dir, "err", err)
return nil
}
var found []string
var names []string
for _, e := range entries {
if e.IsDir() {
continue
if !e.IsDir() && showable(e.Name()) {
names = append(names, e.Name())
}
if slices.Contains(pictures, strings.ToLower(path.Ext(e.Name()))) {
found = append(found, e.Name())
}
sort.Strings(names)
var found []render.Picture
for _, name := range names {
if p, ok := picture(origin, name); ok {
found = append(found, p)
}
}
sort.Strings(found)
return found
}
@@ -247,7 +251,7 @@ func (f fragments) render(w util.BufWriter, source []byte, n ast.Node, entering
}
return ast.WalkContinue, nil
}
out, err := f.partial(call.name, render.Fragment{Args: call.args, Items: call.items})
out, err := f.partial(call.name, render.Fragment{Args: call.args, Pictures: call.pictures})
if err != nil {
slog.Error("skipping shortcode", "name", call.name, "err", err)
return ast.WalkContinue, nil