Re-adopts the parked extras entry as ADR-0047: `extras/` inside a bundle is skipped
by the scanner entirely, so a `.md` in there is an asset with no identity and no URL
of its own. The engine enumerates the tree, sorts it by path, classifies by
extension, renders markdown and text, and offers everything else as bytes. One route
with two behaviours — `…/extras/{path}` selects an entry, `?raw` returns the file.
Almost everything it needed already existed, which is the sign the model was right:
the scanner had a directory exclusion, `Assets()` knew which bundles own a
directory, and `Lookup` already decided visibility — so a draft hides its extras
with no new check. A test proves that, including `?raw`.
Two deviations from the parked shape, both because the shape was written before the
code. The directory name is fixed rather than a cascade key, since nothing reads a
section-level setting yet. And an entry is resolved against the *enumeration* rather
than the filesystem: not being in the listing is a stronger answer than os.Root
refusing a path, and cheaper.
Selecting is a link and a full page. No JavaScript is involved, and a
sidebar-and-pane layout is the theme's business — which is the layer rule applied
before writing the feature rather than after.
Three size warnings fired as a result and were fixed by splitting at seams, not by
sharding: render.go gave up its type declarations to view.go, which is the theme
contract in Go and nothing else; serve() split into a dispatcher and serveBundle;
resolve() gave up its language-prefix step to cutLang.
251 lines
8.2 KiB
Go
251 lines
8.2 KiB
Go
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"
|
|
"sync"
|
|
|
|
"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
|
|
}
|
|
if strings.Contains(p, "/"+content.ExtrasDir+"/") {
|
|
// Extras are supporting material, not published pictures: deriving sizes nothing references would
|
|
// only slow startup down (content-model.md).
|
|
return nil
|
|
}
|
|
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})
|
|
}
|
|
|
|
// inspected remembers what each picture is.
|
|
//
|
|
// Measured, not assumed: without this the same bytes were read, hashed and decoded on every request, costing
|
|
// ~102µs per picture — a twelve-picture gallery spent 1.2ms of its 1.24ms doing work it had already done
|
|
// (BenchmarkGalleryPage in internal/web). conventions.md allows a cache in the render path once a benchmark
|
|
// asks for one, and this is the smallest thing the benchmark asks for.
|
|
//
|
|
// Keyed by name, size and modification time, so an edited picture is inspected again rather than remembered
|
|
// wrongly. A map behind a mutex because requests are concurrent and there is one entry per picture on the site.
|
|
var (
|
|
inspectedMu sync.Mutex
|
|
inspected = map[string]render.Picture{}
|
|
)
|
|
|
|
// 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)
|
|
info, err := fs.Stat(origin.Files, name)
|
|
if err != nil {
|
|
slog.Error("picture unreadable", "path", name, "err", err)
|
|
return p, true
|
|
}
|
|
key := fmt.Sprintf("%s\x00%d\x00%d", name, info.Size(), info.ModTime().UnixNano())
|
|
inspectedMu.Lock()
|
|
remembered, known := inspected[key]
|
|
inspectedMu.Unlock()
|
|
if known {
|
|
return remembered, true
|
|
}
|
|
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, ", ")
|
|
}
|
|
inspectedMu.Lock()
|
|
inspected[key] = p
|
|
inspectedMu.Unlock()
|
|
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
|
|
}
|