3.3 Image Pipeline (ADR)
Status: Accepted · 2026-06-14
Scope: @stnd/press, @stnd/loader/vault, app configs
Context
Images rendered by @stnd/press were processed by three overlapping passes:
cf-images.js— a global typography plugin that flattened every<img>
src to/assets/vault/{filename}and applied Cloudflare resizing.enhanceImagesinpress.parse()— an app-config pass that also applied
cfImage(only whenconfig.press.cfImagewas set).vault.processImages(press)— a post-parse()pass in the vault loader that
copied loose files to/assets/vaultand rewrote the src.
The flatten in pass 1 was correct for one flow (a file-vault site whose
images are loose files copied to /assets/vault), but it ran globally. For
the database/edge flow (Standard Garden), where images live in R2 and are
referenced by /cdn/… URLs, pass 1 silently rewrote a valid /cdn/… src to a
non-existent /assets/vault/{name} → invisible images. One module’s
assumption bled into every other app.
This is a behavioral leak, not an import leak: the strict import hierarchy
(features never import features) said nothing about a shared runtime plugin
baking a per-app assumption into everyone’s render.
Decision
Collapse the three passes into one pipeline inside @stnd/press, driven
entirely by config.press.images, with two orthogonal layers:
- Layer A · Resolution — where do the bytes live? The presence of the
data is the mode:fromandtoset →local-assets: loose files referenced by
basename are copied intotoand their src rewritten (Node, build-time).- neither set →
passthrough: the src is already final (/cdn/…, remote). - exactly one set → fatal config error, linked to
stnd.build/manual/press-images-incomplete.
- Layer B · Delivery —
cfImagewraps the resolved URL for Cloudflare
resizing; its presence switches it on. Runs once, last.
cf-images.js is deleted. vault.processImages is deleted — the vault
loader no longer touches images; its job is content. The Node-only
local-assets strategy lives in @stnd/press/images-local, dynamically
imported and gated by import.meta.env.SSR so it never reaches client or edge
bundles.
// app/modules/core/index.module.js
config: { press: { images: {
from: "vault", to: "/assets/vault", // omit both for passthrough
cfImage: { widths: [640, 960, 1280, 1920], quality: 85 },
} } }
Consequences
- No bleed: shared render code is source-agnostic. An app’s image source is
declared in its own config; a wrong assumption can no longer leak across apps. - One principle: both layers follow “presence of data = enabled.”
- Decoupling:
@stnd/pressand@stnd/loader/vaultare now independent —
use either without the other. - Self-fixing for existing content: notes re-render on read, so published
Garden notes recover without republishing. - Cost: the
local-assetsstrategy must be told where local files live
(from/to), replacing the vault loader’s implicitimageIndex. Explicit
over implicit — by design.
Alternatives considered
- Keep cf-images, add a guard (the initial hotfix) — fixes the symptom, not
the conflation; the plugin still does two jobs globally. - Resolution as a module hook (
press:image, contributed by the vault
module) — rejected: it re-introduces module→module coupling and makes image
behavior an implicit side effect of including a module. Config is explicit
and reads at a glance. - A
resolve: "vault" | "passthrough"enum — rejected: redundant with the
presence offrom/to; the data already carries the intent.