@bettercms-ai/mcp 0.70.0 → 0.71.0

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.
@@ -128,6 +128,7 @@ resources: `bettercms://skill/bettercms` for this file, and each reference under
128
128
  | The same for an article entry, navigation in the Global Layout, tabs holding columns | [worked-examples-advanced.md](references/worked-examples-advanced.md) | a collection entry with a body, site chrome, or nested blocks | example, blog post, document body, mega menu, tabs, columns |
129
129
  | Componentize a derived site | [componentize.md](references/componentize.md) | `get_site_composition` shows pages made of fields only | componentize, sections, fields-only, get_componentize_plan |
130
130
  | Click-to-edit attributes, addresses, declared mode | [bindings.md](references/bindings.md) | `grep -rl "data-bcms-" src` finds files; a value the editor cannot select | data-bcms-field, click to edit, binding report, stega, declared |
131
+ | Animated sites: GSAP, ScrollTrigger, split text, Framer Motion, Lenis, marquees, carousels, Lottie, hydrated islands | [animation.md](references/animation.md) | `package.json` lists `gsap`, `lenis`, `framer-motion`, `swiper` or a text splitter; a publish refused with `SPLIT_PATTERN_UNKNOWN` or `ISLAND_PROPS_UNMATCHED`; a `client-only-island` gate exception | animation, GSAP, ScrollTrigger, SplitText, Lenis, Framer Motion, marquee, Swiper, Lottie, island, client:only, data-bcms-split |
131
132
  | Wiring, env, hosting modes, media host, snapshots | [frameworks.md](references/frameworks.md) | `package.json` lists an adapter; `bcms-content.json` exists | hosting, headless, env, codegen, types, revalidate, snapshot |
132
133
  | Astro adapter | [astro.md](references/astro.md) | the repo has `astro.config.*` | astro.config, Astro, BcmsBlocks, getStaticPaths |
133
134
  | Next.js adapter | [next.md](references/next.md) | the repo has `next.config.*` | next.config, Next.js, App Router, ISR, notFound, static export |
@@ -181,7 +182,12 @@ Stop at the first step that is not clean, fix, then restart. A report that does
181
182
  3. `get_site_composition`: every page you touched is made of sections, with the intended
182
183
  components.
183
184
  4. `list_seo_issues`: no issue on a page you created or edited.
