@bettercms-ai/mcp 0.83.0 → 0.83.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +86 -86
  2. package/SKILL.md +160 -160
  3. package/dist/index.js +38 -9
  4. package/dist/index.js.map +1 -1
  5. package/dist/kit-catalog.json +1509 -1509
  6. package/dist/skills/bettercms/SKILL.md +206 -206
  7. package/dist/skills/bettercms/references/accessibility.md +171 -171
  8. package/dist/skills/bettercms/references/animation.md +296 -296
  9. package/dist/skills/bettercms/references/astro.md +148 -148
  10. package/dist/skills/bettercms/references/bindings.md +366 -366
  11. package/dist/skills/bettercms/references/componentize.md +185 -185
  12. package/dist/skills/bettercms/references/components.md +415 -415
  13. package/dist/skills/bettercms/references/content-experiments.md +62 -62
  14. package/dist/skills/bettercms/references/content-modeling.md +382 -382
  15. package/dist/skills/bettercms/references/detection.md +465 -465
  16. package/dist/skills/bettercms/references/dock-input.md +121 -121
  17. package/dist/skills/bettercms/references/editor-chrome.md +92 -92
  18. package/dist/skills/bettercms/references/external-content.md +78 -78
  19. package/dist/skills/bettercms/references/finish-site.md +377 -377
  20. package/dist/skills/bettercms/references/forms-and-interactions.md +245 -245
  21. package/dist/skills/bettercms/references/frameworks.md +159 -159
  22. package/dist/skills/bettercms/references/getting-started.md +156 -156
  23. package/dist/skills/bettercms/references/integrations.md +364 -364
  24. package/dist/skills/bettercms/references/kit-catalog.md +3259 -3259
  25. package/dist/skills/bettercms/references/kit-scenarios.md +712 -712
  26. package/dist/skills/bettercms/references/localization.md +85 -85
  27. package/dist/skills/bettercms/references/media-and-brand.md +181 -181
  28. package/dist/skills/bettercms/references/next.md +189 -189
  29. package/dist/skills/bettercms/references/other-frameworks.md +85 -85
  30. package/dist/skills/bettercms/references/portable-text.md +213 -213
  31. package/dist/skills/bettercms/references/programmatic-seo.md +98 -98
  32. package/dist/skills/bettercms/references/publishing.md +289 -289
  33. package/dist/skills/bettercms/references/seo.md +357 -357
  34. package/dist/skills/bettercms/references/shopify-headless.md +52 -52
  35. package/dist/skills/bettercms/references/site-blueprints.md +895 -895
  36. package/dist/skills/bettercms/references/site-review.md +74 -74
  37. package/dist/skills/bettercms/references/worked-examples-advanced.md +509 -509
  38. package/dist/skills/bettercms/references/worked-examples.md +885 -885
  39. package/dist/skills/bettercms/references/write-limits.md +217 -217
  40. package/package.json +1 -1
