> ## Documentation Index
> Fetch the complete documentation index at: https://docs.avocadostudio.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# The field table

> One declaration of what a CMS-backed site lets Avocado edit, and the four things derived from it.

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.

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

## The shape

```ts avocado/table.ts theme={null}
import type { FieldTable } from "@avocadostudio-ai/site-sdk/lens"

export const TABLE: FieldTable = {
  hero_section: {
    displayName: "Hero",
    topLevel: true,
    category: "content",
    fields: {
      title: { kind: "text" },
      body: { kind: "richtext" },
      background_image: { kind: "image", label: "Background" },
      cta_link: { kind: "link" },
      alignment: { kind: "enum", options: ["left", "center"] },
      anchor: { kind: "text", localized: false },
      buttons: { kind: "list", of: ["button"] },
    },
  },
  button: {
    displayName: "Button",
    topLevel: false,
    fields: {
      label: { kind: "text" },
      variant: { kind: "enum", options: ["solid", "outline"] },
    },
  },
}
```

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

```ts avocado/lens.ts theme={null}
import { createLens, registerFieldTable } from "@avocadostudio-ai/site-sdk/lens"
import { storyblokPrimitives, storyblokLocale } from "@avocadostudio-ai/site-sdk/lens/storyblok"
import { TABLE } from "./table"

const primitives = storyblokPrimitives()

registerFieldTable(TABLE, { primitives })

export const lens = createLens({
  table: TABLE,
  locale: storyblokLocale("de", ["de", "en", "fr"]),
  primitives,
})
```

`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

```ts theme={null}
const props = lens.project(story.content, "hero_section", "fr")
// → { title: "Bienvenue", background_image: "https://…", background_image_alt: "…" }

const { doc, changed, warnings } = lens.merge(story.content, editedProps, "hero_section", "fr")
```

`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

<AccordionGroup>
  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>
</AccordionGroup>

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:

```ts theme={null}
const { clean, fields, warnings } = lens.roundTrip(story.content, "hero_section", "fr")
```

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

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

## Field kinds

| Kind                                | Projects to               | Notes                                                        |
| ----------------------------------- | ------------------------- | ------------------------------------------------------------ |
| `text`                              | a string                  | `multiline` changes the control, not the storage             |
| `richtext`                          | a document                | edited as a document, never flattened to a string            |
| `html`                              | a string of markup        | for a prop the template renders with `set:html` — see below  |
| `image`                             | a URL **and** an alt prop | the two names come from the pack's `imageNaming`             |
| `file`                              | a URL                     | a menu PDF; no alt                                           |
| `link`                              | an href                   | writable when the CMS stores a URL                           |
| `reference`                         | an href                   | **read-only** — see below                                    |
| `enum`                              | a string                  | a value outside `options` is refused with a reason           |
| `boolean`, `number`, `headingLevel` | themselves                |                                                              |
| `stringList`                        | `string[]`                | bullets                                                      |
| `imageList`                         | `{ image, alt }[]`        | a logo strip, a gallery                                      |
| `list`                              | rows                      | `of: [...]` for CMS-typed rows, `itemFields` for inline ones |

### 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`:

```ts theme={null}
title: { kind: 'html', label: 'Title' }
```

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

<Note>
  `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.
</Note>

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

```ts theme={null}
import type { Primitives } from "@avocadostudio-ai/site-sdk/lens"
import { suffixNaming } from "@avocadostudio-ai/site-sdk/lens"

export const myCmsPrimitives = (): Primitives => ({
  imageNaming: suffixNaming("", "_alt"),
  rowIdKey: "_id",
  rowTypeKey: "_type",
  newRowId: () => crypto.randomUUID(),
  codecs: { /* image, file, link, reference, richtext, imageList */ },
})
```

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.

## What to read next

<CardGroup cols={2}>
  <Card title="CMS adapters" icon="database" href="/integration/cms-adapters">
    The other half: reading pages out of your CMS and writing edits back.
  </Card>

  <Card title="Multilingual" icon="language" href="/integration/multilingual">
    One page per document × language, and the two traps that corrupt a dataset.
  </Card>

  <Card title="Custom blocks" icon="cubes" href="/integration/custom-blocks">
    `registerBlock`, for content that is not CMS-backed.
  </Card>

  <Card title="Coverage checks" icon="clipboard-check" href="/integration/coverage">
    `panelCoverage` grades whether the panel a field table produced is usable.
  </Card>
</CardGroup>