184
- 5. `get_next_steps`: nothing left that belongs to your task; owner actions go in your final
185
- message by name.
186
-
187
- "Done" means the loop is clean, not that a tool call returned 200.
185
+ 5. `get_next_steps`: `receipt.complete` is `true`. While it is `false`, take the first row of
186
+ `receipt.gate.blocking`, run its `fix`, and read again; nothing else ends an imported site's
187
+ setup. The gate, its bars and the exceptions it accepts are in
188
+ [finish-site.md](references/finish-site.md). Suggestions still in `missing` after that, and
189
+ owner actions, go in your final message by name.
190
+
191
+ "Done" means the loop is clean and the gate reads complete, not that a tool call returned 200.
192
+ `bettercms init` reads the gate again when you exit and prints its numbers per page, so the
193
+ user sees them whatever your last message said.
@@ -0,0 +1,235 @@
1
+ ---
2
+ title: "Animated sites"
3
+ description: "Keeping GSAP, ScrollTrigger, SplitText, Framer Motion, Lenis, marquees, carousels, Lottie and hydrated islands working while their copy stays editable and publishable."
4
+ ---
5
+
6
+ # Animated sites: editable copy that keeps moving
7
+
8
+ Read this when `package.json` lists `gsap`, `lenis`, `framer-motion` or `motion`, `swiper`,
9
+ `split-type`, `splitting`, `lottie-web` or `three`, or when the built HTML carries
10
+ `astro-island`, split-text spans or a duplicated marquee track. Attribute grammar:
11
+ [bindings.md](bindings.md). Adapter wiring: [frameworks.md](frameworks.md).
12
+
13
+ ## 1. What a publish does to animated markup
14
+
15
+ A text publish on a project in the text-node mode (off by default, for new projects too: the
16
+ operator switches it on per project, or for the fleet) writes the change into the element's
17
+ text nodes and keeps every element inside it. That is what the recipes below rely on:
18
+
19
+ | The page has | A publish does |
20
+ |---|---|
21
+ | Inline markup inside the bound element (`<em>`, a highlight `<span>`, `<br>`) | changes the words, keeps the elements and their attributes; a bare `<em>` or `<span>` the new words leave empty is removed |
22
+ | Text cut into `word` / `char` / `line` spans, same word count | writes each span in place |
23
+ | The same, word count changed, spans made by a library at runtime | puts the plain text back; the library splits it on its next run |
24
+ | The same, spans written by your own template | rebuilds them from their own pattern, a new one copied from the last |
25
+ | A copy of the value under `aria-hidden="true"` or `data-bcms-clone` | writes the copy too (coverage kind `duplicate-track`) |
26
+ | The value inside `astro-island` props, `__NEXT_DATA__` or the app router's flight scripts | rewrites the serialized string (coverage kind `island-props`) |
27
+
28
+ It refuses, and says so on the publish, when the change would have to remove something:
29
+ `TEXT_IN_CHILD_FIELD` (the words belong to a field declared inside this one),
30
+ `TEXT_EMPTIES_ELEMENT` (a link, a scripted element or a wrapper with attributes would be left empty),
31
+ `SPLIT_PATTERN_UNKNOWN` (split markup that is not one row of like tokens) and
32
+ `ISLAND_PROPS_UNMATCHED` (a hydrated island draws the text from props that do not hold it).
33
+ Each is fixed in the template with the recipe for its library.
34
+
35
+ On a project in the older mode, the default, a text publish replaces the element's content with
36
+ plain text. There, bind only elements that hold text and nothing else.
37
+
38
+ ## 2. Two rules for every library
39
+
40
+ 1. **Bind the element that holds the words, never the element the animation owns.** A pinned
41
+ section, a scroll wrapper, a slider track and a motion container are layout. Their text
42
+ leaves are the fields.
43
+ 2. **Refresh measurements after an edit.** The editor posts `{ type: "bcms:content-applied" }`
44
+ to the page's window after it applies an edit. Longer copy changes heights, so anything that
45
+ measured the page must measure again:
46
+
47
+ ```js
48
+ window.addEventListener("message", (event) => {
49
+ if (event.data?.type !== "bcms:content-applied") return;
50
+ ScrollTrigger.refresh(); // or lenis.resize(), swiper.update(), split.revert() then split again
51
+ });
52
+ ```
53
+
54
+ `onContentApplied(handler)` from `@bettercms-ai/sdk` is the same listener with the check
55
+ written for you. On a published page nothing posts the message and the listener never runs.
56
+
57
+ ## 3. GSAP and ScrollTrigger
58
+
59
+ ```html
60
+ <section class="pin-wrap"> <!-- trigger and pin: no field -->
61
+ <div class="pin">
62
+ <h2 data-bcms-field="story.title">Built to last</h2>
63
+ <p data-bcms-field="story.lead">Forged, not cast.</p>
64
+ </div>
65
+ </section>
66
+ ```
67
+
68
+ ```js
69
+ gsap.to(".pin h2", { yPercent: -20, scrollTrigger: { trigger: ".pin-wrap", pin: ".pin", scrub: 1 } });
70
+ ```
71
+
72
+ Never put `data-bcms-field` on `.pin-wrap` or `.pin`: ScrollTrigger wraps a pinned element in
73
+ a spacer and writes inline styles on it every frame. Use `<BcmsField>` only around a single
74
+ element, where it adds no wrapper (section 10); a wrapper span between `.pin` and its `h2`
75
+ would break `.pin > h2`.
76
+
77
+ Check: with the page open, `ScrollTrigger.getAll().length` is the same before and after a
78
+ publish of every field on the route.
79
+
80
+ ## 4. SplitText, SplitType, Splitting.js
81
+
82
+ Bind the parent and say that it is split. Never bind a word or a character.
83
+
84
+ ```html
85
+ <h1 class="js-split" data-bcms-field="hero.title" data-bcms-split="words">Fast screws, shipped</h1>
86
+ ```
87
+
88
+ ```js
89
+ let split = new SplitText(".js-split", { type: "words" });
90
+ window.addEventListener("message", (event) => {
91
+ if (event.data?.type !== "bcms:content-applied") return;
92
+ split.revert();
93
+ split = new SplitText(".js-split", { type: "words" });
94
+ });
95
+ ```
96
+
97
+ `data-bcms-split` takes `chars`, `words` or `lines`. It tells the editor that the spans under
98
+ this element are one value, and it tells a publish that the children are tokens even when their
99
+ class is not `word`, `char` or `line`.
100
+
101
+ When your own template writes the spans (`title.split(" ").map(...)`), keep them one row of
102
+ the same tag, with whitespace or nothing between them, and no other element in the row:
103
+
104
+ ```astro
105
+ <h1 data-bcms-field="hero.title" data-bcms-split="words">
106
+ {title.split(" ").map((word) => <span class="word">{word}</span>)}
107
+ </h1>
108
+ ```
109
+
110
+ A `<br>` between two word spans makes the row unreadable once the word count changes
111
+ (`SPLIT_PATTERN_UNKNOWN`): put the break in CSS, or split by lines.
112
+
113
+ Check: publish a title with one more word, reload, and count `.word`: it equals the new word
114
+ count and no word sits outside a span.
115
+
116
+ ## 5. Framer Motion
117
+
118
+ ```tsx
119
+ <motion.section initial={{ opacity: 0 }} whileInView={{ opacity: 1 }}> {/* no field */}
120
+ <motion.h2 data-bcms-field="story.title" variants={rise}>{story.title}</motion.h2>
121
+ <p data-bcms-field="story.lead">{story.lead}</p>
122
+ </motion.section>
123
+ ```
124
+
125
+ Put `data-bcms-field` straight on the motion element that renders the text: it passes
126
+ `data-*` through to the DOM. Do not wrap a motion component in `<BcmsField>`; a component child
127
+ keeps the wrapper span, and `layout` animations measure the wrong box. For words animated one
128
+ by one, map them to `motion.span` under a parent carrying `data-bcms-split="words"`, as in
129
+ section 4. The component hydrates, so section 9 applies to its props.
130
+
131
+ Check: the built HTML for the route shows `data-bcms-field` on the `h2` itself, with no
132
+ `display:contents` span around it.
133
+
134
+ ## 6. Lenis and other smooth scroll
135
+
136
+ Lenis moves the page, not the content, so no binding changes. Two things do:
137
+
138
+ ```js
139
+ const lenis = new Lenis();
140
+ window.addEventListener("message", (event) => {
141
+ if (event.data?.type === "bcms:content-applied") lenis.resize();
142
+ });
143
+ ```
144
+
145
+ and any panel of yours that must scroll by itself inside the page takes `data-lenis-prevent`.
146
+ Never bind the element Lenis is given as `wrapper` or `content`.
147
+
148
+ Check: after an edit that adds two lines to a paragraph, the page still scrolls to the footer.
149
+
150
+ ## 7. Marquees and duplicated tracks
151
+
152
+ Render the track once with fields, and mark every copy:
153
+
154
+ ```astro
155
+ <div class="marquee">
156
+ <ul class="track">{logos.map((l, i) => <li class="logo" data-bcms-field={`logos[${i}].name`}>{l.name}</li>)}</ul>
157
+ <ul class="track" aria-hidden="true" data-bcms-clone>{logos.map((l) => <li class="logo">{l.name}</li>)}</ul>
158
+ </div>
159
+ ```
160
+
161
+ A copy under `aria-hidden="true"` or `data-bcms-clone` is not counted as unowned text, and a
162
+ publish writes it with the original when it is the same tag with the same classes and the same
163
+ text. A copy with neither marker is separate copy: it counts against the route's text coverage
164
+ and keeps the old words. A script that clones the track in the browser needs nothing: the clone
165
+ is made from the published HTML on every load.
166
+
167
+ Check: publish one logo name, then `document.querySelectorAll(".track")` shows the new name in
168
+ every track.
169
+
170
+ ## 8. Swiper and carousels
171
+
172
+ Bind what is inside a slide, never `.swiper-wrapper` or `.swiper-slide`:
173
+
174
+ ```html
175
+ <div class="swiper-slide"><h3 data-bcms-field="slides[0].title">Torque</h3></div>
176
+ ```
177
+
178
+ Loop mode clones slides in the browser (`.swiper-slide-duplicate`); those are made from the
179
+ published HTML and need no field. On `bcms:content-applied`, call `swiper.update()`.
180
+
181
+ Check: `get_binding_report` lists no `slides[n]` path as pending for the route.
182
+
183
+ ## 9. Hydrated islands and `client:only`
184
+
185
+ A hydrated component draws itself again from the props the build serialized. Keep the text it
186
+ shows equal to one string prop, whole:
187
+
188
+ ```astro
189
+ <Hero client:load title={page.hero.title} />
190
+ ```
191
+
192
+ ```tsx
193
+ export default function Hero({ title }) { return <h1 data-bcms-field="hero.title">{title}</h1>; }
194
+ ```
195
+
196
+ A publish then rewrites that prop with the text. It cannot when the component builds the text
197
+ (`${title}!`, `title.toUpperCase()`, a string literal in the component), or when the props hold
198
+ the same words more than once, under a key such as `id` or `slug`, or as `__html`: the publish reports
199
+ `ISLAND_PROPS_UNMATCHED` and the value goes live with the next build. Content passed as a
200
+ slot is plain HTML and needs no prop.
201
+
202
+ `client:only` renders nothing at build time, so the built page has no copy to own. The release
203
+ names every such island on its route and the setup gate lists it as a `client-only-island`
204
+ exception by itself. To make the copy editable, change the directive to `client:load` or
205
+ `client:visible`, or pass the copy in as a slot.
206
+
207
+ Check: view source on the built route, find the `astro-island`, and its `props` attribute holds
208
+ the published title after a publish.
209
+
210
+ ## 10. `<BcmsField>` adds no wrapper around one element
211
+
212
+ ```astro
213
+ <BcmsField path="hero.title"><h1 class="title">{page.hero.title}</h1></BcmsField>
214
+ ```
215
+
216
+ renders `<h1 class="title" data-bcms-field="hero.title">`: nothing sits between the parent and
217
+ the heading. Text, several children, and in Next a component child are still wrapped in a
218
+ `display:contents` span, and `as="div"` still gives a real wrapper. A site built with an older
219
+ package version keeps its wrapper spans until it is rebuilt.
220
+
221
+ ## 11. Lottie, canvas, WebGL, video
222
+
223
+ Pixels drawn by a script are not in the HTML, so nothing in them is a field by itself. What is
224
+ editable is what you pass in:
225
+
226
+ | Drawn by | Make editable by |
227
+ |---|---|
228
+ | Lottie | Bind the animation file as an image-or-file field on the container and load `path` from it. For words inside the animation, keep a text layer named after the field and set it at load (`anim.renderer.elements[i].updateDocumentData({ t: value })`), reading the value from a hidden bound element or a prop. |
229
+ | Canvas / WebGL / three.js | Read every string and texture URL from `data-*` attributes or props on the canvas's container; bind a visually hidden element per string and an `<img hidden>` per texture, and re-read them on `bcms:content-applied`. |
230
+ | Video | The poster and the source are fields on the `<video>`; burned-in captions are not text. |
231
+
232
+ Give the canvas `role="img"` and an `aria-label` bound to a field, or an adjacent caption. A
233
+ scene with no text a visitor reads needs no field; record it under the `decorative` exception.
234
+
235
+ Check: change the bound string, publish, reload: the drawn text reads the new value.
@@ -41,8 +41,8 @@ build. Pass `apiUrl` and `workspace` as options, or let the snapshot supply them
41
41
  |---|---|
