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.