Accepted

ADR 0050 — Image block and media field wire shape

The wire shape for the core/image block and the media field on data_json: pick a library row, optionally override the alt text per instance

Two editor surfaces consume the media library: the core/image block inside the block editor, and the media field type on a document's data_json. Both need a stable wire shape that survives block-model migrations, per-document data round-trips, the restore engine and version history, and the headless API's OpenAPI generator.

The core/image block

The block is declared in src/shared/blocks/types.ts and carries these fields:

  • type — the literal core/image.
  • v — the block-model version, currently 1.
  • mediaId — a logical handle to media.id. Because blocks are stored as JSON there is no SQL foreign key.
  • alt — mirrored from media.alt at insert and editor-overridable thereafter.
  • caption — optional Portable Text.
  • align — optional, one of start, center, end, wide, or full.
  • href — optional link target.

An empty mediaId is the placeholder state, set right after insertion from the slash menu while the picker is open. The renderer is defensive: a missing mediaId, a deleted media row, or a 404 on the source all degrade to a figure with the alt text and no img element.

The media field

On data_json the field stores an object under the field slug:

{ "mediaId": "01H...", "alt": "Override or empty" }

A bare-string mediaId is still read for back-compat. An undefined or empty mediaId is the cleared state, and the field renderer returns undefined from its onChange so the slug is dropped from the document.

Alt-text mirroring

When a block or field selects a media row for the first time, the editor's alt input is pre-filled from media.alt. After that, edits to the per-instance alt are independent of edits to the library's alt. The per-instance value always wins on render: the library's alt text is the suggestion, the per-instance value is the source of truth. The library answers what is this image; the block answers what does it mean here.

Hydration and the OpenAPI generator

The renderer fetches media rows in a batched per-page lookup via src/worker/render/media-hydrate.ts, chunked to stay clear of the SQL bind limit. Hydrated rows are KV-cached under the surrogate key media:{id}, so a media-row update invalidates through the existing surrogate-purge step in the publish workflow.

The headless API's OpenAPI generator describes the media field as a nullable ULID string — the media id, or null when cleared — so external consumers resolve the row themselves against the media endpoint. The richer per-instance shape (the stored mediaId/alt object) is an admin-side concern, not part of the headless contract.

Consequences

  • One mental model across both surfaces: pick a row, optionally override alt per instance. No coupling between the block and the field.
  • mediaId is a stable handle. Replacing a file in place on the same row preserves every block and field reference; a re-uploaded file that lands on a new id is a different reference.
  • The wire shape is forward-compatible. Additive optional keys (focal point, art-direction sets) need no parse changes; a structural change such as a gallery array bumps the block version with a migrator.
  • A single shared picker modal, src/admin/src/components/media/MediaPicker.tsx, services both surfaces.

For how field types are stored and round-tripped on data_json, see the document data-shape decision.