42
42
  | `@bettercms-ai/astro/components/BcmsBlocks.astro` | Server render of a block tree. Pass `components` or instances render nothing |
43
43
  | `@bettercms-ai/astro/components/BcmsLiveBlocks.astro` | Same tree as a hydrated island that follows the editor's draft bridge |
44
- | `@bettercms-ai/astro/components/BcmsField.astro` | `path`, `kind` (`richtext`/`image`), `as`; emits `data-bcms-field` + `data-bcms-kind` |
45
- | `@bettercms-ai/astro/components/BcmsButton.astro` | A `button` field: `path`, `value`, `class`. One `<a>` with the label bound, the href in `data-bcms-props` and `data-variant` / `data-size` for your CSS; renders nothing without a label |
44
+ | `@bettercms-ai/astro/components/BcmsField.astro` | `path`, `kind` (`richtext`/`image`), `as`; emits `data-bcms-field` + `data-bcms-kind` on its single child element, with no wrapper ([animation.md](animation.md) §10) |
45
+ | `@bettercms-ai/astro/components/BcmsButton.astro` | A `button` field: `path`, `value`, `class`. One `<a>` with the label bound, the href in `data-bcms-props` and `data-variant` / `data-size`. Style only the looks the site has ([components.md](components.md) §5); renders nothing without a label |
46
46
  | `@bettercms-ai/astro/components/BcmsForm.astro` | Renders a delivered form and submits it ([forms-and-interactions.md](forms-and-interactions.md)) |
