@avocadostudio-ai/site-sdk/lens/contentful is the Contentful counterpart of
the Storyblok and Sanity packs described in the field table.
Two integrations wrote it by hand, about 190 lines each, and arrived at the same
design. That design now ships as the pack, so an integration only has to declare its
table and wire up a read and a publish.
It needs no Contentful SDK. Reads and writes are plain fetch calls against the
REST APIs, and fetch is a parameter, so all of it can be unit-tested with no space
behind it. The rich-text converters are re-exported too (fromContentful,
toContentful), so @avocadostudio-ai/richtext does not have to be a direct
dependency.
How a page gets to Avocado and back
Setup
avocado/contentful.ts
localesFrom(await cda.locales()) returns { defaultLocale, locales } if you would
rather read the locales from the space than list them yourself.
Locales: unwrap on read, re-wrap on write
Alocale=* read stores every field in a locale container: { "en-US": …, "de-DE": … }.
That includes fields that are not localised: Contentful stores those under the
default locale and nowhere else. The lens reads a non-localised field at the bare
key, so readEntry unwraps those fields on the way in and entryPatch wraps them
again on the way out:
de-DE slot that Contentful rejects.
The pack reads through the Delivery or Preview API with locale=*, not through
the site’s own client (often GraphQL, one locale per query). A page is one locale,
but a publish writes into one entry that holds all of them. createContentfulDelivery
forces locale=* on every call.
Each entry becomes one Avocado page per locale. Read
Multilingual content before writing the adapter:
its four rules apply here unchanged.
What each field kind does
Images, and why alt text is read-only
An image is a Link to an Asset.readEntry annotates the Link from the response’s
includes with the asset’s URL and title, and the codec projects those. A changed
URL cannot be written back as a Link, because no asset exists for it yet. The codec
writes an upload sentinel instead, { __avocadoUpload: url, title }, and the
publisher turns it into a new asset (processed and published) plus a Link to it.
Localhost and AI-generated images reach the publisher as bytes in the publish
context and go through the Upload API.
Alt text is the asset’s title, and every entry that uses the asset shares it.
An alt edit on its own is therefore refused with a warning. Writing it would
change the alt text on every page that shows that picture. When the image is
replaced as well, the new alt becomes the title of the new asset, since that asset
belongs to no other entry yet.
Say so in the field table too, so the panel shows the alt disabled with the reason
instead of offering an edit the publish will refuse. The image stays swappable:
References
A reference is a Link to an Entry, projected as the target’s slug. Unlike the Storyblok and Sanity packs, this one writes references, because on Contentful a person can type something that identifies the target: its slug. A changed slug becomes a lookup sentinel,{ __avocadoLookup: slug, contentType, locale }. At
publish, the publisher looks up the entry that has that slug and writes a Link to
it. If no entry has the slug, or the entry it finds has never been published, the
whole publish is refused before anything is written. Pass lookup to
publishEntries if your targets are identified some other way.
Rich text
The body is converted withfromContentful / toContentful. “Unchanged” is
decided against the stored value’s own canonical round trip, not against its
bytes. toContentful(fromContentful(x)) is not x: the converter drops empty text
nodes and adds data: {} wherever an authoring tool left it out. If an edit were
compared with the raw stored value, every body would read as changed, and a publish
that fixed one typo would rewrite every rich-text field in the space.
Embedded entries and assets survive as opaque nodes, so editing the prose around
an embedded entry does not delete it.
Rendering the preview: overlay the draft on your own data
The lens above is what the orchestrator reads pages from (getPages()) and what a
publish writes through. It does not have to be what your templates render from.
The default pattern leaves the site’s own data layer in place. The public route
fetches the page exactly as it always has, and on an editor render it overlays the
draft onto that object, only at the paths Avocado edits:
fetchEditorPage(slug, session, siteId) once
resolveEditorContext() says the request is an editor render.
Outside the editor there is no draft, so applyDraftBlocks returns the object
it was given, the same reference. The overlay never mutates the site’s object.
It copies only the containers on the path it writes, so content.links, which the
rich-text renderer resolves embedded entries against, is the same object your query
returned. A rule with to shapes the value on the way in, and it receives what the
site held at that path, so you can replace one key of an image and keep its other
fields:
- A separate preview route that re-implements each page’s composition from Avocado props. It drifts from the public route the first time someone changes one and not the other, and nothing checks that the two stay in step.
- Rewriting the templates to read Avocado’s projected props (
book.coverImageUrlforbook.coverImage.url). This keeps one render path, but every production read then goes through the lens, and the site’s own data layer becomes dead code. On Astro it remains an option, reading pages fromgetPages(). Choose it only when the site has no data layer worth keeping.
new URL(url), a blur placeholder from
the Contentful Images API). Render one image the editor produced (an Unsplash URL,
a generated localhost URL) before calling the preview done.
Pages built from sections of one entry
A blog post is one entry, but the template renders it as several sections: a hero, the body, a grid of related posts. Map one block per rendered section, even when the sections share an entry. Mapping the whole entry to one block means a click anywhere on the post selects all of it, and the panel shows every field. The table is keyed by section, andcontentTypeOf tells the locale lens which
content type’s schema each section should read:
Publishing
avocado/publish.ts
unsupported rather than logging them. A skip that
only reaches the site’s server log is a publish the user was told succeeded.
entryPatch contains only the locale slots that changed. publishEntries then
fetches the live entry through the Management API, applies the patch on top of
it, and sends an update only if something really differs. That way, an edit made in
Contentful after Avocado read the page survives the publish. The English and German
pages of one entry are combined into one write, so they cannot overwrite each other.
An entry that already has unpublished changes
A Contentful publish applies to a whole entry, not to one field.PUT /entries/:id/published
makes the entry’s entire current draft live. If someone has been editing that entry
in the web app, their unfinished work goes to production along with Avocado’s edit.
onUnpublishedChanges sets what happens:
An entry that has never been published counts as having unpublished changes, since
all of its content is somebody’s draft. Archived entries are always refused.
Developing without credentials: a read-only fixture
contentfulExportSource reads the JSON written by contentful space export (or the
export.json a Contentful starter ships). It has the same entries / contentTypes
/ locales interface as the Delivery API, and no write path. The entries in an
export use the locale=* shape, so the adapter code does not change:
editableCoverage and a
clean roundTrip before any space existed. Because the fixture is read-only, a
publish against it fails, so it can never look like it worked.
Seeding a space
- Publish linked entries first, and add the links in a second pass. An entry cannot be published while it links to entries that are unpublished. Posts that link to each other as related posts have to be created and published first, and linked afterwards.
- Check that the space has the content model before you seed it. Some Contentful templates ship without one, because the starter’s sign-up flow creates it. The survey step should check that every content type the site queries exists.
- Set embedded-entry size limits on every rich-text field (see above).
Before the first render in the editor
Three Contentful-specific issues only show up in the browser, inside the editor frame:- Contentful Live Preview SDK.
ContentfulLivePreviewProviderthrows “The current origin is not supported” when anything other thanapp.contentful.comframes it. SettingenableInspectorMode={false}does not skip that check. PasstargetOrigin={[editorOrigin]}on editor renders. - Frame headers. Templates often send
X-Frame-Options: SAMEORIGINandframe-ancestors 'self' https://app.contentful.com. Add the editor origin. - Draft mode. Contentful Live Preview and Avocado both use Next’s
__prerender_bypasscookie. Key editor rewrites oneditor_draft_session.
What is not covered
- Lists of references (“related articles”). A Links-many field is not written by this pack; leave it out of the table.
- New entries from the editor. Adding a page in Avocado does not create an entry.
- Alt text on its own. It is the shared asset title, and edits to it are refused (see above).
What to read next
The field table
The table, the two write rules, and
roundTrip.Multilingual
One page per entry × locale, and the four rules that keep the round trip clean.
CMS adapters
Reading pages out of a CMS and the publish contract.
Coverage checks
editableCoverage and panelCoverage, for the renderers and the panel.