Skip to main content
Four things have to agree about every block on a CMS-backed site:
  1. the Zod schema the operations engine validates an AI edit against,
  2. the panel metadata the property panel draws with,
  3. the projection that turns a CMS document into Avocado props,
  4. the merge that writes edited props back.
Written separately they disagree within a week. @avocadostudio-ai/site-sdk/lens derives all four from one table, so adding a field to a block is one line in one file.
What differs between two CMSes is how one value of a given kind is read and written, and where a language’s value is stored. The derivation above that — table to schema, table to panel metadata, projection, merge — is the same program either way, which is why a pack is small and a third CMS only has to answer six questions.

The shape

avocado/table.ts
Key the table by the CMS’s own name for a type — hero_section, not SiteHeroSection. BlockType is a free string, and keeping one name across the CMS, the manifest and a publish diff makes the adapter an identity map instead of a translation nobody can grep for. A field the table does not declare is invisible to Avocado and untouched by it. It does not reach the planner, does not appear in the panel, and survives every publish, because the merge patches the source document rather than replacing it. That is the lever for scope: declare what an editor should be able to change and leave the layout and behaviour switches out.

Wiring it up

avocado/lens.ts
createLens returns a value and registration writes to a global registry, so they are separate calls. registerLens takes the table and the primitives off the lens you just built, so the panel and the projection cannot disagree about what an image field’s two props are called. Sanity is the same with sanityPrimitives() and sanityLocale(...) from @avocadostudio-ai/site-sdk/lens/sanity. Contentful uses contentfulPrimitives() and contentfulLocale(...) from @avocadostudio-ai/site-sdk/lens/contentful, plus helpers for reading every locale at once and a publisher. It has its own page, because Contentful stores images and references as Links and publishes whole entries.
registerFieldTable(TABLE, { primitives }) is the older form, and it is two declarations that have to agree. When they did not, the panel drew image and image_alt while the projection emitted imageUrl and imageAlt — every image on the site both invisible and uneditable, with nothing erroring, because neither half is wrong on its own. It now throws on a table that declares an image when no image naming was given, so the mistake fails loudly. Use registerFieldTable for a table that has no lens — a site registering blocks without projecting a CMS through them. If you are calling createLens, call registerLens.
Both calls also narrow the editor’s catalogue to the table’s top-level types (every spec without topLevel: false), so Avocado’s built-in blocks are not offered on a site that cannot render them. Pass { narrowCatalogue: false } to keep the built-ins. A block spec has no role: to split the add-block picker into sections and building blocks, pass the object form of blockTypes to both handlers. It is declared after registerBlocks runs, so it wins — see the picker.

Reading and writing

merge takes the live CMS document as its source, not a snapshot Avocado holds. Every field the table never declared survives by construction.

Two rules that govern every write

A CMS with per-language fallback resolves a missing translation to the default language, which is correct on screen and a lie in storage. Merge a projection back wholesale and every one of those fallbacks becomes a real, fabricated translation — dozens per publish, each identical to what the page already showed, so nothing looks wrong. A codec returns a value only when it really differs from what the source holds.
Writing "" into a slot that had no value for this language is a no-op on screen and a diff in the document — so a publish that touched one field reports every page as modified.
Neither was reasoned out from the shapes. Both came from running a projection through its own inverse over a real dataset, which is why that check is part of the API:
Nothing should move. A non-empty fields is a codec that is not the inverse of itself for some value in your content — invisible in the editor, harmless in the preview, and visible as a publish wanting to rewrite documents nobody opened. Run it over real content, not fixtures.

localized: false is not decoration

It means the bare key, which is not the same as the default language.The two coincide on a CMS that localises into a suffixed sibling (title and title__i18n__fr) and diverge on one that localises into an object under the key, where the default language lives at title.de and a non-localised field lives at title with no container at all.On the second kind of CMS an image is one asset reference for every language and a list is one array. Leave them declared as localised and the projection looks inside a container that is not there: the image reads as empty, the list as having no rows. There is deliberately no implicit fallback to the bare key, because a read that fell back would pair with a write that did not — and reading one place while writing another is how a lens corrupts a document.

Field kinds

imageList rows