47
47
 
48
48
  `BcmsLiveBlocks` rules:
@@ -20,8 +20,12 @@ cannot resolve an address guesses, which works once.
20
20
 
21
21
  **Put the attributes on the element that renders the value and nothing else: the `<img>` itself,
22
22
  the `<h1>` itself. Never a wrapper, never an element whose children you need.** A declared write
23
- replaces what is inside the element it names. Bind a `<section>` or a card and the band's own
24
- markup is what you overwrite; the page comes back as bare text where a layout used to be.
23
+ owns what is inside the element it names. Bind a `<section>` or a card and the band's own
24
+ markup is what a rich text or image write overwrites; the page comes back as bare text where a
25
+ layout used to be. A plain-text write keeps the inline elements inside its element (a highlight
26
+ `<span>`, split-text spans, a `<br>`) on a project in the text-node publish mode, and replaces
27
+ them on an older one: [animation.md](animation.md) §1 has both, and the recipe per animation
28
+ library.
25
29
 
26
30
  `data-bcms-kind` does not choose the editor; the MODEL does. The attribute says **"this element is
27
31
  the declared CONTAINER for the field"**, so the canvas repaints it whole. Leave it off and the
@@ -92,6 +96,30 @@ the fallback; the build renders it and the publish writes the editor's image. Fr
92
96
  images are in the Media Library, matched by file name. An image rendered inside a loop over a
93
97
  list is not bound automatically: bind it in the list's data file.
94
98
 
99
+ The codemod knows an image component by its IMPORT, not its name: `Image` and `Picture` from
100
+ `astro:assets` (aliased, or through `import * as assets`), and the default export of
101
+ `next/image` under any name. An import that reaches the page through `getImage()`, `hero.src` or
102
+ a style is refused as `IMAGE_ASSET_UNRESOLVED`, and the row's message names the import: declare
103
+ the element that shows it by hand (`data-bcms-field` + `data-bcms-kind="image"`).
104
+
105
+ Images need no code at all on a text-matched site. Every release derives an image field for each
106
+ picture a page shows: an `<img>` (its `src`, or where that is a placeholder a lazy loader's
107
+ `data-src`, then the largest `srcset` candidate), a whole `<picture>` as ONE field, a
108
+ `<video poster>`, an inline or STYLESHEET `background-image` (Tailwind `bg-[url(...)]` included)
109
+ and an inline `<svg>`. What it declines is listed with a reason in the binding report's
110
+ `imageGaps` ([media-and-brand.md](media-and-brand.md) Where images hide).
111
+
112
+ Publishing an image keeps the page responsive. A library image re-points every `srcset`
113
+ candidate and each `<source>` at the new picture at that candidate's own width and format, and
114
+ leaves `sizes`, `width`, `loading`, classes, ids and `data-*` as written; never hand-edit those
115
+ after a swap. A stylesheet background gets one inline `background-image` over the rule, removed
116
+ again when the value returns to the sheet's own picture.
117
+
118
+ To keep an image editable in code you write yourself, render it with `BcmsImage` and its `path`
119
+ (Astro: `@bettercms-ai/astro/components/BcmsImage.astro`; Next: `BcmsImage` from
120
+ `@bettercms-ai/next`): one `<img>` that declares the field, reads the value's alt, crop and focal
121
+ point, and offers a width ladder when given `sizes`.
122
+
95
123
  ## 3. Addresses
96
124
 
97
125
  The grammar is identical in `@bettercms-ai/next` and `@bettercms-ai/astro`; emit it with the SDK
@@ -103,7 +131,9 @@ helpers rather than by hand.
103
131
  this list. An `href`, an `src` and a `poster` are kind `url` (`cta.href|url|href`, not
104
132
  `|text|`); an `alt` is `text`. The same address on two elements is edited as two copies of one
105
133
  value (numbered `data-node-index`).
