@bettercms-ai/mcp 0.69.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.
@@ -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
 
@@ -136,10 +136,10 @@ match when the sequence is equal and optional parts are missing only at the ends
136
136
  |---|---|
137
137
  | Two or more siblings of one shape (cards, tiers, logos, questions, steps) | A repeater. Inside a component: a `group` or `table` prop |
138
138
  | A list of plain strings (check-mark bullets) | An `array` of scalars. No icon field, no link field |
139
- | Siblings of different kinds in one grid (a card, a quote, a big number) | The section's ordered children. A `slot` prop holds ONE instance, so it is not a list |
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.
@@ -220,7 +220,7 @@ field itself.
220
220
  | Pricing | Calculator | Range sliders with listeners, a live total in `<output>`, no submit |
221
221
  | Conversion | CTA band | Full-width high-contrast band, one heading, at most one sentence, one or two buttons, no inputs |
222
222
  | Conversion | Newsletter | A form with exactly one `type="email"` input, a consent line, a mail-provider action URL |
223
- | Conversion | Lead capture | Copy and bullets beside a multi-field form; fieldsets with Next and Back for multi-step |
223
+ | Conversion | Lead capture | Copy and bullets beside a multi-field form; fieldsets with Next and Back for multi-step (`step` fields) |
224
224
  | Conversion | Contact | `mailto:` and `tel:` links by label, a form, a map iframe, hours |
225
225
  | Conversion | Popup | A hidden `role="dialog"` shown on a timer, scroll or mouse leave |
226
226
  | Listing | Blog index | `<article>` cards with image, heading link, `<time>`, excerpt; a shared href prefix |