@@ -1,415 +1,415 @@
1
- ---
2
- title: "Components, props and variants"
3
- description: "The prop contract, instances, sectionType families and variants, publishing codes, rendering components in a site, preview source and component groups."
4
- ---
5
-
6
- # Components, props and section variants
7
-
8
- Doctrine (components vs collections, section anatomy, variants): §3 and §4 of
9
- `bettercms://playbook/schema`; flows: `new_component` prompt, `componentize_site` prompt. This
10
- reference is what the code does with what you write. Componentizing a derived site end to end:
11
- [componentize.md](componentize.md).
12
-
13
- ## Contents
14
-
15
- - [1. The prop contract](#1-the-prop-contract) · [2. Instances](#2-instances) ·
16
- [3. Sections, families and variants](#3-sections-families-and-variants) ·
17
- [4. Publishing](#4-publishing) · [5. Rendering components in your site](#5-rendering-components-in-your-site)
18
- - [6. Preview source](#6-preview-source) · [7. The values popover](#7-the-values-popover-dashboard) ·
19
- [8. Groups](#8-groups) · [9. Verify](#9-verify)
20
-
21
- ## 1. The prop contract
22
-
23
- A component declares `props`. Each prop is `{ key, label, type, target: { blockId, path },
24
- defaultValue? }`.
25
-
26
- - `type` is one of exactly 30: `text`, `richtext`, `image`, `url`, `boolean`, `number`, `select`, `group`, `table`, `slot`,
27
- `form`, then `date`, `color`, `file`, `link`, `icon`, `multiselect` and `button`, then `object`, `datetime`, `slug`,
28
- `email`, `phone`, `json`, `location`, `tags`, `rating`, `code`, `reference` and `multi-reference`.
29
- - The last seven store what the same-named model field stores: `date` is `YYYY-MM-DD`, `color` a
30
- hex, `file` `{ url, name?, size? }`, `link` an href string or `{ href, rel?, newTab? }`, `icon` an icon name or an image
31
- value, `multiselect` a `string[]` drawn from `config.options`. A bad `defaultValue` is a 400.
32
- - **A `button` prop is one unit.** Its value is `{ label, href, variant?, size?, arrow?, icon?,
33
- newTab? }` and its `target.path` is `props` on a `button` block. For a new component never
34
- declare a label prop plus a url prop: the two drift apart on the first edit.
35
- - **A button the site's own script handles has no href.** Its value is `{ label, action: "code",
36
- variant?, size?, arrow?, icon? }`: a `<button type="button">` that opens a dialog, accepts
37
- cookies or starts a tour. The label and the look are edited; the click stays the site's.
38
- `href`, `newTab` and `rel` beside `action: "code"` are refused, and a value with neither an
39
- href nor the action is refused as before. Setup derives these as button fields on a new
40
- import. A control of the page itself is never one: a menu or theme toggle, a carousel arrow, a
41
- dialog's close, an accordion or tab trigger, a copy button, a pagination step.
42
- - `target.blockId` + `target.path` must point at a real block and property inside the
43
- component's own `blockJson`. Why: rendering copies each prop's value to that target; a target
44
- that does not exist renders the default and the edit goes nowhere.
45
- - Every prop takes `helpText` and the component a `description` ("Hero with a heading,
46
- supporting text and two buttons."). Leave them out: the platform writes the description, and
47
- a prop's help only for a format or unit, a place the canvas cannot show, a real constraint or
48
- a fallback (most props get none). Write your own by the rules in
49
- [content-modeling.md](content-modeling.md) ("Writing help text"): one short plain sentence,
50
- under 90 characters, with no semicolon, no dash and no figure taken from the page.
51
- - A prop takes `showIf` (`{ match, rules: [{ field, op, value? }] }`): hidden until SIBLING props match. `op` is `eq`, `neq`, `in`, `isSet`, `notEmpty` or `empty`; the last three take no `value`, the first three need one. Props and model fields share all six, and a sub-field's `showIf` names its sibling sub-fields.
52
- - `object` holds ONE `{ childKey: value }` built from `config.fields` (required), like the model field `object`. Groups, tables and objects nest to 10 levels counting the prop; an 11th is refused `field_depth_exceeded`. Aim an `object` at `props` or at a key your component code reads, never at a text slot such as a heading's `props.text` (`prop_target_unrenderable`).
53
- - `datetime`, `slug`, `email`, `phone`, `json`, `location` (`{ lat, lng }`), `tags` (`string[]`), `rating` (0..`config.max`) and `code` (`{ language?, code }` or a string) hold what the same-named model field holds, and a placement's value is checked by that field's rules (`prop_value_invalid`). Like `object`, a `tags` or `code` prop never targets a text slot.
54
- - `reference` holds one CMS item's id and `multi-reference` a list of them, as the model fields do: `config.contentModelId`, or `polymorphic: true` with `allowedModelSlugs` (one of the two is required), and `min`/`max` on the list. A placement's new id must exist (`REF_MISSING`); an id already stored is never re-checked. Page reads (delivery, preview) hydrate the ids to entries at their `depth`, so component code reads the entry; on a heading or button text slot the entry's title renders. A `multi-reference` never targets a text slot.
55
- - `update_component` replaces the whole `props` array. Read with `get_component`, change, write
56
- all props back.
57
- - `select` and `multiselect` need `config.options`; `slot` holds one nested component instance,
58
- or a list of them with `config.multiple`; `form` holds one Form document.
59
- - `slot` and `form` are reference-shaped: `target.path` is `props` (the whole envelope) on an
60
- empty `component` or `form` block, and their values are `{ componentId, overrides? }` and
61
- `{ formId }`. Neither may appear inside a `group` or `table` row.
62
- - **A `slot` with `config.multiple: true` holds a list.** Its value is
63
- `[{ componentId, overrides? }, ...]` and its `target.path` is a container's child list:
64
- `props.children` on a `section` block, or `props.columns.<n>` on a `columns` block (never
65
- `props` on a `component` block). Each item is drawn there as a child component, in order.
66
- Until a placement sets the slot, the children the definition holds in that list are shown;
67
- `[]` shows none.
68
- - `config.maxItems` defaults to 12 and cannot pass 50. `config.minItems` and
69
- `config.componentIds` (which components may be listed) are checked on every write.
70
- - An item may also carry `style` (use it for `hideOn`) and `window` with `from` / `until`
71
- as UTC to the second (`2026-11-27T00:00:00Z`). A `defaultValue` must be an array and takes
72
- no `window`.
73
- - A write is a 400 whose message starts with the code: `slot_too_many`, `slot_too_few`,
74
- `slot_item_invalid`, `slot_component_not_allowed`, `slot_component_unknown` (not a
75
- component of this project), `component_cycle` (a component would end up inside itself,
76
- through any chain), `component_too_deep` (more than 10 levels), `slot_target_invalid`,
77
- `slot_not_multiple` (a list sent to a single slot). A value that is already stored is not
78
- refused when the page is saved again.
79
- - A child's copy is not click-to-edit on the canvas. Change it in the placement's
80
- `overrides.<slot key>` list with `update_page`.
81
- - Publish every listed component: a draft child renders nothing, like any draft component.
82
- - **A `form` prop stores an id, nothing more.** Modelling the questions again as props gives you
83
- two answers to "what does this form ask?", and they diverge at the first edit on either side.
84
- The save schema is `.strict()`, so posting the hydrated form object back is a 400, not a silent
85
- strip — you find out at the write, not at the render.
86
- - A draft form is a 403 at every public ingress, so it renders nothing on the live site. Publish
87
- it before pointing a component at it.
88
-
89
- ### Where a form prop actually renders
90
-
91
- The prop binds which form. What the READER does with it depends on how the component was built,
92
- and only the first row below changes the rendered fields:
93
-
94
- | Component | Preview / Output | The form comes from | Does the prop change the fields? |
95
- |---|---|---|---|
96
- | Built from blocks (a `form` block in `blockJson`) | no Output — Output needs a recorded source file | the renderer's forms map | **yes**, on the live site and the page preview |
97
- | A component that renders `<BcmsForm>` / `<BcmsBlocks forms>` | yes | a forms catalogue passed beside the blocks | yes, where the catalogue reaches it |
98
- | A codemod section (`@bettercms-ai/convert`) | yes | static markup; convert stamps `data-bcms-form` onto the existing `<form>` | no — the fields are hand-written HTML |
99
- | A page route that fetches `/forms/public/:id` itself | inert (page-kind Output never runs scripts) | its own fetch | no |
100
-
101
- ## 2. Instances
102
-
103
- A placed component is a block `{ type: "component", props: { componentId, overrides?, bind?,
104
- source? } }`.
105
-
106
- - `overrides` is keyed by prop `key`. Rendering walks the definition's props and takes an
107
- override only for a declared key. An override under any other key is ignored.
108
- - A richtext override is a Portable Text array ([portable-text.md](portable-text.md)).
109
- - Inside an instance, edit addresses are `<instanceId>__overrides.<key>`.
110
-
111
- ## 3. Sections, families and variants
112
-
113
- - A page is made of sections. A band used on more than one page, or with layout variants, is a
114
- component with a `sectionType`. A true one-off is a `section` block whose `props.children` hold
115
- its blocks.
116
- - Components that share a `sectionType` are one family: its variants.
117
- - **A section has a kind and a title, and only the kind is a name.** `sectionType` is the KIND
118
- (`Hero`, `Logo cloud`, `Pricing`, `FAQ`): what the picker lists and the outline prints under each
119
- section. It is the one thing to work out and name. The component's `name` is the layout within
120
- that kind (`Centered`). The TITLE is the section's own heading, as written, which the outline
121
- reads live from the section's content: nobody writes it, and a block `label` is only a person's
122
- rename. Why: one string doing all three made two heroes collide (`Hero`, `Hero 2`) and gave a
123
- component reused on a second page the first page's headline for a name. Never put a heading in
124
- `sectionType` or `name`, and never write a `label` that rewords a heading or repeats the kind.
125
- - **A built-in section is a starting point, not a thing you place.** `list_components` shows the
126
- locked `builtin:*` blueprints; `create_component` with `from: "builtin:hero-centered"` copies
127
- one into the project as an ordinary draft component, then edit and publish it like any other.
128
- Whatever you send (`name`, `slug`, `props`, `blockJson`, `sectionType`, `category`) wins over
129
- the copy. `list_components` `sourceKey` says which built-in a component came from. A copy
130
- never follows later changes to the built-in. An unknown id is 404 `BUILTIN_NOT_FOUND`.
131
- - **Give every component a library `category` as well.** Why: `sectionType` groups a family, the
132
- category is how the component library lists it — an uncategorised component is created fine and
133
- is then hard to find in the picker, which is where a human looks for it.
134
- - **Spell `sectionType` exactly the same on every variant**, same case, no spaces. Why: the
135
- backend (including the key-drift check) groups families case-insensitively but the dashboard
136
- picker only trims, so "Hero" and "hero" are one family to the backend and two in the picker.
137
- - **Every variant in a family declares the same prop keys with the same types.** Why: a variant
138
- swap changes only `componentId`; the overrides stay, and only keys the new variant declares
139
- are rendered. A key the new variant lacks is dropped from view; a key with a different type
140
- renders wrong.
141
- - Before swapping, compare `get_component` props of both variants. If keys differ, tell the user
142
- which values will not show.
143
- - **A card's or badge's looks are a `variant` select prop, not a family.** A card that comes as
144
- Default, Featured and Outline is ONE component with a `select` prop keyed `variant` whose options
145
- are the looks, and its markup picks classes from that value. Why: an editor switches the look in
146
- place and the content stays put, and the library holds one card, not three. Families of looks
147
- (`variantOf`, a style select on every section that places them) are for buttons. On a site
148
- built in BetterCMS a new button look is `get_component`, then `create_component` with
149
- `variantOf` naming the base. On an imported site the family is read, not written (§5).
150
- - Read `warnings` and act on them; they never block the write:
151
- - `create_component` / `update_component`: `VARIANT_PROP_KEYS_MISMATCH` (a key no sibling
152
- declares, or one siblings declare that this variant lacks) and `VARIANT_PROP_TYPE_MISMATCH`
153
- (same key, different type).
154
- - `update_page` with `blockJson` that swaps a component block: `COMPONENT_SWAP_DROPS_OVERRIDES`
155
- (overrides the new component does not render; they stay on the block, so swapping back
156
- restores them) and `COMPONENT_SWAP_PROP_TYPE_CHANGED`.
157
-
158
- Pitfalls of a component library:
159
-
160
- | Pitfall | Signal | Fix |
161
- |---|---|---|
162
- | A variant needs keys its siblings lack | `VARIANT_PROP_KEYS_MISMATCH` on every write to it | It is a different band: give it its own `sectionType` family |
163
- | Near-identical components in separate families ("Hero", "Hero 2", "Home hero") | Several families whose `get_component` props match | One family, variants by layout; every extra family is one more choice in "Add a section" |
164
- | A component nobody can insert | its id in `get_site_composition` `notInPicker` | Set its `sectionType` |
165
- | Components nothing places | ids in `get_site_composition` `unused` | Place them or name them to the user; deleting a component is a dashboard action |
166
- | A required prop left empty | `emptyRequiredProps` on a placement | Fill it; `required` is not enforced on write |
167
- | Props labelled by their copy or a generic role ("Text") | `get_next_steps` lists `labels-are-copy` | Label and help-text each prop from the site's code (`update_component` `propLabel`, `propHelpText`) |
168
- | Repeated cards as one prop per card | `plan1Title`, `plan2Title`… | One list prop whose rows are the cards |
169
-
170
- ## 4. Publishing
171
-
172
- Publish with `publish_component` or `publish_components` (batch). A refusal is a 409 with a code.
173
- Handle by code, never by retrying the same call:
174
-
175
- | Code | Meaning | What to do |
176
- |---|---|---|
177
- | `COMPONENT_BLUEPRINT_MATERIALIZATION_REQUIRED` | You tried to publish a platform built-in | Create a project-local component from it, publish that |
178
- | `COMPONENT_PROJECT_SCOPED_RELEASE_REQUIRED` | The component is workspace-global | Create a project-local variant, publish that |
179
- | `COMPONENT_IMPLEMENTATION_NOT_READY` | A REPO-managed component lacks exact evidence and human approval, or a variant-group member (the provisioned navigation and footer) has a family contract that is unpublished. Never a `create_component` component outside a variant group: that one is dashboard-managed and publishes on the preflight alone | Report `readiness`; the owner resolves it in the dashboard |
180
- | `COMPONENT_ORCHESTRATION_V2_REQUIRED` | The project runs implementation in Agent Dock | Continue there; stop here |
181
- | `COMPONENT_PUBLISH_CONFLICT` | The component or its evidence changed mid-publish | Re-read with `get_component`, then publish once more |
182
- | `COMPONENT_PROJECT_CONTEXT_MISMATCH` | The component's `projectId` differs from the request's project scope | Call with the component's own project |
183
-
184
- ### Site chrome (navigation and footer)
185
-
186
- A new project's navigation and footer are draft components placed in a draft Global Layout.
187
- Nothing of the chrome is live until all three are published, in this order:
188
-
189
- 1. `publish_component` for navigation-default and footer-default. Each can be published while the
190
- other is still draft; the publish is refused only for problems it would itself create.
191
- 2. `publish_layout` with the revision from `get_layout`.
192
- 3. `get_layout` with copy "published" to confirm.
193
-
194
- The navigation's logo is optional. When it is empty, the nav shows the brand mark
195
- (`set_brand_assets`) or the site name. Never generate or add a logo the site does not have. A
196
- page that renders its own header or footer does not get the Layout's copy on top of it.
197
-
198
- ## 5. Rendering components in your site
199
-
200
- - **Next**: `readComponents()` from `@bettercms-ai/next` reads definitions from the
201
- `bcms-content.json` snapshot. Pass them to `<BcmsBlocks components={…}>`; each `component`
202
- block is resolved by applying its overrides to the definition.
203
- - **Astro**: `BcmsBlocks.astro` takes the same `components` prop. Without it every instance
204
- renders nothing. Get definitions from `readComponents()` of `@bettercms-ai/next` or the
205
- delivery components endpoint.
206
- - **SDK only**: `listComponents()` on the read client.
207
-
208
- Rule: if an instance renders empty, check that definitions were passed before debugging the
209
- component.
210
-
211
- ### When the site's own code renders the sections
212
-
213
- A code-built site ships only what its templates draw, whatever the page's `blockJson` says. The
214
- setup gate judges it per route (`sections` and `style`, [finish-site.md](finish-site.md)), and
215
- adding or spacing one section is the recipe in [publishing.md](publishing.md).
216
-
217
- - **Render the page's block list, never hard-coded bands.** A section added or moved in the
218
- editor reaches the live site only when the template loops over the page's blocks in order and
219
- each section root carries its block id as `data-bcms-block`. The codemod writes both: `--sections`
220
- wraps a page as it is (below), and `--componentize` lifts its bands into components and
221
- generates `src/lib/bcms-sections.ts` for the loop. Until one ships, the
222
- editor still adds, moves, swaps and removes sections (saved, and drawn on the canvas at once),
223
- tells the author that publishing leaves the live order to the site's code, and its "Let BetterCMS
224
- control section order" button commits that same codemod to the connected repository. `submit_conversion_receipt` keeps
225
- `complete: false` with one `SECTIONS_UNSTAMPED` row per route.
226
- - **A page of imported section components is wrapped, not lifted.** `<BcmsSections>` takes each
227
- top-level section as a named slot and draws the block list in order, stamping each section's
228
- own root; a section added in the editor renders between them. `bettercms init` writes and
229
- commits the wrap; what it refused, and the markup to write by hand, is in
230
- [componentize.md](componentize.md) §5.
231
- - **The order is measured, not assumed.** A release reads the stamped section roots in document order:
232
- a build that draws them in the CMS order (`ordered: true` on the binding report's structure row)
233
- owns nothing, whatever sections it has no code for; one that draws them in its own order
234
- (`ordered: false`) — a template that hardcodes its bands and stamps each with a snapshot id — is
235
- code-ordered as soon as the CMS order moves. Stamp ids only from the loop, never as literals.
236
- - **No chrome placements on a page.** A nav, footer or modal placement the site's layout draws
237
- renders nowhere, and the editor locks its outline row and skips it when it moves the sections
238
- around it. Remove it.
239
- - **Spread `sectionRootAttrs(block)` on each root you write by hand** (`@bettercms-ai/astro`,
240
- `@bettercms-ai/next`): it adds `data-bcms-block`, `data-bcms-style` and the block's style. The
241
- editor offers section spacing and style handles only when every stamped root carries
242
- `data-bcms-style`. A site converted before it existed shows none and `get_next_steps` lists
243
- `upgrade-section-roots`. The fix is the codemod's `--upgrade-sections` mode, latest version, at
244
- the repo root (`--dry-run` first); give each root it reports as skipped (`OWN_STYLE`, `SPREAD`,
245
- `BLOCK_ID_ONLY`, …) its block style by hand, confirm with `--verify-styles`, push and release.
246
- - **A `button` field renders with `BcmsButton`** (`path`, `value`): one `<a data-bcms-button>`
247
- carrying `data-variant` and `data-size`, so every look control (style, size, arrow, icon, new
248
- tab) applies on the canvas and on publish. A value with `action: "code"` renders
249
- `<button type="button" data-bcms-button>` with the same children and no href: attach your
250
- handler to it as before. A publish writes its label and look and never an href, a target or a
251
- rel, and never turns it into an anchor.
252
- - **A button's looks are the ones the site already has.** Each release measures the built pages
253
- and keeps the project's Button family equal to the looks found there. Style offers those looks
254
- and no others, and a publish paints a pick with the site's own classes. Never write CSS for a
255
- look so that a control "works": there is no fixed list of variants to implement. A `button`
256
- value's `variant` is one of those looks; a refused value comes back with the looks the project
257
- has. A look the site drops is flagged, never deleted. A new look is the owner's design
258
- decision: add its class to the site, then Rebuild.
259
- - **Each look is named for the role it plays on that site, and a site has only the roles it
260
- uses.** One site's family is Primary, Secondary, Tertiary; another's is Primary, Secondary,
261
- Ghost, Outline, Text Link. Never create, rename toward, or ask for a role the site does not
262
- use, and never number one ("Outline 2"): the release gives the names, in this order.
263
-
264
- | Role | The look that gets it |
265
- |---|---|
266
- | The site's own name | A look the site names keeps that name: `btn-primary` is Primary, `btn-ghost` is Ghost, `data-variant="link"` is Text Link. Its stored `variant` value stays the site's own. |
267
- | Primary | Exactly one: the filled look used most (placements, then pages, then hero placements). On a site that fills nothing, its most used outline or ghost. |
268
- | Brand | The most used look filled with a colour, when Primary is a neutral dark or light fill, or when the site names a less used look Primary. |
269
- | Secondary | The look most often beside Primary in a button group; else the next most used look that is not a text link. |
270
- | Tertiary | A third filled look. Further fills are Dark, Light, or the colour's name. |
271
- | Outline | Bordered and not filled. |
272
- | Ghost | No fill and no border, padded like a button or filled on hover. |
273
- | Text Link | A plain link the site styles as a call to action. Menu and footer links and links in copy are not looks. |
274
-
275
- What counts as a button is one rule for the family and for the fields: what the site names one
276
- (`btn`, `button`, `cta`, `role="button"`), or what its stylesheet paints as one (a fill or a
277
- border of its own). Two looks paint nothing at rest and still count: a ghost, by its own padded
278
- box, and a text link drawn as a row that underlines. In a menu those two count only beside a
279
- painted button.
280
-
281
- A light and a dark rendition, a hover or disabled state, a size, and one paint spelled in two
282
- sets of classes are all ONE look. Two looks left with one role are told apart in words from
283
- their paint ("Outline on dark", "Pill Outline"). A role once given stays: a look added later
284
- takes a role that is free and renames nothing, and a name a person gave is never changed back.
285
- Without the SDK, write that same anchor: `data-bcms-button="<path>"`, the label in its own
286
- `<span data-bcms-field="<path>.label">`, the href through `data-bcms-props`. The arrow is the
287
- `svg.bcms-btn-arrow` it draws; never leave a hand-written arrow `<svg>` beside it, or turning
288
- the arrow off leaves one behind. A CTA whose look is hard-coded tells the editor "Style lives
289
- in the site's code".
290
- - **Render a CTA as one button** (the fix a 409 `CODE_RENDERED_ROUTE` names: its `next` gives the
291
- `route` and the pair's `keys`). On a route the site's app renders, the code reads the keys it
292
- declares, so the swap is made in code, on a branch, never on the default one:
293
- 1. Find the anchor. Search the repo for the keys; follow them through props and bindings tables
294
- to the component that writes the `<a>` (a page passes them to a layout component, which
295
- passes them to the button component).
296
- 2. Render it with `BcmsButton` (`@bettercms-ai/astro/components/BcmsButton.astro`, or
297
- `BcmsButton` from `@bettercms-ai/next`) under ONE key: the link key without `-href`. Pass
298
- `value` as the entry's button when it holds one, else `{ label, href }` from the copy and
299
- href the page renders today, so nothing changes until someone edits it. Keep the anchor's
300
- classes on `class`.
301
- 3. Remove the old pair's stamps and reads for that anchor (`data-bcms-field`, `data-bcms-props`,
302
- the lookups of the two old keys). Leave other callers of a shared component working.
303
- 4. Open a pull request. After it is merged and the site rebuilt, run `editor_setup`, then check
304
- the page: the anchor carries `data-bcms-button="<key>"` and `get_binding_report` lists no
305
- unmatched path for it. The button field is added by that build and starts from the pair's
306
- label and link.
307
- - **Read a published value where the app renders the page** (the fix a publish that fails with
308
- "Published, but this page's code doesn't read this field yet: `<key>`" names). On a route the
309
- site's app renders, a stamp (`data-bcms-field`, a `bcmsBindings` entry) only says where a field
310
- IS; the page shows a publish only when its code READS the value. In code, on a branch:
311
- 1. Read through the helper the conversion wrote (`bcms`, `bcmsRows`, `bcmsImage`, `bcmsLayout`
312
- from `src/bcms-content.ts`) or the SDK's `readPublishedContent` / `publishedEntry`. Never read
313
- a row of `bcms-content.json` by hand, and never bundle the file (`import`, `import.meta.glob`).
314
- 2. An image the page imports and passes on (`src={heroImage}` beside
315
- `bcmsBindings={{ image: "<key>" }}`): pass `src={bcmsImage(<page snapshot>, "<key>", heroImage)}`.
316
- The import stays the fallback; a published image is served from the copy BetterCMS places
317
- beside the app. `npx @bettercms-ai/convert` makes this edit for every such call site.
318
- 3. Keep the hard-coded copy as the fallback argument, so a build with no content renders as today.
319
- 4. Open a pull request. After it is merged and the site rebuilt, publish the field again: the
320
- toast reads "Live".
321
-
322
- ## 6. Preview source
323
-
324
- - `set_component_source` records which file in the repo implements a component;
325
- `clear_component_source` removes it. Previews and validation read this mapping.
326
- - **Call it as soon as you write the file, not at the end of the job.** Why: until the mapping
327
- exists a preview refuses with `COMPONENT_SOURCE_NOT_RECORDED`, and that refusal arrives later,
328
- in the dashboard, to whoever opened the component — not to you while you still have the path in
329
- hand.
330
- - `@bettercms-ai/preview-runtime` builds component previews for Next and Astro apps and validates
331
- components in CI. `@bettercms-ai/component-output` verifies preview sessions and runs the
332
- preview bridge in your app.
333
-
334
- ### Output validation
335
-
336
- A component's Output (its live preview) shows only after a validation run renders it. The run
337
- renders the component from its recorded file, or cuts it out of the deployed page by its
338
- `data-bcms-block` attribute. A component with neither has nothing to render, so its validation
339
- can only fail.
340
-
341
- Do these steps in order, every time you create or componentize a component:
342
-
343
- 1. Make it renderable. Either call `set_component_source` with the file that renders it, or, after
344
- `componentize_sections`, put each `blockId` from the receipt's `stamps` on its section root as
345
- `data-bcms-block` (or run `npx @bettercms-ai/convert --componentize`, then
346
- `submit_componentize_receipt`).
347
- 2. Deploy, so the build that validation renders contains that markup.
348
- 3. `get_component_readiness`. If `validation.gap` is set, do what `validation.fix` says first.
349
- 4. `declare_component_route` only when the app serves previews somewhere other than the default route.
350
- 5. `request_component_validation`. It refuses with 422 and the fix when nothing renders the
351
- component, and opens no request.
352
- 6. In the app's repository, run
353
- `npx @bettercms-ai/preview-runtime validate --local --request <requestId>` with `BCMS_API_URL`,
354
- `BCMS_API_KEY` (a project key with artifact write), `BCMS_PROJECT_ID` and `BCMS_PREVIEW_ORIGIN`
355
- set in the environment. Never put a key in a tool call.
356
- 7. `get_component_readiness` again. `outputReady` means the Output renders.
357
-
358
- Rules:
359
-
360
- - Your evidence is self-attested. Publishing still needs the owner's Visual Approval in the
361
- dashboard. No tool gives it: never say you approved a component.
362
- - CI validation (GitHub Actions) runs in the owner's repository at the owner's cost. The tool
363
- refuses it with "VALIDATION_PROVIDER_DASHBOARD_ONLY". Ask the owner to start it from the dashboard.
364
- - A validation failure naming "COMPONENT_SOURCE_NOT_RECORDED" means step 1 or 2 was skipped. It is
365
- not a bug in the component.
366
-
367
- ## 7. The values popover (dashboard)
368
-
369
- Where you edit decides what you change:
370
-
371
- - On a **placed instance** (a page body or rich-text field): the edit writes that instance's
372
- override. Only this placement changes.
373
- - On the **component's own canvas**: the edit writes the default of the variant being edited
374
- (Base, or the open variant). Every instance without an override changes.
375
-
376
- Rule: when the user says "change it on this page", edit the instance; "change it everywhere",
377
- edit the default. Say which one you did.
378
-
379
- ## 8. Groups
380
-
381
- Components are organised into `component_groups` (the folders of the Components tab). A component
382
- `componentize_sections` creates belongs to ONE page, so it is filed by that page, not by family.
383
-
384
- - `componentize_sections` files every component it creates: under `group` when you pass one
385
- (found or created by name); otherwise an MCP or release run files it under its page's folder,
386
- `Sections/<Page title>` (`Sections/<Page title>/<pattern>` for a template), and a dashboard or
387
- studio run leaves it unfiled. The receipt's `components.groups` lists the Groups it filed into.
388
- A reused component keeps its Group.
389
- - Sections `editor_setup` componentizes are filed the same way.
390
- - `componentize_sections` reuses a component by identity and slug, never by family, so a
391
- hand-built `Hero / Split` is not picked for a derived hero band. Run `dryRun: true` and read
392
- `components.wouldDuplicate`: each id is an existing same-family component the run would sit a
393
- new one beside. Decide with the user before applying.
394
- - Components componentized from the dashboard are not filed automatically.
395
- - File one yourself with `create_component` or `update_component` `group` (by name).
396
-
397
- Which pages use a component is computed on request by searching page and entry JSON; it is not a
398
- stored list.
399
-
400
- ## 9. Verify
401
-
402
- 1. `get_component` on each component you changed: props complete, `sectionType` spelled like its
403
- siblings.
404
- 2. `get_site_composition`: every page is sections; every component you meant to publish is
405
- published.
406
- - Per page: `kind` is `blocks`, `fields-only` or `empty`. `nonComponentBlocks` are loose
407
- top-level bands the editor's section lane cannot outline.
408
- - Per placement: the component's `status` is `published`, `draft` or `missing`. `inPicker` is
409
- false for a component with no `sectionType`: "Add a section" does not offer it.
410
- `emptyRequiredProps` is reported only here.
411
- - Site-wide: `components` counts published and draft, `awaitingEvidence` and
412
- `awaitingApproval`. A person approves in the dashboard, one component at a time. Then the
413
- `notInPicker` and `unused` ids, and `layout.placements`: the chrome placed in the Global
414
- Layout draft.
415
- 3. `get_binding_report` on a page that uses the component: instance values bind.
1
+ ---
2
+ title: "Components, props and variants"
3
+ description: "The prop contract, instances, sectionType families and variants, publishing codes, rendering components in a site, preview source and component groups."
4
+ ---
5
+
6
+ # Components, props and section variants
7
+
8
+ Doctrine (components vs collections, section anatomy, variants): §3 and §4 of
9
+ `bettercms://playbook/schema`; flows: `new_component` prompt, `componentize_site` prompt. This
10
+ reference is what the code does with what you write. Componentizing a derived site end to end:
11
+ [componentize.md](componentize.md).
12
+
13
+ ## Contents
14
+
15
+ - [1. The prop contract](#1-the-prop-contract) · [2. Instances](#2-instances) ·
16
+ [3. Sections, families and variants](#3-sections-families-and-variants) ·
17
+ [4. Publishing](#4-publishing) · [5. Rendering components in your site](#5-rendering-components-in-your-site)
18
+ - [6. Preview source](#6-preview-source) · [7. The values popover](#7-the-values-popover-dashboard) ·
19
+ [8. Groups](#8-groups) · [9. Verify](#9-verify)
20
+
21
+ ## 1. The prop contract
22
+
23
+ A component declares `props`. Each prop is `{ key, label, type, target: { blockId, path },
24
+ defaultValue? }`.
25
+
26
+ - `type` is one of exactly 30: `text`, `richtext`, `image`, `url`, `boolean`, `number`, `select`, `group`, `table`, `slot`,
27
+ `form`, then `date`, `color`, `file`, `link`, `icon`, `multiselect` and `button`, then `object`, `datetime`, `slug`,
28
+ `email`, `phone`, `json`, `location`, `tags`, `rating`, `code`, `reference` and `multi-reference`.
29
+ - The last seven store what the same-named model field stores: `date` is `YYYY-MM-DD`, `color` a
30
+ hex, `file` `{ url, name?, size? }`, `link` an href string or `{ href, rel?, newTab? }`, `icon` an icon name or an image
31
+ value, `multiselect` a `string[]` drawn from `config.options`. A bad `defaultValue` is a 400.
32
+ - **A `button` prop is one unit.** Its value is `{ label, href, variant?, size?, arrow?, icon?,
33
+ newTab? }` and its `target.path` is `props` on a `button` block. For a new component never
34
+ declare a label prop plus a url prop: the two drift apart on the first edit.
35
+ - **A button the site's own script handles has no href.** Its value is `{ label, action: "code",
36
+ variant?, size?, arrow?, icon? }`: a `<button type="button">` that opens a dialog, accepts
37
+ cookies or starts a tour. The label and the look are edited; the click stays the site's.
38
+ `href`, `newTab` and `rel` beside `action: "code"` are refused, and a value with neither an
39
+ href nor the action is refused as before. Setup derives these as button fields on a new
40
+ import. A control of the page itself is never one: a menu or theme toggle, a carousel arrow, a
41
+ dialog's close, an accordion or tab trigger, a copy button, a pagination step.
42
+ - `target.blockId` + `target.path` must point at a real block and property inside the
43
+ component's own `blockJson`. Why: rendering copies each prop's value to that target; a target
44
+ that does not exist renders the default and the edit goes nowhere.
45
+ - Every prop takes `helpText` and the component a `description` ("Hero with a heading,
46
+ supporting text and two buttons."). Leave them out: the platform writes the description, and
47
+ a prop's help only for a format or unit, a place the canvas cannot show, a real constraint or
48
+ a fallback (most props get none). Write your own by the rules in
49
+ [content-modeling.md](content-modeling.md) ("Writing help text"): one short plain sentence,
50
+ under 90 characters, with no semicolon, no dash and no figure taken from the page.
51
+ - A prop takes `showIf` (`{ match, rules: [{ field, op, value? }] }`): hidden until SIBLING props match. `op` is `eq`, `neq`, `in`, `isSet`, `notEmpty` or `empty`; the last three take no `value`, the first three need one. Props and model fields share all six, and a sub-field's `showIf` names its sibling sub-fields.
52
+ - `object` holds ONE `{ childKey: value }` built from `config.fields` (required), like the model field `object`. Groups, tables and objects nest to 10 levels counting the prop; an 11th is refused `field_depth_exceeded`. Aim an `object` at `props` or at a key your component code reads, never at a text slot such as a heading's `props.text` (`prop_target_unrenderable`).
53
+ - `datetime`, `slug`, `email`, `phone`, `json`, `location` (`{ lat, lng }`), `tags` (`string[]`), `rating` (0..`config.max`) and `code` (`{ language?, code }` or a string) hold what the same-named model field holds, and a placement's value is checked by that field's rules (`prop_value_invalid`). Like `object`, a `tags` or `code` prop never targets a text slot.
54
+ - `reference` holds one CMS item's id and `multi-reference` a list of them, as the model fields do: `config.contentModelId`, or `polymorphic: true` with `allowedModelSlugs` (one of the two is required), and `min`/`max` on the list. A placement's new id must exist (`REF_MISSING`); an id already stored is never re-checked. Page reads (delivery, preview) hydrate the ids to entries at their `depth`, so component code reads the entry; on a heading or button text slot the entry's title renders. A `multi-reference` never targets a text slot.
55
+ - `update_component` replaces the whole `props` array. Read with `get_component`, change, write
56
+ all props back.
57
+ - `select` and `multiselect` need `config.options`; `slot` holds one nested component instance,
58
+ or a list of them with `config.multiple`; `form` holds one Form document.
59
+ - `slot` and `form` are reference-shaped: `target.path` is `props` (the whole envelope) on an
60
+ empty `component` or `form` block, and their values are `{ componentId, overrides? }` and
61
+ `{ formId }`. Neither may appear inside a `group` or `table` row.
62
+ - **A `slot` with `config.multiple: true` holds a list.** Its value is
63
+ `[{ componentId, overrides? }, ...]` and its `target.path` is a container's child list:
64
+ `props.children` on a `section` block, or `props.columns.<n>` on a `columns` block (never
65
+ `props` on a `component` block). Each item is drawn there as a child component, in order.
66
+ Until a placement sets the slot, the children the definition holds in that list are shown;
67
+ `[]` shows none.
68
+ - `config.maxItems` defaults to 12 and cannot pass 50. `config.minItems` and
69
+ `config.componentIds` (which components may be listed) are checked on every write.
70
+ - An item may also carry `style` (use it for `hideOn`) and `window` with `from` / `until`
71
+ as UTC to the second (`2026-11-27T00:00:00Z`). A `defaultValue` must be an array and takes
72
+ no `window`.
73
+ - A write is a 400 whose message starts with the code: `slot_too_many`, `slot_too_few`,
74
+ `slot_item_invalid`, `slot_component_not_allowed`, `slot_component_unknown` (not a
75
+ component of this project), `component_cycle` (a component would end up inside itself,
76
+ through any chain), `component_too_deep` (more than 10 levels), `slot_target_invalid`,
77
+ `slot_not_multiple` (a list sent to a single slot). A value that is already stored is not
78
+ refused when the page is saved again.
79
+ - A child's copy is not click-to-edit on the canvas. Change it in the placement's
80
+ `overrides.<slot key>` list with `update_page`.
81
+ - Publish every listed component: a draft child renders nothing, like any draft component.
82
+ - **A `form` prop stores an id, nothing more.** Modelling the questions again as props gives you
83
+ two answers to "what does this form ask?", and they diverge at the first edit on either side.
84
+ The save schema is `.strict()`, so posting the hydrated form object back is a 400, not a silent
85
+ strip — you find out at the write, not at the render.
86
+ - A draft form is a 403 at every public ingress, so it renders nothing on the live site. Publish
87
+ it before pointing a component at it.
88
+
89
+ ### Where a form prop actually renders
90
+
91
+ The prop binds which form. What the READER does with it depends on how the component was built,
92
+ and only the first row below changes the rendered fields:
93
+
94
+ | Component | Preview / Output | The form comes from | Does the prop change the fields? |
95
+ |---|---|---|---|
96
+ | Built from blocks (a `form` block in `blockJson`) | no Output — Output needs a recorded source file | the renderer's forms map | **yes**, on the live site and the page preview |
97
+ | A component that renders `<BcmsForm>` / `<BcmsBlocks forms>` | yes | a forms catalogue passed beside the blocks | yes, where the catalogue reaches it |
98
+ | A codemod section (`@bettercms-ai/convert`) | yes | static markup; convert stamps `data-bcms-form` onto the existing `<form>` | no — the fields are hand-written HTML |
99
+ | A page route that fetches `/forms/public/:id` itself | inert (page-kind Output never runs scripts) | its own fetch | no |
100
+
101
+ ## 2. Instances
102
+
103
+ A placed component is a block `{ type: "component", props: { componentId, overrides?, bind?,
104
+ source? } }`.
105
+
106
+ - `overrides` is keyed by prop `key`. Rendering walks the definition's props and takes an
107
+ override only for a declared key. An override under any other key is ignored.
108
+ - A richtext override is a Portable Text array ([portable-text.md](portable-text.md)).
109
+ - Inside an instance, edit addresses are `<instanceId>__overrides.<key>`.
110
+
111
+ ## 3. Sections, families and variants
112
+
113
+ - A page is made of sections. A band used on more than one page, or with layout variants, is a
114
+ component with a `sectionType`. A true one-off is a `section` block whose `props.children` hold
115
+ its blocks.
116
+ - Components that share a `sectionType` are one family: its variants.
117
+ - **A section has a kind and a title, and only the kind is a name.** `sectionType` is the KIND
118
+ (`Hero`, `Logo cloud`, `Pricing`, `FAQ`): what the picker lists and the outline prints under each
119
+ section. It is the one thing to work out and name. The component's `name` is the layout within
120
+ that kind (`Centered`). The TITLE is the section's own heading, as written, which the outline
121
+ reads live from the section's content: nobody writes it, and a block `label` is only a person's
122
+ rename. Why: one string doing all three made two heroes collide (`Hero`, `Hero 2`) and gave a
123
+ component reused on a second page the first page's headline for a name. Never put a heading in
124
+ `sectionType` or `name`, and never write a `label` that rewords a heading or repeats the kind.
125
+ - **A built-in section is a starting point, not a thing you place.** `list_components` shows the
126
+ locked `builtin:*` blueprints; `create_component` with `from: "builtin:hero-centered"` copies
127
+ one into the project as an ordinary draft component, then edit and publish it like any other.
128
+ Whatever you send (`name`, `slug`, `props`, `blockJson`, `sectionType`, `category`) wins over
129
+ the copy. `list_components` `sourceKey` says which built-in a component came from. A copy
130
+ never follows later changes to the built-in. An unknown id is 404 `BUILTIN_NOT_FOUND`.
131
+ - **Give every component a library `category` as well.** Why: `sectionType` groups a family, the
132
+ category is how the component library lists it — an uncategorised component is created fine and
133
+ is then hard to find in the picker, which is where a human looks for it.
134
+ - **Spell `sectionType` exactly the same on every variant**, same case, no spaces. Why: the
135
+ backend (including the key-drift check) groups families case-insensitively but the dashboard
136
+ picker only trims, so "Hero" and "hero" are one family to the backend and two in the picker.
137
+ - **Every variant in a family declares the same prop keys with the same types.** Why: a variant
138
+ swap changes only `componentId`; the overrides stay, and only keys the new variant declares
139
+ are rendered. A key the new variant lacks is dropped from view; a key with a different type
140
+ renders wrong.
141
+ - Before swapping, compare `get_component` props of both variants. If keys differ, tell the user
142
+ which values will not show.
143
+ - **A card's or badge's looks are a `variant` select prop, not a family.** A card that comes as
144
+ Default, Featured and Outline is ONE component with a `select` prop keyed `variant` whose options
145
+ are the looks, and its markup picks classes from that value. Why: an editor switches the look in
146
+ place and the content stays put, and the library holds one card, not three. Families of looks
147
+ (`variantOf`, a style select on every section that places them) are for buttons. On a site
148
+ built in BetterCMS a new button look is `get_component`, then `create_component` with
149
+ `variantOf` naming the base. On an imported site the family is read, not written (§5).
150
+ - Read `warnings` and act on them; they never block the write:
151
+ - `create_component` / `update_component`: `VARIANT_PROP_KEYS_MISMATCH` (a key no sibling
152
+ declares, or one siblings declare that this variant lacks) and `VARIANT_PROP_TYPE_MISMATCH`
153
+ (same key, different type).
154
+ - `update_page` with `blockJson` that swaps a component block: `COMPONENT_SWAP_DROPS_OVERRIDES`
155
+ (overrides the new component does not render; they stay on the block, so swapping back
156
+ restores them) and `COMPONENT_SWAP_PROP_TYPE_CHANGED`.
157
+
158
+ Pitfalls of a component library:
159
+
160
+ | Pitfall | Signal | Fix |
161
+ |---|---|---|
162
+ | A variant needs keys its siblings lack | `VARIANT_PROP_KEYS_MISMATCH` on every write to it | It is a different band: give it its own `sectionType` family |
163
+ | Near-identical components in separate families ("Hero", "Hero 2", "Home hero") | Several families whose `get_component` props match | One family, variants by layout; every extra family is one more choice in "Add a section" |
164
+ | A component nobody can insert | its id in `get_site_composition` `notInPicker` | Set its `sectionType` |
165
+ | Components nothing places | ids in `get_site_composition` `unused` | Place them or name them to the user; deleting a component is a dashboard action |
166
+ | A required prop left empty | `emptyRequiredProps` on a placement | Fill it; `required` is not enforced on write |
167
+ | Props labelled by their copy or a generic role ("Text") | `get_next_steps` lists `labels-are-copy` | Label and help-text each prop from the site's code (`update_component` `propLabel`, `propHelpText`) |
168
+ | Repeated cards as one prop per card | `plan1Title`, `plan2Title`… | One list prop whose rows are the cards |
169
+
170
+ ## 4. Publishing
171
+
172
+ Publish with `publish_component` or `publish_components` (batch). A refusal is a 409 with a code.
173
+ Handle by code, never by retrying the same call:
174
+
175
+ | Code | Meaning | What to do |
176
+ |---|---|---|
177
+ | `COMPONENT_BLUEPRINT_MATERIALIZATION_REQUIRED` | You tried to publish a platform built-in | Create a project-local component from it, publish that |
178
+ | `COMPONENT_PROJECT_SCOPED_RELEASE_REQUIRED` | The component is workspace-global | Create a project-local variant, publish that |
179
+ | `COMPONENT_IMPLEMENTATION_NOT_READY` | A REPO-managed component lacks exact evidence and human approval, or a variant-group member (the provisioned navigation and footer) has a family contract that is unpublished. Never a `create_component` component outside a variant group: that one is dashboard-managed and publishes on the preflight alone | Report `readiness`; the owner resolves it in the dashboard |
180
+ | `COMPONENT_ORCHESTRATION_V2_REQUIRED` | The project runs implementation in Agent Dock | Continue there; stop here |
181
+ | `COMPONENT_PUBLISH_CONFLICT` | The component or its evidence changed mid-publish | Re-read with `get_component`, then publish once more |
182
+ | `COMPONENT_PROJECT_CONTEXT_MISMATCH` | The component's `projectId` differs from the request's project scope | Call with the component's own project |
183
+
184
+ ### Site chrome (navigation and footer)
185
+
186
+ A new project's navigation and footer are draft components placed in a draft Global Layout.
187
+ Nothing of the chrome is live until all three are published, in this order:
188
+
189
+ 1. `publish_component` for navigation-default and footer-default. Each can be published while the
190
+ other is still draft; the publish is refused only for problems it would itself create.
191
+ 2. `publish_layout` with the revision from `get_layout`.
192
+ 3. `get_layout` with copy "published" to confirm.
193
+
194
+ The navigation's logo is optional. When it is empty, the nav shows the brand mark
195
+ (`set_brand_assets`) or the site name. Never generate or add a logo the site does not have. A
196
+ page that renders its own header or footer does not get the Layout's copy on top of it.
197
+
198
+ ## 5. Rendering components in your site
199
+
200
+ - **Next**: `readComponents()` from `@bettercms-ai/next` reads definitions from the
201
+ `bcms-content.json` snapshot. Pass them to `<BcmsBlocks components={…}>`; each `component`
202
+ block is resolved by applying its overrides to the definition.
203
+ - **Astro**: `BcmsBlocks.astro` takes the same `components` prop. Without it every instance
204
+ renders nothing. Get definitions from `readComponents()` of `@bettercms-ai/next` or the
205
+ delivery components endpoint.
206
+ - **SDK only**: `listComponents()` on the read client.
207
+
208
+ Rule: if an instance renders empty, check that definitions were passed before debugging the
209
+ component.
210
+
211
+ ### When the site's own code renders the sections
212
+
213
+ A code-built site ships only what its templates draw, whatever the page's `blockJson` says. The
214
+ setup gate judges it per route (`sections` and `style`, [finish-site.md](finish-site.md)), and
215
+ adding or spacing one section is the recipe in [publishing.md](publishing.md).
216
+
217
+ - **Render the page's block list, never hard-coded bands.** A section added or moved in the
218
+ editor reaches the live site only when the template loops over the page's blocks in order and
219
+ each section root carries its block id as `data-bcms-block`. The codemod writes both: `--sections`
220
+ wraps a page as it is (below), and `--componentize` lifts its bands into components and
221
+ generates `src/lib/bcms-sections.ts` for the loop. Until one ships, the
222
+ editor still adds, moves, swaps and removes sections (saved, and drawn on the canvas at once),
223
+ tells the author that publishing leaves the live order to the site's code, and its "Let BetterCMS
224
+ control section order" button commits that same codemod to the connected repository. `submit_conversion_receipt` keeps
225
+ `complete: false` with one `SECTIONS_UNSTAMPED` row per route.
226
+ - **A page of imported section components is wrapped, not lifted.** `<BcmsSections>` takes each
227
+ top-level section as a named slot and draws the block list in order, stamping each section's
228
+ own root; a section added in the editor renders between them. `bettercms init` writes and
229
+ commits the wrap; what it refused, and the markup to write by hand, is in
230
+ [componentize.md](componentize.md) §5.
231
+ - **The order is measured, not assumed.** A release reads the stamped section roots in document order:
232
+ a build that draws them in the CMS order (`ordered: true` on the binding report's structure row)
233
+ owns nothing, whatever sections it has no code for; one that draws them in its own order
234
+ (`ordered: false`) — a template that hardcodes its bands and stamps each with a snapshot id — is
235
+ code-ordered as soon as the CMS order moves. Stamp ids only from the loop, never as literals.
236
+ - **No chrome placements on a page.** A nav, footer or modal placement the site's layout draws
237
+ renders nowhere, and the editor locks its outline row and skips it when it moves the sections
238
+ around it. Remove it.
239
+ - **Spread `sectionRootAttrs(block)` on each root you write by hand** (`@bettercms-ai/astro`,
240
+ `@bettercms-ai/next`): it adds `data-bcms-block`, `data-bcms-style` and the block's style. The
241
+ editor offers section spacing and style handles only when every stamped root carries
242
+ `data-bcms-style`. A site converted before it existed shows none and `get_next_steps` lists
243
+ `upgrade-section-roots`. The fix is the codemod's `--upgrade-sections` mode, latest version, at
244
+ the repo root (`--dry-run` first); give each root it reports as skipped (`OWN_STYLE`, `SPREAD`,
245
+ `BLOCK_ID_ONLY`, …) its block style by hand, confirm with `--verify-styles`, push and release.
246
+ - **A `button` field renders with `BcmsButton`** (`path`, `value`): one `<a data-bcms-button>`
247
+ carrying `data-variant` and `data-size`, so every look control (style, size, arrow, icon, new
248
+ tab) applies on the canvas and on publish. A value with `action: "code"` renders
249
+ `<button type="button" data-bcms-button>` with the same children and no href: attach your
250
+ handler to it as before. A publish writes its label and look and never an href, a target or a
251
+ rel, and never turns it into an anchor.
252
+ - **A button's looks are the ones the site already has.** Each release measures the built pages
253
+ and keeps the project's Button family equal to the looks found there. Style offers those looks
254
+ and no others, and a publish paints a pick with the site's own classes. Never write CSS for a
255
+ look so that a control "works": there is no fixed list of variants to implement. A `button`
256
+ value's `variant` is one of those looks; a refused value comes back with the looks the project
257
+ has. A look the site drops is flagged, never deleted. A new look is the owner's design
258
+ decision: add its class to the site, then Rebuild.
259
+ - **Each look is named for the role it plays on that site, and a site has only the roles it
260
+ uses.** One site's family is Primary, Secondary, Tertiary; another's is Primary, Secondary,
261
+ Ghost, Outline, Text Link. Never create, rename toward, or ask for a role the site does not
262
+ use, and never number one ("Outline 2"): the release gives the names, in this order.
263
+
264
+ | Role | The look that gets it |
265
+ |---|---|
266
+ | The site's own name | A look the site names keeps that name: `btn-primary` is Primary, `btn-ghost` is Ghost, `data-variant="link"` is Text Link. Its stored `variant` value stays the site's own. |
267
+ | Primary | Exactly one: the filled look used most (placements, then pages, then hero placements). On a site that fills nothing, its most used outline or ghost. |
268
+ | Brand | The most used look filled with a colour, when Primary is a neutral dark or light fill, or when the site names a less used look Primary. |
269
+ | Secondary | The look most often beside Primary in a button group; else the next most used look that is not a text link. |
270
+ | Tertiary | A third filled look. Further fills are Dark, Light, or the colour's name. |
271
+ | Outline | Bordered and not filled. |
272
+ | Ghost | No fill and no border, padded like a button or filled on hover. |
273
+ | Text Link | A plain link the site styles as a call to action. Menu and footer links and links in copy are not looks. |
274
+
275
+ What counts as a button is one rule for the family and for the fields: what the site names one
276
+ (`btn`, `button`, `cta`, `role="button"`), or what its stylesheet paints as one (a fill or a
277
+ border of its own). Two looks paint nothing at rest and still count: a ghost, by its own padded
278
+ box, and a text link drawn as a row that underlines. In a menu those two count only beside a
279
+ painted button.
280
+
281
+ A light and a dark rendition, a hover or disabled state, a size, and one paint spelled in two
282
+ sets of classes are all ONE look. Two looks left with one role are told apart in words from
283
+ their paint ("Outline on dark", "Pill Outline"). A role once given stays: a look added later
284
+ takes a role that is free and renames nothing, and a name a person gave is never changed back.
285
+ Without the SDK, write that same anchor: `data-bcms-button="<path>"`, the label in its own
286
+ `<span data-bcms-field="<path>.label">`, the href through `data-bcms-props`. The arrow is the
287
+ `svg.bcms-btn-arrow` it draws; never leave a hand-written arrow `<svg>` beside it, or turning
288
+ the arrow off leaves one behind. A CTA whose look is hard-coded tells the editor "Style lives
289
+ in the site's code".
290
+ - **Render a CTA as one button** (the fix a 409 `CODE_RENDERED_ROUTE` names: its `next` gives the
291
+ `route` and the pair's `keys`). On a route the site's app renders, the code reads the keys it
292
+ declares, so the swap is made in code, on a branch, never on the default one:
293
+ 1. Find the anchor. Search the repo for the keys; follow them through props and bindings tables
294
+ to the component that writes the `<a>` (a page passes them to a layout component, which
295
+ passes them to the button component).
296
+ 2. Render it with `BcmsButton` (`@bettercms-ai/astro/components/BcmsButton.astro`, or
297
+ `BcmsButton` from `@bettercms-ai/next`) under ONE key: the link key without `-href`. Pass
298
+ `value` as the entry's button when it holds one, else `{ label, href }` from the copy and
299
+ href the page renders today, so nothing changes until someone edits it. Keep the anchor's
300
+ classes on `class`.
301
+ 3. Remove the old pair's stamps and reads for that anchor (`data-bcms-field`, `data-bcms-props`,
302
+ the lookups of the two old keys). Leave other callers of a shared component working.
303
+ 4. Open a pull request. After it is merged and the site rebuilt, run `editor_setup`, then check
304
+ the page: the anchor carries `data-bcms-button="<key>"` and `get_binding_report` lists no
305
+ unmatched path for it. The button field is added by that build and starts from the pair's
306
+ label and link.
307
+ - **Read a published value where the app renders the page** (the fix a publish that fails with
308
+ "Published, but this page's code doesn't read this field yet: `<key>`" names). On a route the
309
+ site's app renders, a stamp (`data-bcms-field`, a `bcmsBindings` entry) only says where a field
310
+ IS; the page shows a publish only when its code READS the value. In code, on a branch:
311
+ 1. Read through the helper the conversion wrote (`bcms`, `bcmsRows`, `bcmsImage`, `bcmsLayout`
312
+ from `src/bcms-content.ts`) or the SDK's `readPublishedContent` / `publishedEntry`. Never read
313
+ a row of `bcms-content.json` by hand, and never bundle the file (`import`, `import.meta.glob`).
314
+ 2. An image the page imports and passes on (`src={heroImage}` beside
315
+ `bcmsBindings={{ image: "<key>" }}`): pass `src={bcmsImage(<page snapshot>, "<key>", heroImage)}`.
316
+ The import stays the fallback; a published image is served from the copy BetterCMS places
317
+ beside the app. `npx @bettercms-ai/convert` makes this edit for every such call site.
318
+ 3. Keep the hard-coded copy as the fallback argument, so a build with no content renders as today.
319
+ 4. Open a pull request. After it is merged and the site rebuilt, publish the field again: the
320
+ toast reads "Live".
321
+
322
+ ## 6. Preview source
323
+
324
+ - `set_component_source` records which file in the repo implements a component;
325
+ `clear_component_source` removes it. Previews and validation read this mapping.
326
+ - **Call it as soon as you write the file, not at the end of the job.** Why: until the mapping
327
+ exists a preview refuses with `COMPONENT_SOURCE_NOT_RECORDED`, and that refusal arrives later,
328
+ in the dashboard, to whoever opened the component — not to you while you still have the path in
329
+ hand.
330
+ - `@bettercms-ai/preview-runtime` builds component previews for Next and Astro apps and validates
331
+ components in CI. `@bettercms-ai/component-output` verifies preview sessions and runs the
332
+ preview bridge in your app.
333
+
334
+ ### Output validation
335
+
336
+ A component's Output (its live preview) shows only after a validation run renders it. The run
337
+ renders the component from its recorded file, or cuts it out of the deployed page by its
338
+ `data-bcms-block` attribute. A component with neither has nothing to render, so its validation
339
+ can only fail.
340
+
341
+ Do these steps in order, every time you create or componentize a component:
342
+
343
+ 1. Make it renderable. Either call `set_component_source` with the file that renders it, or, after
344
+ `componentize_sections`, put each `blockId` from the receipt's `stamps` on its section root as
345
+ `data-bcms-block` (or run `npx @bettercms-ai/convert --componentize`, then
346
+ `submit_componentize_receipt`).
347
+ 2. Deploy, so the build that validation renders contains that markup.
348
+ 3. `get_component_readiness`. If `validation.gap` is set, do what `validation.fix` says first.
349
+ 4. `declare_component_route` only when the app serves previews somewhere other than the default route.
350
+ 5. `request_component_validation`. It refuses with 422 and the fix when nothing renders the
351
+ component, and opens no request.
352
+ 6. In the app's repository, run
353
+ `npx @bettercms-ai/preview-runtime validate --local --request <requestId>` with `BCMS_API_URL`,
354
+ `BCMS_API_KEY` (a project key with artifact write), `BCMS_PROJECT_ID` and `BCMS_PREVIEW_ORIGIN`
355
+ set in the environment. Never put a key in a tool call.
356
+ 7. `get_component_readiness` again. `outputReady` means the Output renders.
357
+
358
+ Rules:
359
+
360
+ - Your evidence is self-attested. Publishing still needs the owner's Visual Approval in the
361
+ dashboard. No tool gives it: never say you approved a component.
362
+ - CI validation (GitHub Actions) runs in the owner's repository at the owner's cost. The tool
363
+ refuses it with "VALIDATION_PROVIDER_DASHBOARD_ONLY". Ask the owner to start it from the dashboard.
364
+ - A validation failure naming "COMPONENT_SOURCE_NOT_RECORDED" means step 1 or 2 was skipped. It is
365
+ not a bug in the component.
366
+
367
+ ## 7. The values popover (dashboard)
368
+
369
+ Where you edit decides what you change:
370
+
371
+ - On a **placed instance** (a page body or rich-text field): the edit writes that instance's
372
+ override. Only this placement changes.
373
+ - On the **component's own canvas**: the edit writes the default of the variant being edited
374
+ (Base, or the open variant). Every instance without an override changes.
375
+
376
+ Rule: when the user says "change it on this page", edit the instance; "change it everywhere",
377
+ edit the default. Say which one you did.
378
+
379
+ ## 8. Groups
380
+
381
+ Components are organised into `component_groups` (the folders of the Components tab). A component
382
+ `componentize_sections` creates belongs to ONE page, so it is filed by that page, not by family.
383
+
384
+ - `componentize_sections` files every component it creates: under `group` when you pass one
385
+ (found or created by name); otherwise an MCP or release run files it under its page's folder,
386
+ `Sections/<Page title>` (`Sections/<Page title>/<pattern>` for a template), and a dashboard or
387
+ studio run leaves it unfiled. The receipt's `components.groups` lists the Groups it filed into.
388
+ A reused component keeps its Group.
389
+ - Sections `editor_setup` componentizes are filed the same way.
390
+ - `componentize_sections` reuses a component by identity and slug, never by family, so a
391
+ hand-built `Hero / Split` is not picked for a derived hero band. Run `dryRun: true` and read
392
+ `components.wouldDuplicate`: each id is an existing same-family component the run would sit a
393
+ new one beside. Decide with the user before applying.
394
+ - Components componentized from the dashboard are not filed automatically.
395
+ - File one yourself with `create_component` or `update_component` `group` (by name).
396
+
397
+ Which pages use a component is computed on request by searching page and entry JSON; it is not a
398
+ stored list.
399
+
400
+ ## 9. Verify
401
+
402
+ 1. `get_component` on each component you changed: props complete, `sectionType` spelled like its
403
+ siblings.
404
+ 2. `get_site_composition`: every page is sections; every component you meant to publish is
405
+ published.
406
+ - Per page: `kind` is `blocks`, `fields-only` or `empty`. `nonComponentBlocks` are loose
407
+ top-level bands the editor's section lane cannot outline.
408
+ - Per placement: the component's `status` is `published`, `draft` or `missing`. `inPicker` is
409
+ false for a component with no `sectionType`: "Add a section" does not offer it.
410
+ `emptyRequiredProps` is reported only here.
411
+ - Site-wide: `components` counts published and draft, `awaitingEvidence` and
412
+ `awaitingApproval`. A person approves in the dashboard, one component at a time. Then the
413
+ `notInPicker` and `unused` ids, and `layout.placements`: the chrome placed in the Global
414
+ Layout draft.
415
+ 3. `get_binding_report` on a page that uses the component: instance values bind.