- the Zod schema the operations engine validates an AI edit against,
- the panel metadata the property panel draws with,
- the projection that turns a CMS document into Avocado props,
- the merge that writes edited props back.
@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
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.
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
Unchanged means untouched
Unchanged means untouched
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.
Empty means absent
Empty means absent
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.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
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
readOnlyalt.
photos[4].image.
When the stored value is HTML
A site that renders its own components often stores a prop the template hands toset: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: trueon 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: trueon a field, with an optionalreadOnlyReason, 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, andeditableCoverageexpects no marker for it. On animage,readOnlycovers 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.
Site-wide content
A header, footer, business details or CTA that every page carries is one block spec withshared: true, placed on every page under the same block id:
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.What to read next
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.