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
registerFieldTable writes to a global registry and createLens returns a value, so they are separate calls. Pass the same primitives object to both — that is what makes the panel and the projection agree about what an image field’s two props are called. Sanity is the same two calls with sanityPrimitives() and sanityLocale(...) from @avocadostudio-ai/site-sdk/lens/sanity.

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

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.

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 two traps that corrupt a dataset.

Custom blocks

registerBlock, for content that is not CMS-backed.

Coverage checks

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