Skip to content

v0.57.0: media, end to end

Choose a tag to compare

@glw907 glw907 released this 18 Jun 02:32

Images become first-class. An editor can paste, drag, or insert an image straight into a post, and
cairn stores it, names it by its content, commits it with the entry, and serves it from the site's
own R2 bucket. This is the whole media stack landing together: the foundation that models a stored
image, the infrastructure that ingests and delivers the bytes, and the insert UI that puts it in an
editor's hands. It is additive to the public API, but it needs per-site wiring, so it is a minor.

The foundation models an image as a logical reference, not a path. Content commits a media: token
keyed to the first 16 hex characters of the bytes' sha256, so the same image resolves no matter where
it is stored or what it is named, and identical bytes always land at one key. A small git-committed
manifest (media.json) carries the human layer the bytes cannot: the display name, the alt text, the
original filename, and the pixel facts. A render-time resolver reads that manifest and rewrites each
media: token to its delivery URL, optionally through a Cloudflare Images transform URL when a site
turns transforms on. The adapter's AssetConfig grew to declare the R2 bucket binding, the URL form,
the upload limits, and the named variants.

The infrastructure ingests and serves the bytes. A locked-down /media delivery route, built from
createMediaRoute, streams content-addressed bytes from R2: it validates the hash and extension
before any read, derives the object key from the validated values alone, carries the load-bearing
security headers (nosniff, inline disposition, a default-src 'none'; sandbox CSP, a one-year
immutable cache), and forwards If-None-Match and Range for 304 and 206 responses. An admin
uploadAction takes the editor's bytes, hashes them, dedups against the manifest with a put-first
head check, and rejects a hash collision with a 409. A client ingest helper normalizes a HEIC to a
web format before upload. A save merges the editor's optimistic records into media.json at commit
time, and the edit load hands the admin preview a lean mediaTargets projection so an in-session
image renders before it is committed.

The insert UI puts it in an editor's hands. Three gestures start an insert: paste from the clipboard,
drag a file onto the editor, or the toolbar's Insert image button. A paste or drag opens an at-caret
popover on the capture card with the dropped file; the button opens a chooser with upload first and a
combobox picker below it for reusing an image already on the site. The capture card pre-fills the name
from the filename and never blocks on alt text, so an editor can insert now and describe later. The
inserted reference renders in the editor as an atomic chip (thumbnail, name, and a needs-alt marker),
and an upload still in flight shows a widget-only placeholder with a determinate progress bar that
writes no document text until it resolves. A non-blocking needs-alt notice on the edit page counts the
images still waiting for a description and jumps to each one, never blocking a save or a Publish. The
edit-page preview renders inserted images through the same resolver the live site uses.

Figures land in the same release. An inline image can carry a caption and a placement through a
cairn-reserved :::figure directive that wraps the image as a child node. The caption is the
directive's body text, rendered to a real <figcaption>, and the placement is a closed role set
(center, wide, full, plus the bare measure default) carried as a class on the <figure>. A
persistent editor control wraps a bare image, edits an existing figure's caption and role, or unwraps
it, writing the markdown source the author can read and hand-edit, and the source chip shows the
figure's role so the decoration agrees with the source. figure and figcaption join the base
sanitize floor, so a captioned figure survives on every site, and figure is a reserved directive
name the registry refuses to let a site component shadow. cairn ships default .cairn-place-* CSS in
the showcase reference, and a site restyles those classes to own the placement pixels. A guide section
covers it in add an image.

Hero images land in the same release. A Post or Page carries a lead image in frontmatter as a nested
image: { src, alt, caption } object, where src is a media: reference, alt is the screen-reader
description, and caption is an optional line the template may show. image is a new built-in field
type declared through defineFields like text or date. The editor renders it in the details panel
as a one-row resting field that opens the same picker and capture flow the body insert uses. Alt stays
debt, and the needs-alt notice now counts a hero with an empty alt alongside the body images. One
image serves two jobs: the delivery read path resolves the frontmatter reference into a derived
heroImage projection the template lays out, and the SEO head reads the same resolved image as the
og:image and twitter:image. The on-disk media: token stays canonical, since resolution is a
separate projection that is never written back. resolveImageUrl now rejects a non-http(s) result, so
an unresolved media: token degrades to no social image rather than shipping a broken tag. The site
template owns the hero layout: cairn ships the resolved data and the social-card wiring, not a hero
render step. A required image field is enforced on the presence of its src, never on its alt.

The Media Library lands in the same release. A first-class admin screen at /admin/media, a peer of
Posts and Pages, browses every committed asset, shows where each one is used, edits its name and
default alt, and deletes it safely. The resting surface is a contact-sheet grid with a list-density
toggle; a non-modal detail slide-over carries the preview, the alt editor, the grouped where-used
list, and the actions. The Library computes where-used by content hash across main and every open
edit branch, so a not-yet-published upload still shows and a renamed slug still resolves. The content
manifest gained an additive mediaRefs field per entry to feed the main side of that index; an
existing manifest without it still parses and builds. Safe-delete rechecks usage server-side against
a fresh read at delete time, refuses an in-use asset (the in-use face names what would break and
requires typing the slug), commits the manifest row removal before deleting the R2 object, and fails
closed if it cannot verify usage. Rename and default-alt are a single media.json row commit with no
reference rewrite, since the resolver and route key on the hash; the default alt is the value
prefilled into the next placement, not a rewrite of alt already committed. Replace, bulk actions, and
tags are deferred.

Consumers must: bind an R2 bucket and mount the delivery route before media works. Add an
r2_buckets binding named MEDIA_BUCKET in wrangler.jsonc, and mount the delivery route at
src/routes/media/[...path]/+server.ts with createMediaRoute(runtime.resolvedAssets). Declare the
adapter's assets block naming that binding, and regenerate nothing else; media stays off until the
assets block is present. Cloudflare Images transforms stay behind the transformations: false
default, so a site serves full-size bytes until it opts in. The wiring steps are in
the upgrade guide and the
wire the delivery surface guide; the surface is documented
in the media reference and
the sveltekit reference.

Recommended, not required: regenerate the content manifest (cairn-manifest) and commit it so the
Media Library's main where-used is accurate. The mediaRefs field is additive, so a site builds
without it, but an un-regenerated manifest reads every published media reference as absent until it
is regenerated. Save and publish keep the field current from then on.