stnd.buildSTANDARD MANUAL2026-07-15

3.3 Image Pipeline (ADR)

Why image handling moved into a config-driven, two-layer pipeline owned by @stnd/press — and why no module is allowed to bleed image assumptions into other apps.

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:

  1. cf-images.js — a global typography plugin that flattened every <img>
    src to /assets/vault/{filename} and applied Cloudflare resizing.
  2. enhanceImages in press.parse() — an app-config pass that also applied
    cfImage (only when config.press.cfImage was set).
  3. vault.processImages(press) — a post-parse() pass in the vault loader that
    copied loose files to /assets/vault and 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
    • from and to set → local-assets: loose files referenced by
      basename are copied into to and 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 — cfImage wraps 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/press and @stnd/loader/vault are 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-assets strategy must be told where local files live
    (from/to), replacing the vault loader’s implicit imageIndex. 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.
  • resolve: "vault" | "passthrough" enum — rejected: redundant with the
    presence of from/to; the data already carries the intent.

See also