@@ -82,6 +82,10 @@ Navigation, footer and dialogs live in the Global Layout (`get_layout`, `update_
82
82
  `get_next_steps` reports `chrome-duplicated`, declare the element in the Layout and push; the page
83
83
  copies are stripped for you. Never delete them by hand, and never give them dock chrome.
84
84
 
85
+ A Global field with `overridable: false` and a `lockReason` is not offered on a page: a page write
86
+ of it answers 403 **LAYOUT_FIELD_NOT_OVERRIDABLE** with the reason. A page value written earlier is
87
+ kept but the Global value renders; `get_layout` scope page lists it under `shadowed`.
88
+
85
89
  ## Acceptance
86
90
 
87
91
  Read back with `get_content_model`, `get_page` or `get_component`. Done means `get_next_steps`
@@ -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
@@ -48,9 +48,10 @@ change it safely.
48
48
 
49
49
  For a complex one-off component, keep the working code and expose only an intentional prop
50
50
  contract. A nested child is edited through a `slot` prop or its own instance; the edit must
51
- address the child instance, never the parent definition. A `slot` holds exactly one nested
52
- component; for several heterogeneous children use the section's ordered children. Prevent
53
- circular nesting.
51
+ address the child instance, never the parent definition. A `slot` holds one nested
52
+ component, or a list of them with `config.multiple`; a fixed set of different children is the
53
+ section's ordered children. A write that would nest a component inside itself is refused
54
+ (`component_cycle`).
54
55
 
55
56
  ## Forms: the rules
56
57
 
@@ -127,7 +128,7 @@ The pipeline with the code that enforces each hop.
127
128
 
128
129
  ### 2. Field types and server checks
129
130
 
130
- Thirteen types exist (`src/lib/validation/schemas/forms.ts:61`, `:62`, `:65`). No file upload.
131
+ Thirteen input types exist (`src/lib/validation/schemas/forms.ts`), plus `step`, a divider. No file upload.
131
132
 
132
133
  | Type | The server checks |
133
134
  |---|---|
@@ -140,6 +141,7 @@ Thirteen types exist (`src/lib/validation/schemas/forms.ts:61`, `:62`, `:65`). N
140
141
  | `url` | Starts with `http://` or `https://` |
141
142
  | `date` | `YYYY-MM-DD` |
142
143
  | `checkbox`, `hidden` | Nothing beyond `required` |
144
+ | `step` | Nothing: a divider is never required and never submitted; a value sent under its key is refused with rule `step` |
143
145
 
144
146
  Any field with `required` is checked for presence. Every value must be a plain value or a flat
145
147
  list of them; a nested object is refused with rule `scalar`. Each refusal is
@@ -159,6 +161,16 @@ Rules:
159
161
  - Read `issues[].field` and `issues[].rule` to fix a refused submission; retrying unchanged returns
160
162
  the same refusal.
161
163
 
164
+ #### Steps
165
+
166
+ A multi-step form is one form with `step` fields in `fields`: `{ key, type: "step", label }` opens
167
+ a page named `label`, and the fields before the first `step` are page 1. A step takes only `key`
168
+ and `label` (`step_has_input_keys`), needs a field after it (`step_empty`), and a form has at most
169
+ ten (`step_limit`). The visitor sees one page at a time with Back and Next, and the form posts
170
+ once, at the end, as one submission whose body never carries a step key. Without JavaScript every
171
+ page shows at once, as one long form. Map a source form's `fieldset` pages, or a "Next" button that
172
+ only reveals the next group, to `step` fields; keep one `required` per real question, not per page.
173
+
162
174
  ### 3. MCP versus dashboard
163
175
 
164
176
  | Operation | Where |
@@ -129,8 +129,8 @@ for a reader before calling a site wired.
129
129
 
130
130
  Read it through one of those, the converted-site helper (`bcms`, `bcmsRows`, `bcmsImage`) or
131
131
  `publishedEntry(slug)`. Never hand-write a parser of the file: each row holds its field values
132
- under `data` (`row.data.title`), so code that reads `row.title` gets `undefined` and renders its
133
- fallback on every publish. An editable image is `bcmsImage(page, path, importedImage)`: the bare
132
+ under `data` (`row.data.title`), so code that reads `row.title` gets `undefined` and the page shows
133
+ its in-code copy after every publish. For an editable image call `bcmsImage(page, path, importedImage)`: the bare
134
134
  import with only a stamp never shows a published image on a route the app renders. An old
135
135
  `src/utils/bcms.ts` with `pageEntry()` is that parser: replace its reads with `publishedEntry`.
136
136
 
@@ -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
@@ -38,16 +38,17 @@ webhook. Use the project's webhook secret from the dashboard (the package docs n
38
38
 
39
39
  | Export | Use |
40
40
  |---|---|
41
- | `BcmsBlocks` | Render a block tree; pass `components` from `readComponents()` |
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 |
45
- | `sectionRootAttrs(block)` | Spread on a section root your own code renders: `data-bcms-block`, `data-bcms-style` and the block's style ([components.md](components.md) §5) |
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
+ | `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
 
@@ -79,6 +79,51 @@ Builds run `build:bcms` instead of `build` when `package.json` declares it. Keep
79
79
  of it and every step the build needs in: `"build": "astro check && astro build"` beside
80
80
  `"build:bcms": "astro build"`. The convert codemod adds one to a repo whose build chains a check.
81
81
 
82
+ ### A section change that is held at the write
83
+
84
+ `update_page` and `compose_pages` save the draft and answer with `holds` when a `blockJson` adds,
85
+ moves or removes a section the live page cannot show yet. Each hold has a `reason`, a `message`,
86
+ the `sections` it is about and the one `action`. Never report such a write as done:
87
+
88
+ - `CODE_FIXED_SECTIONS`: the site's code fixes this page's sections, so no publish ships the
89
+ change. The action is `componentize_sections` with `codemod: true`; after its build, publish.
90
+ - `SECTION_NEEDS_BUILD`: the build draws the page's sections from BetterCMS. Publish: the build
91
+ starts by itself, and a move or a removal needs none.
92
+ - `STRUCTURE_UNMEASURED`: nothing has measured the page. Publish, then read the publish's outcome
93
+ in `get_deploy_status`.
94
+
95
+ ### Reading `get_deploy_status`
96
+
97
+ - `release` is the newest release: `id`, `sha`, `status`, `phaseMarks`, `error`, `createdAt`.
98
+ While an uploaded build is ingested or released the state is `building`, `release.sha` is the
99
+ new sha and `lastLiveSha` the old one. Sending the live sha again does nothing. A failed deploy
100
+ has `release.status` `failed` and its `error`.
101
+ - `held` means the release waits until the hold is cleared; `held.by` is `staff`, `archived` or
102
+ `no-subdomain`.
103
+ - `slot` is where the bytes are: `current` is production, `staging` the staging handle.
104
+ `productionSha` is what production serves. `awaitingPromote: true` is a build that is verified
105
+ and ready on staging while production keeps the older sha, until `promote_project` runs.
106
+ - The poll is finished only when `slot` is `current`, or when `awaitingPromote` is false and
107
+ `productionSha` equals the sha you deployed.
108
+ - `publishes` is the newest publishes, value by value. `status` `live` means every published value
109
+ was found in the file the site serves that page from. `partial` means some were not: `summary`
110
+ counts them ("3 of 4 live · 1 held") and `notLive` lists each with its `route`, `address`,
111
+ `state`, `reason` and the one `action` that fixes it. Never tell the user a publish is live
112
+ while it reads `partial`: relay the reason and do the action, then poll again.
113
+
114
+ | `reason` | `state` | The one action |
115
+ |---|---|---|
116
+ | `NOT_STAMPED` | held | Rebuild: the built page has no element for the field and does not show it |
117
+ | `VALUE_REFUSED` | held | Bind the field to an element that can hold its value |
118
+ | `APP_DOES_NOT_READ` | held | Convert in code: make the page's code read the published field |
119
+ | `ROWS_NEED_BUILD`, `SECTION_NEEDS_BUILD`, `NEEDS_BUILD` | held | Wait for the build the publish started |
120
+ | `STRUCTURE_UNBOUND` | refused | Let BetterCMS control section order (componentize with the codemod) |
121
+ | `SECTION_MISSING` | held | The same: the served page does not render the section |
122
+ | `STYLE_NOT_PAINTABLE` | held | Run editor setup so section roots carry `sectionRootAttrs` |
123
+ | `WINDOW_NOT_OPEN` | held | Nothing: it goes live when its window opens |
124
+ | `ROUTE_NOT_OWNED` | held | Change one of the two page paths that share the URL |
125
+ | `APP_UNREACHABLE`, `NOT_CHECKED`, `EDGE_PENDING`, `IMAGE_UNAVAILABLE`, `STRUCTURE_UNMEASURED` | unverified | Poll again; publish again if it stays |
126
+
82
127
  ### Deploy to Vercel or Netlify
83
128
 
84
129
  To serve a `managed` or `hybrid` site from the owner's own Vercel or Netlify account, call
@@ -156,6 +201,7 @@ for a content value the sync got wrong is §4.
156
201
  | Notify another system of an event | Event webhooks, `list_webhooks` / `create_webhook`: `content.publish`, `content.unpublish`, `content.stage_changed` (workflow stage), `content.created`, `content.updated`, `content.deleted` and `deployment.triggered`; HMAC-SHA256 signed in `X-BetterCMS-Signature`; payloads carry ids, never content. The signing `secret` is returned ONCE, on create. A receiver that writes back on `content.updated` loops unless it skips its own writes. Per-subscription payload filters: dashboard | You |
157
202
  | Send form submissions somewhere | Per-form integrations: Google Sheets, Slack, a webhook, email | Dashboard only |
158
203
  | Go live or retire at a time | `scheduledAt` (publish a draft at a time; needs publish permission) and `unpublishAt` (return a published row to draft) on `update_content_entry` and `update_page`; `null` cancels either. Campaigns: `publish_campaign` takes both | You. An edit that omits `scheduledAt` CANCELS the pending publish, so resend it with every edit before it fires; omitting `unpublishAt` never cancels it |
204
+ | Show one section only for a period | `window` `{ from?, until? }` on a page block (`update_page` `blockJson`) or on a Layout Section (`update_layout` `add-section`, `update-section`). Shown from `from` until `until`; each is an ISO 8601 datetime with an offset (`2026-11-27T00:00:00+01:00`), stored as UTC, and either may be left out. `null` clears it. Refusals: `invalid_window` (no offset, or not a date), `window_inverted` (`from` not before `until`), `window_in_component` (inside a component's `blockJson`: put it on the placement), `window_in_entry` (on a block inside an entry's sections value: put it on the page block or Layout Section that shows the entry). A Section a page adds or customizes takes a window like a Global one | You. Outside its window the block is in no published output: the page, its Markdown, the delivery API and search. A draft preview draws it, marked. A page BetterCMS renders per request changes at the boundary; a static site catches up within about a minute, in place when a section goes and with a build when one appears. Site code that renders its own header ignores a Section's window |
159
205
  | Load many entries | `create_content_entries`: up to 200 drafts of one collection per call, each row `created` or `failed` (with `externalKey` also `updated` or `unchanged`, so a re-run is safe); then `publish_entries` | You |
160
206
  | React to edits made elsewhere | Poll `list_activity` with `since` set to the time of your last read (1–100 events per call) | You |
161
207
  | Generate or rewrite in bulk with a human gate | AI jobs: `list_ai_jobs`, `get_ai_job`, then `approve_ai_job` or `reject_ai_job` on the user's word | User approves |
@@ -102,7 +102,7 @@ Topics, FAQs, Testimonials) once. Drop any collection the source does not have.
102
102
 
103
103
  | Piece | Where it lives |
104
104
  |---|---|
105
- | Header, footer, announcement bar | Global Layout via `update_layout`. Never a page section. Footer link columns are a repeater of groups; the newsletter box points at a form |
105
+ | Header, footer, announcement bar | Global Layout via `update_layout`. Never a page section. Footer link columns are a repeater of groups; the newsletter box points at a form. A bar or promo that runs for a period takes a `window` on its Section, and a seasonal band one on its page block ([publishing.md](publishing.md) §6) |
106
106
  | Company name, address, phone, social profiles | One place in the Global Layout, read by the footer and the contact page. Site JSON-LD repeats it once in `siteSchema` (`update_seo`) |
107
107
  | Cookie consent | A third-party consent tool stays code. Report it; do not rebuild it as content |
108
108
  | 404, legal, thank-you pages | Static pages in a `System` Content sidebar folder. Thank-you pages are noindex |
@@ -126,7 +126,7 @@ every entry and are not repeated in the tables. SEO fields are never listed.
126
126
  | One or many other entries | `reference`, `multi-reference`. A missing target is refused on write |
127
127
  | Call to action | `button` (label and link in one value). A bare link is `link`; also `email`, `phone` |
128
128
  | Date, date and time | `date`, `datetime`. Keep the zone as a `text` IANA name beside it when the site shows times |
129
- | A spec or comparison table | `repeater` of label and value rows. Models have no typed table field |
129
+ | A spec or comparison table | `repeater` with one field per column (any field type) and `ui.layout: "table"`. There is no separate table field type |
130
130
 
131
131
  An external system's id (`"store_product_id"`, `"ats_job_id"`, `"feed_id"`, `"lms_course_id"`)
132
132
  is a `text` field kept as migration metadata and locked: `readOnly: "developer"` with a
@@ -194,7 +194,7 @@ collection-wide default in `collectionSchemaTypes`. Never a self-serving `Aggreg
194
194
  ### Forms
195
195
 
196
196
  Thirteen field types: `text`, `textarea`, `email`, `phone`, `number`, `url`, `date`, `select`,
197
- `radio`, `checkbox`, `checkboxes`, `consent`, `hidden`. No file upload, so CVs, floor plans and
197
+ `radio`, `checkbox`, `checkboxes`, `consent`, `hidden`, plus the `step` divider for multi-step forms. No file upload, so CVs, floor plans and
198
198
  attachments go to the external system. Payment details never go in a form. Submissions land in
199
199
  the inbox; an editor turns one into an entry. Full pipeline:
200
200
  [forms-and-interactions.md](forms-and-interactions.md).
@@ -208,6 +208,19 @@ the inbox; an editor turns one into an entry. Full pipeline:
208
208
  - Every collection has a `urlPattern`; every changed URL has a 301.
209
209
  - Structured data is built from visible fields, with the type its catalog names.
210
210
 
211
+ ### Built-in sections
212
+
213
+ The section library ships 23 platform blueprints in 17 families, so a standard band needs no new
214
+ component: Hero, Feature grid (three, two, bento), Feature rows, Steps, Timeline, Stats, Logo cloud,
215
+ Testimonial (quote, three cards), Team, Case study, Comparison, Pricing, FAQ, Call to action,
216
+ Capture (newsletter band) and Listing (latest, related). Read one with `list_components`; inline its
217
+ `blockJson` as a `section` block for a one-off band, or copy it with `create_component` `from:
218
+ "builtin:<key>"` for a recurring one (see [components.md](components.md)). Two ship unbound on purpose: the Capture form has no form until you
219
+ set its `form` prop, and both Listing layouts list the page's own entries until you give them a
220
+ `source`; each shows its empty text meanwhile. Not available as a section, so build it in the repo
221
+ or leave it out: mega menu, docs sidebar, cookie consent, exit intent, store locator, map, billing
222
+ toggle, countdown and anything commerce.
223
+
211
224
  ## Portfolio website
212
225
 
213
226
  Use this when the primary job is to show selected work and make an individual or small studio credible. First determine whether the work is private, client-approved, dated, or still under embargo.
@@ -674,7 +687,8 @@ countdown.
674
687
  | Sponsors | `"logo"`, `"tier"` `select`, `"website"` `link` |
675
688
 
676
689
  - **Chrome:** header with a tickets button; an announcement bar whose countdown end is one
677
- `datetime` with its zone. Never migrate a countdown that resets per visitor.
690
+ `datetime` with its zone, and whose Section `window` ends with it. Never migrate a countdown
691
+ that resets per visitor.
678
692
  - **Lists:** a `collection` block with `source` sorts by `"starts_at"`, but its filter values
679
693
  are fixed: none means "today". Upcoming and past lists, and an agenda grouped by day and
680
694
  track, are the site's code filtering on `"starts_at"`. State that in the report; do not fake
@@ -484,10 +484,24 @@ the attributes. What it writes:
484
484
  - Without the definitions passed to the renderer, every instance draws empty: check that before
485
485
  debugging a card.
486
486
 
487
- Not available today: a slot with several children. A `slot` prop holds one instance, so "a column
488
- of mixed blocks" is a `columns` block, not a slot. A tab icon (a tab has a label only) and a
487
+ A slot can hold several children. Give the `slot` prop `config: { multiple: true }` and point its
488
+ target at a container's child list (`props.children` on a `section`, `props.columns.<n>` on a
489
+ `columns` block); a placement then lists the children, each a published component of this project:
490
+
491
+ ```json
492
+ { "type": "component", "id": "tour-cards", "props": { "componentId": "<card grid id>", "overrides": {
493
+ "cards": [
494
+ { "componentId": "<tour card id>", "overrides": { "title": "Plan" } },
495
+ { "componentId": "<stat id>", "overrides": { "value": "3.4x" }, "style": { "hideOn": ["mobile"] } }
496
+ ] } } }
497
+ ```
498
+
499
+ A fixed layout of different blocks that the editor never reorders is still a `columns` block, as
500
+ in the tabs above. Rules and refusal codes: [components.md](components.md).
501
+
502
+ Not available today: a tab icon (a tab has a label only) and a
489
503
  "first open tab" setting. Per-column widths: columns are equal, and `gap` is a whole number of