106
- - `<BcmsField path kind as>` wraps hand-rendered entry values. `path` is dotted keys with any
134
+ - `<BcmsField path kind as>` binds hand-rendered entry values. Around ONE element it adds no
135
+ wrapper: the attributes go on that element. Around text or several elements it renders a
136
+ `display:contents` span, and `as` names a real wrapper. `path` is dotted keys with any
107
137
  number of `[n]` indices: `title`, `hero.title`, `columns[0].links[2].href`.
108
138
  - Rich text is addressed per paragraph: `<base>#<_key>`. No write tool takes an address:
109
139
  `update_content_entry` replaces the whole field (with `merge: true` it keeps the other
@@ -23,9 +23,9 @@ reference is what the code does with what you write. Componentizing a derived si
23
23
  A component declares `props`. Each prop is `{ key, label, type, target: { blockId, path },
24
24
  defaultValue? }`.
25
25
 
26
- - `type` is one of exactly 18: `text`, `richtext`, `image`, `url`, `boolean`, `number`,
27
- `select`, `group`, `table`, `slot`, `form`, then `date`, `color`, `file`, `link`, `icon`,
28
- `multiselect` and `button`.
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
29
  - The last seven store what the same-named model field stores: `date` is `YYYY-MM-DD`, `color` a
30
30
  hex, `file` `{ url, name?, size? }`, `link` an href string or `{ href, rel?, newTab? }`, `icon` an icon name or an image
31
31
  value, `multiselect` a `string[]` drawn from `config.options`. A bad `defaultValue` is a 400.
@@ -38,7 +38,10 @@ defaultValue? }`.
38
38
  - Every prop takes `helpText` and the component a `description` ("Hero with a heading,
39
39
  supporting text and two buttons."). Leave them out and the platform writes them; write your
40
40
  own by the rules in [content-modeling.md](content-modeling.md) ("Writing help text").
41
- - A prop takes `showIf` (`{ match, rules: [{ field, op, value? }] }`): hidden until SIBLING props match. `op` is `eq`, `neq`, `in` or `isSet` (no `value`); model fields take the same four.
41
+ - 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.
42
+ - `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`).
43
+ - `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.
44
+ - `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.
42
45
  - `update_component` replaces the whole `props` array. Read with `get_component`, change, write
43
46
  all props back.
44
47
  - `select` and `multiselect` need `config.options`; `slot` holds one nested component instance,
@@ -123,8 +126,9 @@ source? } }`.
123
126
  Default, Featured and Outline is ONE component with a `select` prop keyed `variant` whose options
124
127
  are the looks, and its markup picks classes from that value. Why: an editor switches the look in
125
128
  place and the content stays put, and the library holds one card, not three. Families of looks
126
- (`variantOf`, a style select on every section that places them) are for buttons: a new button
127
- look is `get_component`, then `create_component` with `variantOf` naming the base.
129
+ (`variantOf`, a style select on every section that places them) are for buttons. On a site
130
+ built in BetterCMS a new button look is `get_component`, then `create_component` with
131
+ `variantOf` naming the base. On an imported site the family is read, not written (§5).
128
132
  - Read `warnings` and act on them; they never block the write:
129
133
  - `create_component` / `update_component`: `VARIANT_PROP_KEYS_MISMATCH` (a key no sibling
130
134
  declares, or one siblings declare that this variant lacks) and `VARIANT_PROP_TYPE_MISMATCH`
@@ -214,8 +218,16 @@ A code-built site ships only what its templates draw, whatever the page's `block
214
218
  the repo root (`--dry-run` first); give each root it reports as skipped (`OWN_STYLE`, `SPREAD`,
215
219
  `BLOCK_ID_ONLY`, …) its block style by hand, confirm with `--verify-styles`, push and release.
216
220
  - **A `button` field renders with `BcmsButton`** (`path`, `value`): one `<a data-bcms-button>`
217
- carrying `data-variant` and `data-size`, so the site's CSS decides what each look looks like and
218
- every look control (style, size, arrow, icon, new tab) applies on the canvas and on publish.
221
+ carrying `data-variant` and `data-size`, so every look control (style, size, arrow, icon, new
222
+ tab) applies on the canvas and on publish.
223
+ - **A button's looks are the ones the site already has.** Each release measures the built pages
224
+ and keeps the project's Button family equal to the looks found there, named as the site names
225
+ them (`btn-outline` is Outline). Style offers those looks and no others, and a publish paints a
226
+ pick with the site's own classes. Never write CSS for a look so that a control "works": there
227
+ is no fixed list of variants to implement. A `button` value's `variant` is one of those looks;
228
+ a refused value comes back with the looks the project has. A look the site drops is flagged,
229
+ never deleted. A new look is the owner's design decision: add its class to the site, then
230
+ Rebuild.
219
231
  Without the SDK, write that same anchor: `data-bcms-button="<path>"`, the label in its own
220
232
  `<span data-bcms-field="<path>.label">`, the href through `data-bcms-props`. The arrow is the
221
233
  `svg.bcms-btn-arrow` it draws; never leave a hand-written arrow `<svg>` beside it, or turning
