- 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
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
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
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.
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 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.