Every row is { image, alt }: the image’s URL or path, and its alt text. Those two names are fixed — they do not follow the pack’s imageNaming, which applies to image fields. A CMS pack adds the row’s identity under its rowIdKey (_key on Sanity, _uid on Storyblok, id on Contentful), and that key is internal. The schema admits any other key on a row, so one passes validation, but the panel draws exactly two controls, image and alt text, and nothing else on the row is editable. Use a list with itemFields instead when:
  • a row holds more than an image and its alt — a caption, a link, a credit;
  • the rows should follow the table’s image naming, as on a file-backed site whose JSON already spells a gallery row { image, image_alt };
  • a row needs a flag of its own, such as a readOnly alt.
Either way a row’s marker takes the full path — photos[4].image.

When the stored value is HTML

A site that renders its own components often stores a prop the template hands to set:html, dangerouslySetInnerHTML or v-html. That is html, not richtext:
richtext means a document. Declare a markup string as richtext and the property panel renders it literally — a person sees Free template for <span class="hidden xl:inline">creating… in the input, cannot edit it without breaking it, and writes broken markup back into the site’s source file if they try. html is stored as the string the template renders and edited as a document: the panel converts on the way in and back on the way out. The conversion is idempotent from the first pass, so opening a page and closing it leaves no diff. Tags the converter has no equivalent for are preserved, not dropped — an unknown element wrapping text keeps its tag and attributes and rides along around whatever the text becomes, and one with no children rides through whole. A converter that discarded them would corrupt the page on the first save of a neighbouring field, silently.
html fields are not inline-editable on the page by default, because the preview overlay edits an element’s text and the value here is markup wrapped around that text — a person fixing a typo in a headline would write the typo back without the spans. Set inlineEditable: true on a field you know holds plain text.
internal: true on any field marks a value the publisher needs, the planner must never see and nobody may edit — a row identity like _uid or _key. Declaring it keeps the merge able to match rows by it while keeping it out of the panel.

Sections of one entry, and values the site will not store

Two declarations cover what the publisher would otherwise refuse an hour after the edit:
  • fixed: true on a block spec is for one block per rendered section when several sections are slices of the same CMS entry, drawn by the template in a fixed order — a blog post’s hero, body and related-articles grid. The editor hides move, delete and add on it; the ops engine refuses to move, remove or duplicate it, or to move another block across it; the planner is told the layout is fixed. Its fields are edited as usual.
  • readOnly: true on a field, with an optional readOnlyReason, is for a value the site shows but will not write: asset alt text shared across entries, slugs, dates. The panel draws it disabled with the reason as help text, the ops engine refuses a change with that reason, the planner is told, and editableCoverage expects no marker for it. On an image, readOnly covers the image; alt: { readOnly, readOnlyReason } covers only its alt text, which is the common case — an alt that moves in the same edit as its image is kept, so swapping the image still works.
See Fixed blocks and read-only fields for everything each flag does.

Site-wide content

A header, footer, business details or CTA that every page carries is one block spec with shared: true, placed on every page under the same block id:
An edit on any page reaches every page holding that id, undo reverts it everywhere, and the publish review lists it once with the pages it affects. See Shared blocks.

Why a reference cannot be written

A CMS stores an internal link as a pointer and renders it per-locale: the same stored value serves /faq and /fr/faq. Flattening it to an href therefore cannot round-trip — the projection never equals the source, so every publish of a page nobody edited wants to rewrite every link on it — and writing the rendered href back replaces the reference with a hard-coded URL that stops following renames, which is the one thing the reference was for. External links have no such problem, because the stored value is the href. Those stay editable, which covers the case that matters: booking and shop URLs.

Lists merge by identity

Rows are matched on the CMS’s own row id, never by position. Position fails quietly: reorder a list, or delete the second of five rows, and every row after the change merges onto the wrong source — the edit lands, the page looks plausible, and four rows have silently swapped their untouched fields. A list can also hold types the table does not describe. Those are not projected, and they keep their place through a list edit rather than being deleted.

Writing a pack for another CMS

A pack answers six questions: how an image, a file, a link, a reference, rich text and an image list are read and written; what key a row carries its identity and type under; and what an image field’s two props are called. Everything else — the walk, the locale lens, the two write rules, list identity — is already written.
The scalar kinds — text, number, boolean, enum, heading level, string list — have CMS-independent defaults. A pack that supplied them would be four copies of the same eight lines, and the eighth, the one deciding whether a value counts as changed, is the worst one to have subtly different between two CMS packs.

CMS adapters

The other half: reading pages out of your CMS and writing edits back.

Multilingual

One page per document × language, and the four rules that keep the round trip clean.

Custom blocks

registerBlock, for content that is not CMS-backed.

Coverage checks

panelCoverage grades whether the panel a field table produced is usable.