@@ -46,7 +46,11 @@ collection: the model and its entries both point at the page (`content_models.pa
46
46
  A listing elsewhere (latest posts on home, related posts on an article) is a `collection` block
47
47
  with `source: { modelId }`, never copied entries. It also takes `filters` (max 5, `{ field, op,
48
48
  value }` with the content API operators), `sort` (`-publishedAt,title`), `pinnedIds` (max 10,
49
- shown first), `excludeCurrent` and `limit`. BetterCMS-rendered pages read it on every request;
49
+ shown first), `excludeCurrent` and `limit` (default 20), beside `order` (`newest` or `oldest`) and
50
+ `emptyText`. `cardComponentId` names the component each card is drawn through; without one a card
51
+ shows the entry's `titleField` (default `title`) and `excerptField`. On a `dynamic` page the block
52
+ lists that page's collection and draws ONE entry on `/<page>/<entrySlug>`, through
53
+ `detailComponentId` when set. BetterCMS-rendered pages read it on every request;
50
54
  a route the repo builds does not draw this block, so list there with the content API.
51
55
 
52
56
  | Source pattern | BetterCMS destination |
@@ -126,7 +130,7 @@ sitemap), RSS (dated by `publishedAt`), `llms.txt` (one link per published item)
126
130
  - **Lists are arrays; tags are taxonomy.** Features, steps, benefits and "what's included" are an
127
131
  `array` (`itemType: "text"`), or a repeater when each item carries its own state (included or
128
132
  not, an icon). Never a group holding one field. A label set that filters or classifies entries
129
- is a shared collection referenced with `multi-reference` (§2), not free text.
133
+ is a `tags` field; when each label has its own page, a collection referenced with `multi-reference` (§2).
130
134
  - **Never freeze a feed.** A list of entries on the source stays a listing: copying the nine
131
135
  visible cards into a repeater stops it updating. On a page BetterCMS renders, that is the
132
136
  `collection` block: on the collection's own dynamic page it lists that collection, and with
@@ -310,7 +314,8 @@ production cannot store it. Translate before the first write.
310
314
  | `date`, `datetime` | `date`, `datetime` | `date` (`YYYY-MM-DD`); a datetime stays `text` |
311
315
  | `color` | `color` | `color` |
312
316
  | `select` | `select` | `select` with `config.options` |
313
- | `multiselect`, `tags` | `multiselect` with `config.options` (a `string[]`; `config.allowCustom` for free tags, `min`/`max` count the choices); a taxonomy with its own pages is a collection plus `multi-reference` | `multiselect` with `config.options` |
317
+ | `multiselect` | `multiselect` with `config.options` (a closed `string[]` choice list; `min`/`max` count the choices) | `multiselect` with `config.options` |
318
+ | `tags` | `tags` (a `string[]`; `config.options` are suggestions, `allowCustom: false` closes the list); a taxonomy with its own pages is a collection plus `multi-reference` | `tags` |
314
319
  | `image` | `image` | `image` |
315
320
  | `file`, `video` | `file`; a hosted video URL is a `link` | `file`; `url` for a hosted video |
316
321
  | `icon` | `icon` (an icon name or an uploaded image) | `icon` (an icon name or an image) |
@@ -49,7 +49,7 @@ a product). One page cannot tell chrome from content.
49
49
  |---|---|---|
50
50
  | Real headings and paragraphs inside `<main>` | Server-rendered or static | Parse the raw HTML. Render once anyway to catch client-only bands |
51
51
  | An empty `#root`, `#__next`, `#app`, and large script bundles | Client-rendered | Render in a browser and read the rendered DOM |
52
- | `<astro-island>`, `__NEXT_DATA__`, `window.__NUXT__` | Hydrated islands | Parse the HTML, then render to read the islands. Their inline JSON often holds the data |
52
+ | `<astro-island>`, `__NEXT_DATA__`, `window.__NUXT__` | Hydrated islands | Parse the HTML, then render to read the islands. Their inline JSON often holds the data. Binding their copy: [animation.md](animation.md) §9 |
53
53
 
54
54
  When you render:
55
55
 
@@ -139,7 +139,7 @@ match when the sequence is equal and optional parts are missing only at the ends
139
139
  | Siblings of different kinds in one grid (a card, a quote, a big number) | The section's ordered children. When the editor picks and orders them, a `slot` prop with `config.multiple`; a plain `slot` holds ONE instance |
140
140
  | Same shape, each item linking to its own page | Entries of a collection |
141
141
 
142
- Count content levels, not DOM nodes. Component props nest at most three levels; a deeper subtree
142
+ Count content levels, not DOM nodes. Component props nest at most ten levels, counting the prop; a deeper subtree
143
143
  becomes its own component, placed as a child.
144
144
 
145
145
  **Collections.** A set of records with one shape and their own URLs.
@@ -31,8 +31,86 @@ answers `complete: true` (no path left in `paths.pending`), and after the deploy
31
31
  `declared`. A build job works from `bettercms://playbook/schema` (the `build_site` prompt carries
32
32
  the same flow on a local MCP) and is done when the site is deployed, `get_component_readiness`
33
33
  is clean for every component it placed, and `get_next_steps` lists nothing left to do from code.
34
- Both end in the verify loop of SKILL.md. One converted page, or a receipt with paths still
35
- pending, is not a finished site; report the numbers, not an impression.
34
+ Both end in the verify loop of SKILL.md, and an imported site ends only on the setup gate below.
35
+ One converted page, or a receipt with paths still pending, is not a finished site.
36
+
37
+ ## The setup gate: what "finished" is measured by
38
+
39
+ `get_next_steps` returns `receipt.complete` and `receipt.gate`. The platform computes the gate
40
+ from the last release, one verdict per route of the live build. The CLI that launched you reads
41
+ it again when you exit and prints it to the user, so the gate is the report, not your summary.
42
+ While the gate is incomplete that command exits 3 (0 once it is complete; 2 is a wrong command
43
+ line), which is what a script waiting on the setup reads.
44
+
45
+ | Check | Passes when | Where it shows |
46
+ |---|---|---|
47
+ | `page` | a CMS page, or a collection entry, serves the route | `routes.rows[].page` |
48
+ | `published` | that page, and its entry, is published | `routes.rows[].page` |
49
+ | `twin` | no second page claims the same route | `blocking.rows` |
50
+ | `text` | 2% or less of the visible characters sit outside a field | `routes.rows[].text` |
51
+ | `image` | each image a visitor sees belongs to a field, and the release named no picture it could not make one | `routes.rows[].image`, `imageGaps` |
52
+ | `link` | 95% of the links have a field for the label or the destination | `routes.rows[].link` |
53
+
54
+ `pages` counts routes built, created and published. `coverage` sums `editable` of `total` across
55
+ the site. `routes.rows` is ordered worst first, with `failing` per route and the reasons the
56
+ deriver `skipped` copy there. `blocking.rows` is the work list: each row carries a `detail` with
57
+ its number and a `fix`, the single call to run next. Run the first `fix`, call `get_next_steps`,
58
+ repeat until `complete`. A `fix` of `null` means no tool closes that row (a collection's listing
59
+ route, a URL that cannot be placed, two pages nobody but the owner can choose between): put it to
60
+ the user and stop retrying. Treat a row that its fix did not move the same way.
61
+
62
+ A `page` row on a route setup left without a page carries the pages lane's reason in its
63
+ `detail` (`invalid-path`, `html-too-large`, `page-deleted`, `slug-taken`, `folder-conflict`,
64
+ `entry-not-registered`, `authored-content` and the rest), with what closes it: a change in the
65
+ site's code, another page to move, or the user's decision. Only `unreadable`, `write-failed`
66
+ and `not-registered` carry a `fix`. An `image` row with a `null` fix names a picture by its code
67
+ ([media-and-brand.md](media-and-brand.md) Where images hide): the change is in the site's code.
68
+ `imageGaps` also lists the decoration the import declined, which blocks nothing. On a
69
+ collection's listing route the rows that link to its entries are counted as the collection's,
70
+ so the listing page is judged on its own heading, intro and calls to action.
71
+
72
+ ### Exceptions go on the record
73
+
74
+ A check you are leaving on purpose is recorded, one row per route and check, with
75
+ `editor_setup` and `exempt: [{ route, check, reason, note }]`. `note` states what is being left
76
+ in a sentence a person can verify. `reason: null` withdraws one.
77
+
78
+ | `reason` | May excuse |
79
+ |---|---|
80
+ | `form-validation-copy` | `text` |
81
+ | `legal-boilerplate` | `text` |
82
+ | `third-party-widget` | `text`, `image`, `link` |
83
+ | `decorative` | `text`, `image` |
84
+ | `duplicate-track` | `text`, `image`, `link` (the cloned track of a marquee or slider) |
85
+ | `client-only-island` | nothing: it lists an island the build cannot show, and the route still meets the bar outside it |
86
+ | `user-declined` | every check except `twin`, and only with the user's own words quoted in `note` (`User: "leave pricing in code"`) |
87
+
88
+ A row excuses what its check read when it was recorded. When the route reads differently
89
+ later (more copy nobody owns, another picture), the row stops counting and the check blocks
90
+ again: record it again if it still stands. The CLI prints every `user-declined` row marked for
91
+ the user to confirm.
92
+
93
+ Refused whatever the note says: `out-of-time`, `out-of-context`, `too-many-routes`,
94
+ `tool-failed`, `not-asked`, `good-enough`. A sentence to the user about a gap is not an
95
+ exception either: the gate stays incomplete, and the CLI exits non-zero with that gap listed.
96
+
97
+ ### What setup derives on a collection's item pages
98
+
99
+ Setup reads a few entry pages of each collection and makes one field of everything they render
100
+ at the same place: text, each image (an `<img>` with only a `srcset`, a `<picture>`, a
101
+ background its stylesheet paints), a call to action (one `button`, or a label and its `-href`
102
+ where the project derives pairs), a plain list, and a gallery or spec table as a repeater whose
103
+ rows each entry fills from its own page. The article's container becomes the place the entry's
104
+ `body` is read from and bound, so `body` holds the article alone. An element the site's code
105
+ already declares keeps its declaration and gets no second field. A collection from before this
106
+ gains the fields add-only; its `body` is bound only while no entry of it was edited. Check the
107
+ result per entry route in the gate, never by adding the same fields by hand.
108
+
109
+ Copy the template shows unchanged on every entry (a "Related posts" heading, an "Add to cart"
110
+ button) is one field marked `config.shared`, and its value lives on that field, once: no entry
111
+ holds it. Edit it as a field of any one entry and publish that entry: the value moves onto the
112
+ field and every item page is repainted. Until then it is that entry's draft, shown on no page.
113
+ A translated entry keeps its own words for it. Never write the same value into each entry.
36
114
 
37
115
  Git belongs to the owner. Verification releases go out from the working tree, so the job never
38
116
  needs a commit to prove itself. After the final report, ask whether they want the changes
@@ -72,7 +72,8 @@ H1. Expose typography variants only where the design allows a real choice.
72
72
  not in each page.
73
73
  - **Images.** A stored image value is an object (`url`, `altText`, asset id), never a bare
74
74
  string; read it as one. Alt text, decorative state and caption belong to the usage. Keep
75
- `srcset`/`sizes`, poster, lazy/preload intent and art direction. Decorative images render an
75
+ `srcset`/`sizes`, poster, lazy/preload intent and art direction (a publish keeps them too:
76
+ [bindings.md](bindings.md) Images). Decorative images render an
76
77
  empty `alt`; informative ones need text that fits the context. `update_media` with
77
78
  `decorative: true` marks a library asset decorative, so the audit stops asking for its alt text.
78
79
  - **Request the size you render.** Mint resize URLs with `@bettercms-ai/image-url`
@@ -112,6 +113,26 @@ H1. Expose typography variants only where the design allows a real choice.
112
113
  every video into the media library by default; keep a streaming provider reference where the
113
114
  site depends on one.
114
115
 
116
+ ## Where images hide
117
+
118
+ Setup makes every picture an image field backed by a library asset, in code, with no step for
119
+ you: the release adopts each media file the build ships (alt text from the markup, its size, and
120
+ a default focal point you can move with `update_media`), derives the fields, and links each
121
+ value to its asset. Check it, never redo it: the binding report's `elementCoverage.image` is
122
+ `editable / total`, and `imageGaps` names what is not a field, misses first.
123
+
124
+ | Code | The picture | What to do |
125
+ |---|---|---|
126
+ | `IMG_DATA_URI`, `IMG_NO_SOURCE` | An `<img>` inlined as `data:`, or filled in by a script | Ship it as a file with a real `src` |
127
+ | `BG_PSEUDO_ELEMENT` | A background on `::before` / `::after` | Move it onto the element, or render an `<img>` |
128
+ | `BG_ON_TEXT` | A background on an element that is itself a text field | Wrap the text, so the box and the words are two elements |
129
+ | `SVG_TOO_LARGE` | An inline `<svg>` illustration past the icon size | Ship it as an `.svg` file in an `<img>` |
130
+ | `SVG_SPRITE`, `SVG_NOT_ICON`, `SVG_DECORATIVE` | A `<use>` sprite, a chart, a nameless glyph | Decoration: leave it, or give a logo `role="img"` and an `aria-label` |
131
+ | `BG_SPRITE`, `BG_TINY`, `BG_PATTERN`, `BG_DATA_URI`, `BG_STATE` | A sprite, an icon-sized box, a tile, an inlined texture, a hover image | Decoration: leave it |
132
+
133
+ The first four rows count against the image coverage; the last two do not. A gap you cannot
134
+ close is reported with its code, never described as done.
135
+
115
136
  ## Media host
116
137
 
117
138
  Use `mediaBaseUrl` from `get_project`: media is served from the CDN origin or the API origin of
@@ -40,14 +40,15 @@ webhook. Use the project's webhook secret from the dashboard (the package docs n
40
40
  |---|---|
41
41
  | `BcmsBlocks` | Render a block tree; pass `components` from `readComponents()`. A block outside its `window` is not rendered, read against `now` (default: render time); `showScheduled` draws it marked `data-bcms-window`, for a draft preview |
42
42
  | `readComponents()` | Component definitions from `bcms-content.json` |
43
- | `BcmsField` | `path`, `kind` (`richtext`/`image`), `as`; server-safe |
44
- | `BcmsButton` | A `button` field: `path`, `value`, `className`; one `<a>` with `data-variant` / `data-size` for your CSS; server-safe |
43
+ | `BcmsField` | `path`, `kind` (`richtext`/`image`), `as`; server-safe; no wrapper around a single DOM element ([animation.md](animation.md) §10) |
44
+ | `BcmsButton` | A `button` field: `path`, `value`, `className`; one `<a>` with `data-variant` / `data-size`. Style only the looks the site has ([components.md](components.md) §5); server-safe |
45
45
  | `sectionRootAttrs(block)` | Spread on a section root your own code renders: `data-bcms-block`, `data-bcms-style` and the block's style, plus `data-bcms-hide` when `style.hideOn` is set: ship `RESPONSIVE_CSS` (exported here) in your global CSS ([components.md](components.md) §5) |
46
46
  | `bind`, `bindProps` | Binding attributes; instance-aware inside components |
47
47
  | `BcmsForm` + `getForm(idOrName)` | Forms from the snapshot ([forms-and-interactions.md](forms-and-interactions.md)) |
48
48
  | `buildMetadata(page, siteDefaults, location?)` | Next `Metadata` from native SEO fields |
49
49
  | `llmsTxtRoute(config)` | An `llms.txt` route handler |
50
- | `imageUrlBuilder({ baseUrl })` | Resize URLs. No `BcmsImage` in Next; same media-host rules as Astro |
50
+ | `imageUrlBuilder({ baseUrl })` | Resize URLs; same media-host rules as Astro |
51
+ | `BcmsImage` | An image field as one `<img>`: `path`, `src` (the value), `sizes` or `width`; declares the field and keeps the value's alt, crop and focal point; server-safe |
51
52
 
52
53
  Rules:
53
54