490
- pixels, not a named size. Per-breakpoint values on any of these.
504
+ pixels, not a named size. Per-breakpoint sizes are not available; `style.hideOn` (`["mobile"]`, `["tablet","desktop"]`; never all three) hides a block at up to two breakpoints, under 768, 768-1023 and 1024 and up, as `display:none` that keeps it in the HTML.
491
505
 
492
506
  ## Checks
493
507
 
@@ -94,7 +94,8 @@ Schema (`create_page` `fields`, or one `add_field` with `pageId` per field):
94
94
  { "key": "hero_secondary", "label": "Secondary button", "type": "button" },
95
95
  { "key": "hero_image", "label": "Background image", "type": "image",
96
96
  "helpText": "Fills the whole band behind the text. Use a photo at least 2560 px wide." },
97
- { "key": "hero_rating", "label": "Rating", "type": "number", "config": { "min": 0, "max": 5, "step": 0.1 } },
97
+ { "key": "hero_rating", "label": "Rating", "type": "number", "config": { "min": 0, "max": 5, "step": 0.1 },
98
+ "ui": { "control": "rating" } },
98
99
  { "key": "hero_rating_label", "label": "Rating source", "type": "text", "richText": false }
99
100
  ]
100
101
  ```
@@ -108,8 +109,8 @@ Value (`set_page_content`):
108
109
  "hero_eyebrow": "Freight visibility for mid-size shippers",
109
110
  "hero_title": "Know where every container is before your customer asks.",
110
111
  "hero_lede": "Live ETAs from 140 carriers, delays flagged before they cost you, and one shared view for ops and sales.",
111
- "hero_primary": { "label": "Book a demo", "href": "/demo", "variant": "primary", "arrow": true },
112
- "hero_secondary": { "label": "Start free trial", "href": "https://app.kestrelfreight.com/signup", "variant": "secondary" },
112
+ "hero_primary": { "label": "Book a demo", "href": "/demo", "arrow": true },
113
+ "hero_secondary": { "label": "Start free trial", "href": "https://app.kestrelfreight.com/signup" },
113
114
  "hero_image": {
114
115
  "url": "/img/hero-harbor-2560.jpg",
115
116
  "altText": "Container cranes over a quiet harbor at dusk",
@@ -148,6 +149,8 @@ Bound template (classes unchanged, shortened here):
148
149
  - The image binding sits on the `<img>`, not the `<picture>`. The alt text travels in the image
149
150
  value (`altText`): a publish writes it to the bound element's `alt`. When `get_conversion_brief`
150
151
  lists a separate alt path, declare that path in `data-bcms-props` with kind `text`.
152
+ - Neither button value names a `variant`: the anchors keep the site's own `btn-primary` and
153
+ `btn-ghost`, and the release reads those two looks into the project's Button family.
151
154
  - An `href` is kind `url`, never `text`. The label gets its own `<span>` so a save does not erase
152
155
  the arrow; the arrow is drawn by `BcmsButton` ([components.md](components.md)), so remove the
153
156
  hand-written `<svg>` once the button renders through it.
@@ -258,10 +261,9 @@ Bound template (first card):
258
261
  - The icon `<svg>` is not bound: no field is read from text inside an SVG. The name is edited in
259
262
  the side panel and the template turns it into the sprite reference.
260
263
 
261
- Not available today: an `icon` FIELD type with a picker over a named icon set. In a model or
262
- page field a sprite icon is a plain name; only an inline SVG stored as an `image` with
263
- `media: "icon"` gets the editor's icon picker. A component prop does have the `icon` type: a
264
- BetterCMS icon name or an image ([components.md](components.md) §1).
264
+ To make the icon editable, add a field of type `icon`: its value is a BetterCMS icon name or an
265
+ uploaded icon, and a name off the set is refused. A component prop of type `icon` takes the same
266
+ value ([components.md](components.md) §1). An inline SVG stays an `image` with `media: "icon"`.
265
267
 
266
268
  ## 3. Pricing plans with a comparison table
267
269
 
@@ -446,10 +448,19 @@ Bound template (the Growth card and one comparison row; `pricing-01` is the inst
446
448
  - A comparison written as prose instead belongs in a `richtext` prop as a Portable Text `table`
447
449
  ([portable-text.md](portable-text.md)); then the wrapper of that one value is the bound element.
448
450
 
449
- Not available today: typed table columns (money, boolean) and a per-cell type; every cell is text
450
- or one sub-field type for the whole column. Rows keyed to plan ids, and a field only a developer
451
- can change. Offer markup: no JSON-LD is built from prices. Paste it as `schema` on `update_page`
452
- when the page needs it. A fourth plan in the comparison is a new sub-field, a schema change.
451
+ A typed table is available on both sides. On a component each `table` column is one sub-field
452
+ with its own type (`number`, `boolean`, `url`, ...), as `monthly` and `ctaUrl` are above. On a
453
+ model it is a `repeater` (stored as an `array` with `config.zones.repeatable.fields`): one field
454
+ per column, of any field type, `minItems` / `maxItems` on the repeatable zone, and
455
+ `ui.layout: "table"` to edit the rows as a grid. To key rows to plan ids, give the model a `text`
456
+ column with `unique: true`. A value only a developer may change is a model field with
457
+ `readOnly: "developer"` and a `lockReason`.
458
+
459
+ Not available today: a per-cell type (a column has one type for every row), and a caption,
460
+ footnotes or row groups as parts of the table (model them as fields beside it, as `group` and
461
+ `fineprint` are here). Offer markup: no JSON-LD is built from prices. Paste it as `schema` on
462
+ `update_page` when the page needs it. A fourth plan in the comparison is a new sub-field, a schema
463
+ change.
453
464
 
454
465
  ## 4. Testimonials kept as a collection
455
466
 
@@ -576,6 +587,10 @@ Bound template (the section's own values; one card shown):
576
587
  for every page that shows them. The picked list is changed in the side panel.
577
588
  - Draw the stars from `rating`; never store the glyphs.
578
589
 
590
+ One chosen entry on a hosted page is a `collection` block, not a prop: `source: { modelId }`,
591
+ `pinnedIds: ["<entry id>"]`, `limit: 1` and `cardComponentId` naming the card that draws it. Pinned
592
+ entries come first, so the limit keeps exactly that one.
593
+
579
594
  Not available today: a component prop that references entries (there is no reference prop type; it
580
595
  would save and render nothing). A listing chosen by tag or filter on a route the repo's code
581
596
  renders: the `collection` block with `source`, `filters` and `sort` is drawn by the hosted
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bettercms-ai/mcp",
3
- "version": "0.69.0",
3
+ "version": "0.71.0",
4
4
  "type": "module",
5
5
  "description": "BetterCMS MCP server — schema, content, forms + components authoring tools over the Management API, authed via OAuth device-code.",
6
6
  "bin": {
@@ -32,7 +32,7 @@
32
32
  },
33
33
  "dependencies": {
34
34
  "@bettercms-ai/device-auth": "^0.2.0",
35
- "@bettercms-ai/sdk": "^1.27.0",
35
+ "@bettercms-ai/sdk": "^1.29.0",
36
36
  "@modelcontextprotocol/sdk": "^1.30.0",
37
37
  "zod": "^4.3.6"
38
38
  },