@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.
- package/README.md +86 -86
- package/SKILL.md +160 -160
- package/dist/index.js +38 -9
- package/dist/index.js.map +1 -1
- package/dist/kit-catalog.json +1509 -1509
- package/dist/skills/bettercms/SKILL.md +206 -206
- package/dist/skills/bettercms/references/accessibility.md +171 -171
- package/dist/skills/bettercms/references/animation.md +296 -296
- package/dist/skills/bettercms/references/astro.md +148 -148
- package/dist/skills/bettercms/references/bindings.md +366 -366
- package/dist/skills/bettercms/references/componentize.md +185 -185
- package/dist/skills/bettercms/references/components.md +415 -415
- package/dist/skills/bettercms/references/content-experiments.md +62 -62
- package/dist/skills/bettercms/references/content-modeling.md +382 -382
- package/dist/skills/bettercms/references/detection.md +465 -465
- package/dist/skills/bettercms/references/dock-input.md +121 -121
- package/dist/skills/bettercms/references/editor-chrome.md +92 -92
- package/dist/skills/bettercms/references/external-content.md +78 -78
- package/dist/skills/bettercms/references/finish-site.md +377 -377
- package/dist/skills/bettercms/references/forms-and-interactions.md +245 -245
- package/dist/skills/bettercms/references/frameworks.md +159 -159
- package/dist/skills/bettercms/references/getting-started.md +156 -156
- package/dist/skills/bettercms/references/integrations.md +364 -364
- package/dist/skills/bettercms/references/kit-catalog.md +3259 -3259
- package/dist/skills/bettercms/references/kit-scenarios.md +712 -712
- package/dist/skills/bettercms/references/localization.md +85 -85
- package/dist/skills/bettercms/references/media-and-brand.md +181 -181
- package/dist/skills/bettercms/references/next.md +189 -189
- package/dist/skills/bettercms/references/other-frameworks.md +85 -85
- package/dist/skills/bettercms/references/portable-text.md +213 -213
- package/dist/skills/bettercms/references/programmatic-seo.md +98 -98
- package/dist/skills/bettercms/references/publishing.md +289 -289
- package/dist/skills/bettercms/references/seo.md +357 -357
- package/dist/skills/bettercms/references/shopify-headless.md +52 -52
- package/dist/skills/bettercms/references/site-blueprints.md +895 -895
- package/dist/skills/bettercms/references/site-review.md +74 -74
- package/dist/skills/bettercms/references/worked-examples-advanced.md +509 -509
- package/dist/skills/bettercms/references/worked-examples.md +885 -885
- package/dist/skills/bettercms/references/write-limits.md +217 -217
- package/package.json +1 -1
|
@@ -1,382 +1,382 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: "Structure and content modeling"
|
|
3
|
-
description: "Pages vs collections, the four folder layers, field decisions and naming, and changing a schema safely, including retiring a field in stages."
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Structure and content modeling
|
|
7
|
-
|
|
8
|
-
**Outcome.** A content tree an editor understands on day one, durable relationships between
|
|
9
|
-
records, no accidental URL change, and model changes that every consuming site builds against.
|
|
10
|
-
|
|
11
|
-
Doctrine: §1–§5 and §14 of `bettercms://playbook/schema` (schema shape and sections) and
|
|
12
|
-
`bettercms://playbook/structure` (sidebar); flow: `propose_schema` prompt; rule ids
|
|
13
|
-
`CM-10`…`CM-14`, `CM-40`…`CM-44`. What the server does and does not check on a write:
|
|
14
|
-
[write-limits.md](write-limits.md). Planning a whole site by business type:
|
|
15
|
-
[site-blueprints.md](site-blueprints.md).
|
|
16
|
-
|
|
17
|
-
## Contents
|
|
18
|
-
|
|
19
|
-
1. Read the site before modeling
|
|
20
|
-
2. Pages, collections and entries (thresholds, promoting items to a collection)
|
|
21
|
-
3. Four structure layers, four meanings
|
|
22
|
-
4. Field decisions (references, lists, listings, the `document` body, buttons)
|
|
23
|
-
5. Editor labels and order (when to write help text)
|
|
24
|
-
6. Changing a schema safely
|
|
25
|
-
7. Reading a schema written in the V2 language (field type map, glossary)
|
|
26
|
-
|
|
27
|
-
## 1. Read the site before modeling
|
|
28
|
-
|
|
29
|
-
Inventory route files and rendered routes, source data schemas, repeated records, page templates,
|
|
30
|
-
navigation, taxonomies, authors, tags, media references, SEO, forms, locales and publishing
|
|
31
|
-
states. Compare with the project's pages, collections and entries (`list_pages`,
|
|
32
|
-
`list_content_models`, `get_content_structure`). Note where a value is shared, where it is an
|
|
33
|
-
instance override, and where one entry owns it outright.
|
|
34
|
-
|
|
35
|
-
## 2. Pages, collections and entries
|
|
36
|
-
|
|
37
|
-
A page is `singleton` (one page and its own content: home, about, a legal page) or `dynamic`
|
|
38
|
-
(one template page whose collection entries each render at the page's path plus the entry slug).
|
|
39
|
-
Choose by repetition: a shape that repeats per item is a collection; a one-off is a singleton,
|
|
40
|
-
even when it lists items, because the list page reads the collection. A dynamic page is its
|
|
41
|
-
collection: the model and its entries both point at the page (`content_models.page_id`,
|
|
42
|
-
`content_entries.page_id`), so never create a separate page per entry. A dynamic page made with
|
|
43
|
-
`create_page` takes its route pattern and its collection through `update_page { dynamic:
|
|
44
|
-
{ previewPath, modelId } }`: `/blog/{slug}`, and an existing collection with no page yet.
|
|
45
|
-
|
|
46
|
-
A listing elsewhere (latest posts on home, related posts on an article) is a `collection` block
|
|
47
|
-
with `source: { modelId }`, never copied entries. It also takes `filters` (max 5, `{ field, op,
|
|
48
|
-
value }` with the content API operators), `sort` (`-publishedAt,title`), `pinnedIds` (max 10,
|
|
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;
|
|
54
|
-
a route the repo builds does not draw this block, so list there with the content API.
|
|
55
|
-
|
|
56
|
-
| Source pattern | BetterCMS destination |
|
|
57
|
-
|---|---|
|
|
58
|
-
| Home, about, contact, legal, editorial landing page | Singleton page with its own content |
|
|
59
|
-
| Blog / case-study / product list URL | A list page plus a separate collection |
|
|
60
|
-
| Repeated records sharing one template | One collection and one dynamic detail page |
|
|
61
|
-
| 404, search, gated page | A page that keeps its access and noindex behaviour |
|
|
62
|
-
| Author, category, tag, company reused by entries | A shared collection, referenced from the entries |
|
|
63
|
-
| Data from an external runtime API | Keep the adapter and its owner; never fabricate entries |
|
|
64
|
-
| Sitewide nav, footer, promo bar | Global Layout, never copied page fields |
|
|
65
|
-
|
|
66
|
-
**Thresholds.** Three or more pages sharing a URL pattern and a card or detail layout are a
|
|
67
|
-
collection. Two are a collection when the set will grow (jobs, case studies). Otherwise they are
|
|
68
|
-
singletons.
|
|
69
|
-
|
|
70
|
-
**Promote repeater items to a collection** when any one holds; keep them inline when none does
|
|
71
|
-
(pricing bullets, a hero proof line, quotes on a one-off campaign page):
|
|
72
|
-
|
|
73
|
-
| Signal | Example |
|
|
74
|
-
|---|---|
|
|
75
|
-
| The item has its own URL | A case-study card linking to its detail page |
|
|
76
|
-
| The same item shows on two or more pages | A testimonial on home and on a product page |
|
|
77
|
-
| It carries facts that must not drift between copies | Price, stock, rating, job status, a person's photo |
|
|
78
|
-
| Another entry points at it | A post's author, an event's speaker |
|
|
79
|
-
| Editors will filter, sort or count the items | Jobs by team, events by date |
|
|
80
|
-
|
|
81
|
-
## 3. Four structure layers, four meanings
|
|
82
|
-
|
|
83
|
-
Name the layer every time you say "folder". The answer to "where does X live" differs per layer.
|
|
84
|
-
|
|
85
|
-
1. **Content sidebar folders** organise the dashboard only: nothing here reads or writes a URL,
|
|
86
|
-
slug, schema, content or publish state. Read with `get_content_structure`, get a proposal from
|
|
87
|
-
`suggest_content_structure`, apply it whole with `set_content_structure` once the user agrees,
|
|
88
|
-
or change one thing with `create_folder`, `rename_folder`, `move_to_folder` or `delete_folder`
|
|
89
|
-
(deleting a folder moves its contents up). `set_content_structure` replaces the whole document:
|
|
90
|
-
send the `version` you read; a 412 `VERSION_CONFLICT` means someone changed it, so read and
|
|
91
|
-
re-apply. It refuses duplicate folder ids, a parent not in the doc, a cycle, and nesting deeper
|
|
92
|
-
than 4. The standard pins the home page and each list page at the top, gives each content
|
|
93
|
-
family one folder named for its detail type (the list page and its collection are two items),
|
|
94
|
-
keeps other standalone pages in Pages (Pages › <language> on a multilingual site),
|
|
95
|
-
taxonomies used by two or more families in
|
|
96
|
-
Shared, and Settings last. Components are not sidebar items. Whether folders show depends on a
|
|
97
|
-
dashboard feature flag, so an empty sidebar is not proof the structure is missing.
|
|
98
|
-
2. **URL page folders** are part of a page's path unless set not to be included in it. Renaming
|
|
99
|
-
or moving one 301s every affected URL and queues a rebuild: a routing change that needs the
|
|
100
|
-
user's review. They are set in the dashboard, not through the sidebar tools.
|
|
101
|
-
3. **Component groups** (`component_groups`) organise definitions by family or role
|
|
102
|
-
(Navigation, Hero, CTA, Forms), never by the page that uses them ([components.md](components.md) §8).
|
|
103
|
-
4. **Media library folders** organise assets (`folderId` on `create_media_upload`) and never
|
|
104
|
-
rewrite a stored content URL.
|
|
105
|
-
|
|
106
|
-
What reads published structure: the sitemap (skips noindex pages), robots.txt (points at the
|
|
107
|
-
sitemap), RSS (dated by `publishedAt`), `llms.txt` (one link per published item).
|
|
108
|
-
|
|
109
|
-
## 4. Field decisions
|
|
110
|
-
|
|
111
|
-
Before designing a section's fields, find the closest component-kit entry: the chooser by
|
|
112
|
-
category and edge tag is [kit-scenarios.md](kit-scenarios.md), the full list is the
|
|
113
|
-
`bettercms://kit` resource and [kit-catalog.md](kit-catalog.md). The entry's notes are the
|
|
114
|
-
field mapping and its pitfalls the review list; the rules below still decide the storage.
|
|
115
|
-
|
|
116
|
-
- **Model each visual band as a `group` field (or a repeater), or a component instance.** The
|
|
117
|
-
schema's shape decides whether the site can ever be componentized. The componentize lane finds
|
|
118
|
-
a section in a `group`, a repeater, or two or more top-level keys sharing a prefix before `-` or
|
|
119
|
-
`_` (`"hero_title"` + `"hero_subtitle"` → `hero`). A key with NO shared prefix (`title`,
|
|
120
|
-
`intro`) is a lone scalar, refused `NOT_A_SECTION`. Keep ONE separator per band: a `-` family
|
|
121
|
-
and a `_` family never share a component. A `group` is the robust choice, and child keys inside
|
|
122
|
-
it follow the flat-namespace rule (`"hero_title"`).
|
|
123
|
-
- A collection for an entity with its own lifecycle and relationships; a nested group for values
|
|
124
|
-
one page or section owns.
|
|
125
|
-
- A reference for reusable entities and internal links where the model supports it; an external
|
|
126
|
-
URL for links that leave the site.
|
|
127
|
-
- **Resolve references before creating anything.** Build the target collection and its entries
|
|
128
|
-
first, then the model that points at them. Match each source item to an existing entry by
|
|
129
|
-
stable facts (a slug, a product handle, a name plus the start of the quote) and create only what
|
|
130
|
-
is missing. Store the entry id, never an href or a title. A write naming a target that does not
|
|
131
|
-
exist is refused with 400 (ref_missing). Flag an item with no identity (a quote with no name);
|
|
132
|
-
never import it.
|
|
133
|
-
- A repeater for small, same-shaped items (cards, FAQ rows, links, social accounts); a nested
|
|
134
|
-
component or slot when an item has its own layout or variants.
|
|
135
|
-
- **Lists are arrays; tags are taxonomy.** Features, steps, benefits and "what's included" are an
|
|
136
|
-
`array` (`itemType: "text"`), or a repeater when each item carries its own state (included or
|
|
137
|
-
not, an icon). Never a group holding one field. A label set that filters or classifies entries
|
|
138
|
-
is a `tags` field; when each label has its own page, a collection referenced with `multi-reference` (§2).
|
|
139
|
-
- **Never freeze a feed.** A list of entries on the source stays a listing: copying the nine
|
|
140
|
-
visible cards into a repeater stops it updating. On a page BetterCMS renders, that is the
|
|
141
|
-
`collection` block: on the collection's own dynamic page it lists that collection, and with
|
|
142
|
-
`source: { modelId }` it lists any collection on any hosted page (§2). It takes `limit` (1 to
|
|
143
|
-
200), `order` (`newest` or `oldest`) or `sort`, `filters`, `pinnedIds`, `excludeCurrent` and
|
|
144
|
-
`emptyText`; in an article template a filter value may be `{{field}}` of the entry shown, which
|
|
145
|
-
is a "related" list. Pagination and grouping stay a query in the site's code. Always set the
|
|
146
|
-
empty state, never write card titles or images into the listing, and put "read next" on the
|
|
147
|
-
template once, not on each entry.
|
|
148
|
-
- **`document` is the one long body.** A page schema or a collection may hold it, one at
|
|
149
|
-
most, and only as a top-level field: a group or a repeater cannot contain it. Label it Body. Short formatted copy stays `richtext`.
|
|
150
|
-
- **Model a call to action as a single `button` field.** It stores `{ label, href }` plus the
|
|
151
|
-
optional look keys `variant`, `size`, `arrow`, `icon` and `newTab`, and the site draws it with
|
|
152
|
-
`BcmsButton` ([astro.md](astro.md), [next.md](next.md)). Never a text
|
|
153
|
-
field beside a link field: the pair has nowhere to keep a look, and a new pair in the importer's
|
|
154
|
-
spelling (`hero-span-go` + `hero-a-go-href`) is refused with 400 `cta_pair` on `add_field`,
|
|
155
|
-
`create_page` and model writes. A page that already holds a pair (`primaryCtaText` +
|
|
156
|
-
`primaryCtaHref`) becomes one button with `update_field` `{ pageId, path: "primaryCtaText",
|
|
157
|
-
mergeHref: "primaryCtaHref" }`: each entry's DRAFT gets `{ label, href }` and keeps the old values,
|
|
158
|
-
so code still reading them keeps rendering; the live copy changes on publish. On a route the
|
|
159
|
-
site's app renders at request time the merge is refused (409 `CODE_RENDERED_ROUTE`): change the
|
|
160
|
-
code to draw the CTA with `BcmsButton`, and the next build adds the button field. Inside a component
|
|
161
|
-
a CTA is a `button` block whose `props` one `button` prop targets ([components.md](components.md) §1).
|
|
162
|
-
- A sponsored, untrusted or user-generated link takes `rel` (`nofollow`, `sponsored` or `ugc`) on the
|
|
163
|
-
button, or a `link` field value written as `{ href, rel?, newTab? }`; code that opts into the object reads `.href`.
|
|
164
|
-
- A `date` field with a time takes `timezone` (`site` or an IANA name such as `Europe/Paris`) in its config;
|
|
165
|
-
values must then end in `Z` or an offset, and a bracketed zone suffix is dropped on save.
|
|
166
|
-
- Never model SEO columns ([seo.md](seo.md)), Form questions (they live on the Form document) or
|
|
167
|
-
Global Layout values as content fields.
|
|
168
|
-
- Keep source ids, slugs, locale, status and previous URLs as migration metadata, stable across
|
|
169
|
-
re-imports.
|
|
170
|
-
- Lock a field whose value another system owns (a store, job board or feed id): `readOnly: "developer"` with a `lockReason`.
|
|
171
|
-
Only schema access may then change the value or the lock; other writes are refused (403 `FIELD_LOCKED`) unless they send the stored value back.
|
|
172
|
-
- **Prefer a `select` over a `boolean` for a state that may grow** (`draft | review | live`, not
|
|
173
|
-
`isLive`). Why: no tool retypes a field, so a third state later costs a new field and a value
|
|
174
|
-
migration (§6).
|
|
175
|
-
- Choose field types before the first write. A `link` accepts any scheme-less href (`/about`,
|
|
176
|
-
`about.html`, `#section`) and `http(s):`, `mailto:`, `tel:`; every other scheme is refused.
|
|
177
|
-
Choose the type from what the rendered value is, not habit ([portable-text.md](portable-text.md)).
|
|
178
|
-
|
|
179
|
-
## 5. Editor labels and order
|
|
180
|
-
|
|
181
|
-
**Name for meaning, not look.** Before writing a key or label, ask: would the name survive a
|
|
182
|
-
redesign? `ctaText`, `authorBio` and `features` pass; `blueButton`, `leftColumn` and `bigHeading`
|
|
183
|
-
fail, because the next redesign makes them lie.
|
|
184
|
-
|
|
185
|
-
Short labels named for what the editor sees: Heading, Body, Primary button, Label, Link, Image,
|
|
186
|
-
Alt text — or the name the site's code already uses (`cents` → Cents), never the copy itself
|
|
187
|
-
(`.00`). An import names every field it derives the same way, by its section and its role
|
|
188
|
-
there: "Hero headline", "Hero intro", "Hero primary button", "Pricing plans", "Footer link 2
|
|
189
|
-
URL", "Header logo"; a list's columns by their role in the row ("Title", "Description", "Link
|
|
190
|
-
URL"). A role is numbered only where it repeats in one section, and labels are unique on a page.
|
|
191
|
-
Keys never change. Rename a label where the site's code has a better word: a name you give is
|
|
192
|
-
kept on every later release, and one the import gave follows the import. Most fields need no help line from you (below). Group by what renders
|
|
193
|
-
together, not by the DOM or the source file: core content first, optional appearance second, technical fields hidden. Repeatable items need a meaningful preview label (the FAQ question, the
|
|
194
|
-
button label), reorder, and stable item ids. An empty optional field removes its element without
|
|
195
|
-
leaving a gap; a long value wraps, and clamps only where the design intends it.
|
|
196
|
-
|
|
197
|
-
### Writing help text
|
|
198
|
-
|
|
199
|
-
Most fields get no help. The platform writes a line only where one of the four reasons below
|
|
200
|
-
holds and leaves every other element with none, so leaving `helpText` out is the default, not a
|
|
201
|
-
gap. It says so: `helpText: { source: "auto" }` in the response, `helpSource: "auto"` on the
|
|
202
|
-
stored field. Text you send is stored as yours (`helpSource: "agent"`) and is never rewritten;
|
|
203
|
-
`editor_setup` rewrites only its own lines and never a person's.
|
|
204
|
-
|
|
205
|
-
Ask one question per field: does the editor need this line to type the right value? Write your
|
|
206
|
-
own `helpText` (on a field or a prop) only for one of four reasons, written as one short plain
|
|
207
|
-
sentence under 90 characters, with no semicolon, no dash and no figure taken from the page.
|
|
208
|
-
|
|
209
|
-
| Reason | Good |
|
|
210
|
-
|---|---|
|
|
211
|
-
| A format or unit the label cannot show | Price in cents. · Length in minutes. · A .vtt caption file. |
|
|
212
|
-
| Where the value shows, when the canvas cannot show it | Shown in search results and link previews. · Read by screen readers, not shown. |
|
|
213
|
-
| A real constraint or consequence | Changing this breaks existing links. · Must be later than the start date. |
|
|
214
|
-
| A fallback | Leave empty to use the page title. |
|
|
215
|
-
|
|
216
|
-
Everything else is left out:
|
|
217
|
-
|
|
218
|
-
| Never | Bad | Do instead |
|
|
219
|
-
|---|---|---|
|
|
220
|
-
| The label or the value again | Heading: "The section's main heading." · `e.g. "Starter Kit"` | Nothing. The API refuses help that echoes the value |
|
|
221
|
-
| Coaching the writing | "Keep it short." · "Two to four words works best." · "Start with a verb." | A length is a counter: a number in `config` (`"maxLength": 60`, §6) |
|
|
222
|
-
| An example of the value | Badge: "e.g. Most popular" | A `placeholder` where the control takes one (component props do) |
|
|
223
|
-
| Strategy | Secondary button: "A lower-commitment option that builds trust." | Nothing. The label says which button it is |
|
|
224
|
-
| Internal notes | "Renders as h2." · "Label and link are one unit." · a ticket or decision id | Keep them in the code or the report, never in the form |
|
|
225
|
-
| Generic good practice | "Add alt text for screen readers." on every image | Nothing |
|
|
226
|
-
|
|
227
|
-
One sentence, plain words, sentence case. If more than about one field in five carries your own
|
|
228
|
-
line, you are over-writing. A component, a collection and a Layout Section (`update-section`)
|
|
229
|
-
still take a `description` that says what the thing is ("Hero with a heading, supporting text,
|
|
230
|
-
two buttons and an image"), never its name again.
|
|
231
|
-
|
|
232
|
-
`get_next_steps` lists `help-text-missing` for an element the editor setup has not settled yet, or
|
|
233
|
-
one whose line only echoes its label, and `get_binding_report` and `get_site_composition` carry the
|
|
234
|
-
coverage as `help`. The platform writes a line only for the four reasons above and leaves most
|
|
235
|
-
elements with none; clear the finding by running `editor_setup`, or by writing one of the four
|
|
236
|
-
kinds yourself, never with filler.
|
|
237
|
-
|
|
238
|
-
**Config keys.** Write only the `config` keys a field type documents (`maxLength`, `min`/`max`,
|
|
239
|
-
`options`, `itemType`, `zones`, a rich text's `accept`/`inline`): any other key sends that field to
|
|
240
|
-
the old classic editor. The import's own keys (`attr`, `numeric`, `units`, `band`, `seeded`, …) are the
|
|
241
|
-
server's: both MCP surfaces drop them from what you write.
|
|
242
|
-
|
|
243
|
-
## 6. Changing a schema safely
|
|
244
|
-
|
|
245
|
-
A schema write alone is not a release. Before one, read the model with `get_content_model`, a few
|
|
246
|
-
sample entries, the component definitions that touch it and the frontend readers, and write a
|
|
247
|
-
before/after field map: key, type, label, required, default, validation, editor group, and what
|
|
248
|
-
happens to existing values.
|
|
249
|
-
|
|
250
|
-
1. **Guard concurrent edits.** Pass the model's `optimisticVersion` as `ifMatch` to
|
|
251
|
-
`update_content_model`. A 409 means it changed since you read it: re-read and re-plan, never
|
|
252
|
-
resend. Omitted, the last write wins, except on the editor-chrome args, which refuse without
|
|
253
|
-
it (428).
|
|
254
|
-
2. **Know what the MCP can do.** `add_field` (a model's `modelId` or a page's `pageId`) appends
|
|
255
|
-
and never overwrites or retypes. `update_field` changes ONE existing field, addressed by a dot
|
|
256
|
-
path through its groups and repeaters (`hero.title`): its label, help text, `required`,
|
|
257
|
-
validation `config` (merged by key; null deletes one), `showIf`, `ui` or placement, written at
|
|
258
|
-
the version it read. A retype needs `allowRetype: true`, because stored values that do not fit
|
|
259
|
-
the new type are lost; a key rename needs the migration in step 3. `update_content_model` edits
|
|
260
|
-
the model's name, slug, URL pattern and fieldsets, and removes model fields only with
|
|
261
|
-
`removeFields` (step 3). The home page cannot be deleted, and changing a local type file is not
|
|
262
|
-
a schema change.
|
|
263
|
-
3. **Retire or rename a field in stages, never in one write.** Renaming a key re-addresses its
|
|
264
|
-
content: `update_field` with a new `key` is refused with 409 and the count of entries holding
|
|
265
|
-
the old key, unless `allowFieldRemoval: true` discards their values. So a rename is a new field
|
|
266
|
-
plus a migration:
|
|
267
|
-
1. Relabel the old field "Old title (Deprecated)" and set help text naming its replacement
|
|
268
|
-
(`update_field` `label` and `helpText`).
|
|
269
|
-
2. Move it into a `collapsed: true` fieldset so editors stop reaching for it
|
|
270
|
-
([editor-chrome.md](editor-chrome.md) §2).
|
|
271
|
-
3. Add the replacement (`add_field`, with `modelId` or `pageId`) with the final type and name.
|
|
272
|
-
4. Migrate values in code, draft first, and have the frontend read the new key.
|
|
273
|
-
5. Remove the old one. Page fields: `remove_page_fields` (dry run by default) returns
|
|
274
|
-
`keysWithContent` and writes nothing; read it, then run it with `dryRun: false` and
|
|
275
|
-
`discardContent: true` only for keys you migrated. Model fields: `update_content_model` with
|
|
276
|
-
`removeFields: ["oldKey"]` and `allowFieldRemoval: true`, sent with the `ifMatch` of your
|
|
277
|
-
read. It has NO dry run and discards that key's value in every entry, so count the entries
|
|
278
|
-
holding it first (`list_content_entries` with `modelId`: `meta.total` is the count, `page`
|
|
279
|
-
walks past the 200-row `limit`). Top-level keys only; an unknown key refuses the
|
|
280
|
-
whole call; a page's own fields go through `remove_page_fields`. Both discard data: removal
|
|
281
|
-
is the last step, with the user's yes.
|
|
282
|
-
Why: every stage before 5 is reversible, and a site built against the old key keeps rendering
|
|
283
|
-
until its next release reads the new one.
|
|
284
|
-
4. **Put numeric limits in `config` as numbers** (`"maxLength": 60`), with `update_field` on a
|
|
285
|
-
field that exists. A numeric string is converted on save; an impossible value is refused with
|
|
286
|
-
`FIELD_CONFIG_INVALID`.
|
|
287
|
-
5. **Check existing records before tightening validation.** Backfill or give a real default
|
|
288
|
-
before making a field required; keep a source-to-target map and a snapshot for any high-volume
|
|
289
|
-
transform.
|
|
290
|
-
6. **Validate on a draft.** Entries written through the MCP pass one write path (request schema,
|
|
291
|
-
normalisation, per-field checks, uniqueness), and a write failing any step stores nothing.
|
|
292
|
-
Publishing copies the draft to live, so a draft that passes is the value that ships.
|
|
293
|
-
7. **Change a component family together** (same prop keys and types on every variant;
|
|
294
|
-
[components.md](components.md) §1 and §3).
|
|
295
|
-
8. **Regenerate types** with `bettercms-codegen` in every consuming site after any model or field
|
|
296
|
-
change, commit the output if the repo commits it, and run that repo's build and typecheck with
|
|
297
|
-
the script it already has. Templates typed against an old schema compile and read `undefined`.
|
|
298
|
-
9. **Verify** the editor's grouping and controls, one draft edit per changed field class, the
|
|
299
|
-
preview binding, the static or headless reading path, and the public output once a release is
|
|
300
|
-
authorized.
|
|
301
|
-
|
|
302
|
-
Content models, frontend types, component definitions and live content have separate lifecycles;
|
|
303
|
-
there is no single "deploy schema" action. Production publication stays behind the user's
|
|
304
|
-
authorization and the component readiness and Visual Approval gates.
|
|
305
|
-
|
|
306
|
-
**Evidence to return:** model and field ids, the version you wrote against, migrated record
|
|
307
|
-
count, codegen and build result, one editor check and one rendered-page check, warnings, and any
|
|
308
|
-
change the platform could not make (an open dependency, never a success).
|
|
309
|
-
|
|
310
|
-
## 7. Reading a schema written in the V2 language
|
|
311
|
-
|
|
312
|
-
A plan or Kit sample may arrive in the V2 schema language. It is an intermediate model only:
|
|
313
|
-
production cannot store it. Translate before the first write.
|
|
314
|
-
|
|
315
|
-
- **Keys.** V2 names are camelCase and scoped per object (`hero.heading`). Production keys are
|
|
316
|
-
flat, one namespace per model, prefixed by section: `"hero_heading"`.
|
|
317
|
-
- **Labels and help.** V2 `title` is `label`; V2 `description` is `helpText` (§5 decides whether
|
|
318
|
-
it is written at all).
|
|
319
|
-
- **Limits.** V2 `validation` becomes numbers in `config` (§6 step 4).
|
|
320
|
-
|
|
321
|
-
| V2 type | Model field (`create_content_model`, `add_field`) | Component prop (`create_component`) |
|
|
322
|
-
|---|---|---|
|
|
323
|
-
| `string` | `text` with `richText: false` | `text` |
|
|
324
|
-
| `text` | `longtext` | `text` |
|
|
325
|
-
| `richtext` | `richtext` for section copy; `document` for the one body | `richtext` |
|
|
326
|
-
| `number` | `number` | `number` |
|
|
327
|
-
| `rating` | `number` with `config.min`, `max`, `step` and `ui.control: "rating"` (stars) | `number` |
|
|
328
|
-
| `boolean` | `boolean` | `boolean` |
|
|
329
|
-
| `slug` | `slug` (an entry's own slug stays on the entry) | `text` |
|
|
330
|
-
| `url`, `link` | `link` | `link`; `url` for a media or embed address |
|
|
331
|
-
| `email`, `phone` | `email`; a phone number is `text` with `richText: false` (`phone` opens only in the classic editor) | `text` |
|
|
332
|
-
| `date`, `datetime` | `date`, `datetime` | `date` (`YYYY-MM-DD`); a datetime stays `text` |
|
|
333
|
-
| `color` | `color` | `color` |
|
|
334
|
-
| `select` | `select` | `select` with `config.options` |
|
|
335
|
-
| `multiselect` | `multiselect` with `config.options` (a closed `string[]` choice list; `min`/`max` count the choices) | `multiselect` with `config.options` |
|
|
336
|
-
| `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` |
|
|
337
|
-
| `image` | `image` | `image` |
|
|
338
|
-
| `file`, `video` | `file`; a hosted video URL is a `link` | `file`; `url` for a hosted video |
|
|
339
|
-
| `icon` | `icon` (an icon name or an uploaded image) | `icon` (an icon name or an image) |
|
|
340
|
-
| `button` | `button` | `button`: one prop targeting the `props` of a `button` block (§4) |
|
|
341
|
-
| `reference` | `reference`; `multi-reference` for many | none: keep it on the model |
|
|
342
|
-
| `code` | `longtext` | `text` |
|
|
343
|
-
| `json` | `json` | `text` |
|
|
344
|
-
| `geopoint` | `location` (`{ lat, lng, label? }`) | `group` of two `number` props |
|
|
345
|
-
| `table` | `table` with `config.mode: "data"` (one data table: the editor's Columns and Rows drill-in); a Portable Text table inside `richtext` or `document` when it belongs to running copy | `table` with `config.mode: "data"` |
|
|
346
|
-
| `object` | `group` | `group` |
|
|
347
|
-
| `array` of objects | `repeater`; item limits in `config.minItems` / `config.maxItems` (the editor shows "max N") | `table` |
|
|
348
|
-
| `array` of strings | `array` (`itemType: "text"`) | `table` |
|
|
349
|
-
| `array` of several item shapes | one component per shape, placed as sections; on a model, a `repeater` whose rows carry each shape's fields (`modular` opens only in the classic editor) | separate components |
|
|
350
|
-
| `slot` | components placed on the page (componentize); never a `sections` or `component-ref` field, which the page form cannot edit | `slot` (one instance; a list with `config.multiple`) |
|
|
351
|
-
| a form `reference` | a `form` block inside the section | `form` |
|
|
352
|
-
|
|
353
|
-
V2 features with no production shape are dropped and reported, never faked: a per-cell table
|
|
354
|
-
type, per-breakpoint values (only `style.hideOn` differs by breakpoint) and per-field
|
|
355
|
-
localization. Two have a shape now: typed table columns are a `repeater` with one field per column
|
|
356
|
-
(never `ui.layout: "table"`, which the editor cannot draw), and a slot with several children is a `slot` prop with `config.multiple`
|
|
357
|
-
([components.md](components.md) §1).
|
|
358
|
-
|
|
359
|
-
| The plan says | Production word |
|
|
360
|
-
|---|---|
|
|
361
|
-
| Block | Component (the type); section or component instance (the placed band) |
|
|
362
|
-
| Site-wide block, global | Global Layout (`update_layout`) |
|
|
363
|
-
| CMS item | Entry |
|
|
364
|
-
| Collection page | An entry shown through its collection's dynamic page |
|
|
365
|
-
| Static page | `singleton` page |
|
|
366
|
-
| Collection | Content model plus its `dynamic` page |
|
|
367
|
-
| Variant, layout | Components sharing one `sectionType` ([components.md](components.md)) |
|
|
368
|
-
| Listing | `collection` block (§4) |
|
|
369
|
-
| Picks | `multi-reference` |
|
|
370
|
-
| Polymorphic array, modules | one component per shape |
|
|
371
|
-
|
|
372
|
-
Tools and reports use the production word. Labels and help an editor reads never say
|
|
373
|
-
"document": the `document` field is labelled Body.
|
|
374
|
-
|
|
375
|
-
## Acceptance
|
|
376
|
-
|
|
377
|
-
- Every imported route and collection has one clear home; one dynamic template per collection.
|
|
378
|
-
- Sidebar moves keep every public route and record id; URL changes carry explicit redirects
|
|
379
|
-
(`create_redirect`).
|
|
380
|
-
- Repeated data is modelled once, with references or reusable components.
|
|
381
|
-
- Editors find and understand the fields without reading source code.
|
|
382
|
-
- Model changes compile in each consuming site and keep the published output unchanged.
|
|
1
|
+
---
|
|
2
|
+
title: "Structure and content modeling"
|
|
3
|
+
description: "Pages vs collections, the four folder layers, field decisions and naming, and changing a schema safely, including retiring a field in stages."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Structure and content modeling
|
|
7
|
+
|
|
8
|
+
**Outcome.** A content tree an editor understands on day one, durable relationships between
|
|
9
|
+
records, no accidental URL change, and model changes that every consuming site builds against.
|
|
10
|
+
|
|
11
|
+
Doctrine: §1–§5 and §14 of `bettercms://playbook/schema` (schema shape and sections) and
|
|
12
|
+
`bettercms://playbook/structure` (sidebar); flow: `propose_schema` prompt; rule ids
|
|
13
|
+
`CM-10`…`CM-14`, `CM-40`…`CM-44`. What the server does and does not check on a write:
|
|
14
|
+
[write-limits.md](write-limits.md). Planning a whole site by business type:
|
|
15
|
+
[site-blueprints.md](site-blueprints.md).
|
|
16
|
+
|
|
17
|
+
## Contents
|
|
18
|
+
|
|
19
|
+
1. Read the site before modeling
|
|
20
|
+
2. Pages, collections and entries (thresholds, promoting items to a collection)
|
|
21
|
+
3. Four structure layers, four meanings
|
|
22
|
+
4. Field decisions (references, lists, listings, the `document` body, buttons)
|
|
23
|
+
5. Editor labels and order (when to write help text)
|
|
24
|
+
6. Changing a schema safely
|
|
25
|
+
7. Reading a schema written in the V2 language (field type map, glossary)
|
|
26
|
+
|
|
27
|
+
## 1. Read the site before modeling
|
|
28
|
+
|
|
29
|
+
Inventory route files and rendered routes, source data schemas, repeated records, page templates,
|
|
30
|
+
navigation, taxonomies, authors, tags, media references, SEO, forms, locales and publishing
|
|
31
|
+
states. Compare with the project's pages, collections and entries (`list_pages`,
|
|
32
|
+
`list_content_models`, `get_content_structure`). Note where a value is shared, where it is an
|
|
33
|
+
instance override, and where one entry owns it outright.
|
|
34
|
+
|
|
35
|
+
## 2. Pages, collections and entries
|
|
36
|
+
|
|
37
|
+
A page is `singleton` (one page and its own content: home, about, a legal page) or `dynamic`
|
|
38
|
+
(one template page whose collection entries each render at the page's path plus the entry slug).
|
|
39
|
+
Choose by repetition: a shape that repeats per item is a collection; a one-off is a singleton,
|
|
40
|
+
even when it lists items, because the list page reads the collection. A dynamic page is its
|
|
41
|
+
collection: the model and its entries both point at the page (`content_models.page_id`,
|
|
42
|
+
`content_entries.page_id`), so never create a separate page per entry. A dynamic page made with
|
|
43
|
+
`create_page` takes its route pattern and its collection through `update_page { dynamic:
|
|
44
|
+
{ previewPath, modelId } }`: `/blog/{slug}`, and an existing collection with no page yet.
|
|
45
|
+
|
|
46
|
+
A listing elsewhere (latest posts on home, related posts on an article) is a `collection` block
|
|
47
|
+
with `source: { modelId }`, never copied entries. It also takes `filters` (max 5, `{ field, op,
|
|
48
|
+
value }` with the content API operators), `sort` (`-publishedAt,title`), `pinnedIds` (max 10,
|
|
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;
|
|
54
|
+
a route the repo builds does not draw this block, so list there with the content API.
|
|
55
|
+
|
|
56
|
+
| Source pattern | BetterCMS destination |
|
|
57
|
+
|---|---|
|
|
58
|
+
| Home, about, contact, legal, editorial landing page | Singleton page with its own content |
|
|
59
|
+
| Blog / case-study / product list URL | A list page plus a separate collection |
|
|
60
|
+
| Repeated records sharing one template | One collection and one dynamic detail page |
|
|
61
|
+
| 404, search, gated page | A page that keeps its access and noindex behaviour |
|
|
62
|
+
| Author, category, tag, company reused by entries | A shared collection, referenced from the entries |
|
|
63
|
+
| Data from an external runtime API | Keep the adapter and its owner; never fabricate entries |
|
|
64
|
+
| Sitewide nav, footer, promo bar | Global Layout, never copied page fields |
|
|
65
|
+
|
|
66
|
+
**Thresholds.** Three or more pages sharing a URL pattern and a card or detail layout are a
|
|
67
|
+
collection. Two are a collection when the set will grow (jobs, case studies). Otherwise they are
|
|
68
|
+
singletons.
|
|
69
|
+
|
|
70
|
+
**Promote repeater items to a collection** when any one holds; keep them inline when none does
|
|
71
|
+
(pricing bullets, a hero proof line, quotes on a one-off campaign page):
|
|
72
|
+
|
|
73
|
+
| Signal | Example |
|
|
74
|
+
|---|---|
|
|
75
|
+
| The item has its own URL | A case-study card linking to its detail page |
|
|
76
|
+
| The same item shows on two or more pages | A testimonial on home and on a product page |
|
|
77
|
+
| It carries facts that must not drift between copies | Price, stock, rating, job status, a person's photo |
|
|
78
|
+
| Another entry points at it | A post's author, an event's speaker |
|
|
79
|
+
| Editors will filter, sort or count the items | Jobs by team, events by date |
|
|
80
|
+
|
|
81
|
+
## 3. Four structure layers, four meanings
|
|
82
|
+
|
|
83
|
+
Name the layer every time you say "folder". The answer to "where does X live" differs per layer.
|
|
84
|
+
|
|
85
|
+
1. **Content sidebar folders** organise the dashboard only: nothing here reads or writes a URL,
|
|
86
|
+
slug, schema, content or publish state. Read with `get_content_structure`, get a proposal from
|
|
87
|
+
`suggest_content_structure`, apply it whole with `set_content_structure` once the user agrees,
|
|
88
|
+
or change one thing with `create_folder`, `rename_folder`, `move_to_folder` or `delete_folder`
|
|
89
|
+
(deleting a folder moves its contents up). `set_content_structure` replaces the whole document:
|
|
90
|
+
send the `version` you read; a 412 `VERSION_CONFLICT` means someone changed it, so read and
|
|
91
|
+
re-apply. It refuses duplicate folder ids, a parent not in the doc, a cycle, and nesting deeper
|
|
92
|
+
than 4. The standard pins the home page and each list page at the top, gives each content
|
|
93
|
+
family one folder named for its detail type (the list page and its collection are two items),
|
|
94
|
+
keeps other standalone pages in Pages (Pages › <language> on a multilingual site),
|
|
95
|
+
taxonomies used by two or more families in
|
|
96
|
+
Shared, and Settings last. Components are not sidebar items. Whether folders show depends on a
|
|
97
|
+
dashboard feature flag, so an empty sidebar is not proof the structure is missing.
|
|
98
|
+
2. **URL page folders** are part of a page's path unless set not to be included in it. Renaming
|
|
99
|
+
or moving one 301s every affected URL and queues a rebuild: a routing change that needs the
|
|
100
|
+
user's review. They are set in the dashboard, not through the sidebar tools.
|
|
101
|
+
3. **Component groups** (`component_groups`) organise definitions by family or role
|
|
102
|
+
(Navigation, Hero, CTA, Forms), never by the page that uses them ([components.md](components.md) §8).
|
|
103
|
+
4. **Media library folders** organise assets (`folderId` on `create_media_upload`) and never
|
|
104
|
+
rewrite a stored content URL.
|
|
105
|
+
|
|
106
|
+
What reads published structure: the sitemap (skips noindex pages), robots.txt (points at the
|
|
107
|
+
sitemap), RSS (dated by `publishedAt`), `llms.txt` (one link per published item).
|
|
108
|
+
|
|
109
|
+
## 4. Field decisions
|
|
110
|
+
|
|
111
|
+
Before designing a section's fields, find the closest component-kit entry: the chooser by
|
|
112
|
+
category and edge tag is [kit-scenarios.md](kit-scenarios.md), the full list is the
|
|
113
|
+
`bettercms://kit` resource and [kit-catalog.md](kit-catalog.md). The entry's notes are the
|
|
114
|
+
field mapping and its pitfalls the review list; the rules below still decide the storage.
|
|
115
|
+
|
|
116
|
+
- **Model each visual band as a `group` field (or a repeater), or a component instance.** The
|
|
117
|
+
schema's shape decides whether the site can ever be componentized. The componentize lane finds
|
|
118
|
+
a section in a `group`, a repeater, or two or more top-level keys sharing a prefix before `-` or
|
|
119
|
+
`_` (`"hero_title"` + `"hero_subtitle"` → `hero`). A key with NO shared prefix (`title`,
|
|
120
|
+
`intro`) is a lone scalar, refused `NOT_A_SECTION`. Keep ONE separator per band: a `-` family
|
|
121
|
+
and a `_` family never share a component. A `group` is the robust choice, and child keys inside
|
|
122
|
+
it follow the flat-namespace rule (`"hero_title"`).
|
|
123
|
+
- A collection for an entity with its own lifecycle and relationships; a nested group for values
|
|
124
|
+
one page or section owns.
|
|
125
|
+
- A reference for reusable entities and internal links where the model supports it; an external
|
|
126
|
+
URL for links that leave the site.
|
|
127
|
+
- **Resolve references before creating anything.** Build the target collection and its entries
|
|
128
|
+
first, then the model that points at them. Match each source item to an existing entry by
|
|
129
|
+
stable facts (a slug, a product handle, a name plus the start of the quote) and create only what
|
|
130
|
+
is missing. Store the entry id, never an href or a title. A write naming a target that does not
|
|
131
|
+
exist is refused with 400 (ref_missing). Flag an item with no identity (a quote with no name);
|
|
132
|
+
never import it.
|
|
133
|
+
- A repeater for small, same-shaped items (cards, FAQ rows, links, social accounts); a nested
|
|
134
|
+
component or slot when an item has its own layout or variants.
|
|
135
|
+
- **Lists are arrays; tags are taxonomy.** Features, steps, benefits and "what's included" are an
|
|
136
|
+
`array` (`itemType: "text"`), or a repeater when each item carries its own state (included or
|
|
137
|
+
not, an icon). Never a group holding one field. A label set that filters or classifies entries
|
|
138
|
+
is a `tags` field; when each label has its own page, a collection referenced with `multi-reference` (§2).
|
|
139
|
+
- **Never freeze a feed.** A list of entries on the source stays a listing: copying the nine
|
|
140
|
+
visible cards into a repeater stops it updating. On a page BetterCMS renders, that is the
|
|
141
|
+
`collection` block: on the collection's own dynamic page it lists that collection, and with
|
|
142
|
+
`source: { modelId }` it lists any collection on any hosted page (§2). It takes `limit` (1 to
|
|
143
|
+
200), `order` (`newest` or `oldest`) or `sort`, `filters`, `pinnedIds`, `excludeCurrent` and
|
|
144
|
+
`emptyText`; in an article template a filter value may be `{{field}}` of the entry shown, which
|
|
145
|
+
is a "related" list. Pagination and grouping stay a query in the site's code. Always set the
|
|
146
|
+
empty state, never write card titles or images into the listing, and put "read next" on the
|
|
147
|
+
template once, not on each entry.
|
|
148
|
+
- **`document` is the one long body.** A page schema or a collection may hold it, one at
|
|
149
|
+
most, and only as a top-level field: a group or a repeater cannot contain it. Label it Body. Short formatted copy stays `richtext`.
|
|
150
|
+
- **Model a call to action as a single `button` field.** It stores `{ label, href }` plus the
|
|
151
|
+
optional look keys `variant`, `size`, `arrow`, `icon` and `newTab`, and the site draws it with
|
|
152
|
+
`BcmsButton` ([astro.md](astro.md), [next.md](next.md)). Never a text
|
|
153
|
+
field beside a link field: the pair has nowhere to keep a look, and a new pair in the importer's
|
|
154
|
+
spelling (`hero-span-go` + `hero-a-go-href`) is refused with 400 `cta_pair` on `add_field`,
|
|
155
|
+
`create_page` and model writes. A page that already holds a pair (`primaryCtaText` +
|
|
156
|
+
`primaryCtaHref`) becomes one button with `update_field` `{ pageId, path: "primaryCtaText",
|
|
157
|
+
mergeHref: "primaryCtaHref" }`: each entry's DRAFT gets `{ label, href }` and keeps the old values,
|
|
158
|
+
so code still reading them keeps rendering; the live copy changes on publish. On a route the
|
|
159
|
+
site's app renders at request time the merge is refused (409 `CODE_RENDERED_ROUTE`): change the
|
|
160
|
+
code to draw the CTA with `BcmsButton`, and the next build adds the button field. Inside a component
|
|
161
|
+
a CTA is a `button` block whose `props` one `button` prop targets ([components.md](components.md) §1).
|
|
162
|
+
- A sponsored, untrusted or user-generated link takes `rel` (`nofollow`, `sponsored` or `ugc`) on the
|
|
163
|
+
button, or a `link` field value written as `{ href, rel?, newTab? }`; code that opts into the object reads `.href`.
|
|
164
|
+
- A `date` field with a time takes `timezone` (`site` or an IANA name such as `Europe/Paris`) in its config;
|
|
165
|
+
values must then end in `Z` or an offset, and a bracketed zone suffix is dropped on save.
|
|
166
|
+
- Never model SEO columns ([seo.md](seo.md)), Form questions (they live on the Form document) or
|
|
167
|
+
Global Layout values as content fields.
|
|
168
|
+
- Keep source ids, slugs, locale, status and previous URLs as migration metadata, stable across
|
|
169
|
+
re-imports.
|
|
170
|
+
- Lock a field whose value another system owns (a store, job board or feed id): `readOnly: "developer"` with a `lockReason`.
|
|
171
|
+
Only schema access may then change the value or the lock; other writes are refused (403 `FIELD_LOCKED`) unless they send the stored value back.
|
|
172
|
+
- **Prefer a `select` over a `boolean` for a state that may grow** (`draft | review | live`, not
|
|
173
|
+
`isLive`). Why: no tool retypes a field, so a third state later costs a new field and a value
|
|
174
|
+
migration (§6).
|
|
175
|
+
- Choose field types before the first write. A `link` accepts any scheme-less href (`/about`,
|
|
176
|
+
`about.html`, `#section`) and `http(s):`, `mailto:`, `tel:`; every other scheme is refused.
|
|
177
|
+
Choose the type from what the rendered value is, not habit ([portable-text.md](portable-text.md)).
|
|
178
|
+
|
|
179
|
+
## 5. Editor labels and order
|
|
180
|
+
|
|
181
|
+
**Name for meaning, not look.** Before writing a key or label, ask: would the name survive a
|
|
182
|
+
redesign? `ctaText`, `authorBio` and `features` pass; `blueButton`, `leftColumn` and `bigHeading`
|
|
183
|
+
fail, because the next redesign makes them lie.
|
|
184
|
+
|
|
185
|
+
Short labels named for what the editor sees: Heading, Body, Primary button, Label, Link, Image,
|
|
186
|
+
Alt text — or the name the site's code already uses (`cents` → Cents), never the copy itself
|
|
187
|
+
(`.00`). An import names every field it derives the same way, by its section and its role
|
|
188
|
+
there: "Hero headline", "Hero intro", "Hero primary button", "Pricing plans", "Footer link 2
|
|
189
|
+
URL", "Header logo"; a list's columns by their role in the row ("Title", "Description", "Link
|
|
190
|
+
URL"). A role is numbered only where it repeats in one section, and labels are unique on a page.
|
|
191
|
+
Keys never change. Rename a label where the site's code has a better word: a name you give is
|
|
192
|
+
kept on every later release, and one the import gave follows the import. Most fields need no help line from you (below). Group by what renders
|
|
193
|
+
together, not by the DOM or the source file: core content first, optional appearance second, technical fields hidden. Repeatable items need a meaningful preview label (the FAQ question, the
|
|
194
|
+
button label), reorder, and stable item ids. An empty optional field removes its element without
|
|
195
|
+
leaving a gap; a long value wraps, and clamps only where the design intends it.
|
|
196
|
+
|
|
197
|
+
### Writing help text
|
|
198
|
+
|
|
199
|
+
Most fields get no help. The platform writes a line only where one of the four reasons below
|
|
200
|
+
holds and leaves every other element with none, so leaving `helpText` out is the default, not a
|
|
201
|
+
gap. It says so: `helpText: { source: "auto" }` in the response, `helpSource: "auto"` on the
|
|
202
|
+
stored field. Text you send is stored as yours (`helpSource: "agent"`) and is never rewritten;
|
|
203
|
+
`editor_setup` rewrites only its own lines and never a person's.
|
|
204
|
+
|
|
205
|
+
Ask one question per field: does the editor need this line to type the right value? Write your
|
|
206
|
+
own `helpText` (on a field or a prop) only for one of four reasons, written as one short plain
|
|
207
|
+
sentence under 90 characters, with no semicolon, no dash and no figure taken from the page.
|
|
208
|
+
|
|
209
|
+
| Reason | Good |
|
|
210
|
+
|---|---|
|
|
211
|
+
| A format or unit the label cannot show | Price in cents. · Length in minutes. · A .vtt caption file. |
|
|
212
|
+
| Where the value shows, when the canvas cannot show it | Shown in search results and link previews. · Read by screen readers, not shown. |
|
|
213
|
+
| A real constraint or consequence | Changing this breaks existing links. · Must be later than the start date. |
|
|
214
|
+
| A fallback | Leave empty to use the page title. |
|
|
215
|
+
|
|
216
|
+
Everything else is left out:
|
|
217
|
+
|
|
218
|
+
| Never | Bad | Do instead |
|
|
219
|
+
|---|---|---|
|
|
220
|
+
| The label or the value again | Heading: "The section's main heading." · `e.g. "Starter Kit"` | Nothing. The API refuses help that echoes the value |
|
|
221
|
+
| Coaching the writing | "Keep it short." · "Two to four words works best." · "Start with a verb." | A length is a counter: a number in `config` (`"maxLength": 60`, §6) |
|
|
222
|
+
| An example of the value | Badge: "e.g. Most popular" | A `placeholder` where the control takes one (component props do) |
|
|
223
|
+
| Strategy | Secondary button: "A lower-commitment option that builds trust." | Nothing. The label says which button it is |
|
|
224
|
+
| Internal notes | "Renders as h2." · "Label and link are one unit." · a ticket or decision id | Keep them in the code or the report, never in the form |
|
|
225
|
+
| Generic good practice | "Add alt text for screen readers." on every image | Nothing |
|
|
226
|
+
|
|
227
|
+
One sentence, plain words, sentence case. If more than about one field in five carries your own
|
|
228
|
+
line, you are over-writing. A component, a collection and a Layout Section (`update-section`)
|
|
229
|
+
still take a `description` that says what the thing is ("Hero with a heading, supporting text,
|
|
230
|
+
two buttons and an image"), never its name again.
|
|
231
|
+
|
|
232
|
+
`get_next_steps` lists `help-text-missing` for an element the editor setup has not settled yet, or
|
|
233
|
+
one whose line only echoes its label, and `get_binding_report` and `get_site_composition` carry the
|
|
234
|
+
coverage as `help`. The platform writes a line only for the four reasons above and leaves most
|
|
235
|
+
elements with none; clear the finding by running `editor_setup`, or by writing one of the four
|
|
236
|
+
kinds yourself, never with filler.
|
|
237
|
+
|
|
238
|
+
**Config keys.** Write only the `config` keys a field type documents (`maxLength`, `min`/`max`,
|
|
239
|
+
`options`, `itemType`, `zones`, a rich text's `accept`/`inline`): any other key sends that field to
|
|
240
|
+
the old classic editor. The import's own keys (`attr`, `numeric`, `units`, `band`, `seeded`, …) are the
|
|
241
|
+
server's: both MCP surfaces drop them from what you write.
|
|
242
|
+
|
|
243
|
+
## 6. Changing a schema safely
|
|
244
|
+
|
|
245
|
+
A schema write alone is not a release. Before one, read the model with `get_content_model`, a few
|
|
246
|
+
sample entries, the component definitions that touch it and the frontend readers, and write a
|
|
247
|
+
before/after field map: key, type, label, required, default, validation, editor group, and what
|
|
248
|
+
happens to existing values.
|
|
249
|
+
|
|
250
|
+
1. **Guard concurrent edits.** Pass the model's `optimisticVersion` as `ifMatch` to
|
|
251
|
+
`update_content_model`. A 409 means it changed since you read it: re-read and re-plan, never
|
|
252
|
+
resend. Omitted, the last write wins, except on the editor-chrome args, which refuse without
|
|
253
|
+
it (428).
|
|
254
|
+
2. **Know what the MCP can do.** `add_field` (a model's `modelId` or a page's `pageId`) appends
|
|
255
|
+
and never overwrites or retypes. `update_field` changes ONE existing field, addressed by a dot
|
|
256
|
+
path through its groups and repeaters (`hero.title`): its label, help text, `required`,
|
|
257
|
+
validation `config` (merged by key; null deletes one), `showIf`, `ui` or placement, written at
|
|
258
|
+
the version it read. A retype needs `allowRetype: true`, because stored values that do not fit
|
|
259
|
+
the new type are lost; a key rename needs the migration in step 3. `update_content_model` edits
|
|
260
|
+
the model's name, slug, URL pattern and fieldsets, and removes model fields only with
|
|
261
|
+
`removeFields` (step 3). The home page cannot be deleted, and changing a local type file is not
|
|
262
|
+
a schema change.
|
|
263
|
+
3. **Retire or rename a field in stages, never in one write.** Renaming a key re-addresses its
|
|
264
|
+
content: `update_field` with a new `key` is refused with 409 and the count of entries holding
|
|
265
|
+
the old key, unless `allowFieldRemoval: true` discards their values. So a rename is a new field
|
|
266
|
+
plus a migration:
|
|
267
|
+
1. Relabel the old field "Old title (Deprecated)" and set help text naming its replacement
|
|
268
|
+
(`update_field` `label` and `helpText`).
|
|
269
|
+
2. Move it into a `collapsed: true` fieldset so editors stop reaching for it
|
|
270
|
+
([editor-chrome.md](editor-chrome.md) §2).
|
|
271
|
+
3. Add the replacement (`add_field`, with `modelId` or `pageId`) with the final type and name.
|
|
272
|
+
4. Migrate values in code, draft first, and have the frontend read the new key.
|
|
273
|
+
5. Remove the old one. Page fields: `remove_page_fields` (dry run by default) returns
|
|
274
|
+
`keysWithContent` and writes nothing; read it, then run it with `dryRun: false` and
|
|
275
|
+
`discardContent: true` only for keys you migrated. Model fields: `update_content_model` with
|
|
276
|
+
`removeFields: ["oldKey"]` and `allowFieldRemoval: true`, sent with the `ifMatch` of your
|
|
277
|
+
read. It has NO dry run and discards that key's value in every entry, so count the entries
|
|
278
|
+
holding it first (`list_content_entries` with `modelId`: `meta.total` is the count, `page`
|
|
279
|
+
walks past the 200-row `limit`). Top-level keys only; an unknown key refuses the
|
|
280
|
+
whole call; a page's own fields go through `remove_page_fields`. Both discard data: removal
|
|
281
|
+
is the last step, with the user's yes.
|
|
282
|
+
Why: every stage before 5 is reversible, and a site built against the old key keeps rendering
|
|
283
|
+
until its next release reads the new one.
|
|
284
|
+
4. **Put numeric limits in `config` as numbers** (`"maxLength": 60`), with `update_field` on a
|
|
285
|
+
field that exists. A numeric string is converted on save; an impossible value is refused with
|
|
286
|
+
`FIELD_CONFIG_INVALID`.
|
|
287
|
+
5. **Check existing records before tightening validation.** Backfill or give a real default
|
|
288
|
+
before making a field required; keep a source-to-target map and a snapshot for any high-volume
|
|
289
|
+
transform.
|
|
290
|
+
6. **Validate on a draft.** Entries written through the MCP pass one write path (request schema,
|
|
291
|
+
normalisation, per-field checks, uniqueness), and a write failing any step stores nothing.
|
|
292
|
+
Publishing copies the draft to live, so a draft that passes is the value that ships.
|
|
293
|
+
7. **Change a component family together** (same prop keys and types on every variant;
|
|
294
|
+
[components.md](components.md) §1 and §3).
|
|
295
|
+
8. **Regenerate types** with `bettercms-codegen` in every consuming site after any model or field
|
|
296
|
+
change, commit the output if the repo commits it, and run that repo's build and typecheck with
|
|
297
|
+
the script it already has. Templates typed against an old schema compile and read `undefined`.
|
|
298
|
+
9. **Verify** the editor's grouping and controls, one draft edit per changed field class, the
|
|
299
|
+
preview binding, the static or headless reading path, and the public output once a release is
|
|
300
|
+
authorized.
|
|
301
|
+
|
|
302
|
+
Content models, frontend types, component definitions and live content have separate lifecycles;
|
|
303
|
+
there is no single "deploy schema" action. Production publication stays behind the user's
|
|
304
|
+
authorization and the component readiness and Visual Approval gates.
|
|
305
|
+
|
|
306
|
+
**Evidence to return:** model and field ids, the version you wrote against, migrated record
|
|
307
|
+
count, codegen and build result, one editor check and one rendered-page check, warnings, and any
|
|
308
|
+
change the platform could not make (an open dependency, never a success).
|
|
309
|
+
|
|
310
|
+
## 7. Reading a schema written in the V2 language
|
|
311
|
+
|
|
312
|
+
A plan or Kit sample may arrive in the V2 schema language. It is an intermediate model only:
|
|
313
|
+
production cannot store it. Translate before the first write.
|
|
314
|
+
|
|
315
|
+
- **Keys.** V2 names are camelCase and scoped per object (`hero.heading`). Production keys are
|
|
316
|
+
flat, one namespace per model, prefixed by section: `"hero_heading"`.
|
|
317
|
+
- **Labels and help.** V2 `title` is `label`; V2 `description` is `helpText` (§5 decides whether
|
|
318
|
+
it is written at all).
|
|
319
|
+
- **Limits.** V2 `validation` becomes numbers in `config` (§6 step 4).
|
|
320
|
+
|
|
321
|
+
| V2 type | Model field (`create_content_model`, `add_field`) | Component prop (`create_component`) |
|
|
322
|
+
|---|---|---|
|
|
323
|
+
| `string` | `text` with `richText: false` | `text` |
|
|
324
|
+
| `text` | `longtext` | `text` |
|
|
325
|
+
| `richtext` | `richtext` for section copy; `document` for the one body | `richtext` |
|
|
326
|
+
| `number` | `number` | `number` |
|
|
327
|
+
| `rating` | `number` with `config.min`, `max`, `step` and `ui.control: "rating"` (stars) | `number` |
|
|
328
|
+
| `boolean` | `boolean` | `boolean` |
|
|
329
|
+
| `slug` | `slug` (an entry's own slug stays on the entry) | `text` |
|
|
330
|
+
| `url`, `link` | `link` | `link`; `url` for a media or embed address |
|
|
331
|
+
| `email`, `phone` | `email`; a phone number is `text` with `richText: false` (`phone` opens only in the classic editor) | `text` |
|
|
332
|
+
| `date`, `datetime` | `date`, `datetime` | `date` (`YYYY-MM-DD`); a datetime stays `text` |
|
|
333
|
+
| `color` | `color` | `color` |
|
|
334
|
+
| `select` | `select` | `select` with `config.options` |
|
|
335
|
+
| `multiselect` | `multiselect` with `config.options` (a closed `string[]` choice list; `min`/`max` count the choices) | `multiselect` with `config.options` |
|
|
336
|
+
| `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` |
|
|
337
|
+
| `image` | `image` | `image` |
|
|
338
|
+
| `file`, `video` | `file`; a hosted video URL is a `link` | `file`; `url` for a hosted video |
|
|
339
|
+
| `icon` | `icon` (an icon name or an uploaded image) | `icon` (an icon name or an image) |
|
|
340
|
+
| `button` | `button` | `button`: one prop targeting the `props` of a `button` block (§4) |
|
|
341
|
+
| `reference` | `reference`; `multi-reference` for many | none: keep it on the model |
|
|
342
|
+
| `code` | `longtext` | `text` |
|
|
343
|
+
| `json` | `json` | `text` |
|
|
344
|
+
| `geopoint` | `location` (`{ lat, lng, label? }`) | `group` of two `number` props |
|
|
345
|
+
| `table` | `table` with `config.mode: "data"` (one data table: the editor's Columns and Rows drill-in); a Portable Text table inside `richtext` or `document` when it belongs to running copy | `table` with `config.mode: "data"` |
|
|
346
|
+
| `object` | `group` | `group` |
|
|
347
|
+
| `array` of objects | `repeater`; item limits in `config.minItems` / `config.maxItems` (the editor shows "max N") | `table` |
|
|
348
|
+
| `array` of strings | `array` (`itemType: "text"`) | `table` |
|
|
349
|
+
| `array` of several item shapes | one component per shape, placed as sections; on a model, a `repeater` whose rows carry each shape's fields (`modular` opens only in the classic editor) | separate components |
|
|
350
|
+
| `slot` | components placed on the page (componentize); never a `sections` or `component-ref` field, which the page form cannot edit | `slot` (one instance; a list with `config.multiple`) |
|
|
351
|
+
| a form `reference` | a `form` block inside the section | `form` |
|
|
352
|
+
|
|
353
|
+
V2 features with no production shape are dropped and reported, never faked: a per-cell table
|
|
354
|
+
type, per-breakpoint values (only `style.hideOn` differs by breakpoint) and per-field
|
|
355
|
+
localization. Two have a shape now: typed table columns are a `repeater` with one field per column
|
|
356
|
+
(never `ui.layout: "table"`, which the editor cannot draw), and a slot with several children is a `slot` prop with `config.multiple`
|
|
357
|
+
([components.md](components.md) §1).
|
|
358
|
+
|
|
359
|
+
| The plan says | Production word |
|
|
360
|
+
|---|---|
|
|
361
|
+
| Block | Component (the type); section or component instance (the placed band) |
|
|
362
|
+
| Site-wide block, global | Global Layout (`update_layout`) |
|
|
363
|
+
| CMS item | Entry |
|
|
364
|
+
| Collection page | An entry shown through its collection's dynamic page |
|
|
365
|
+
| Static page | `singleton` page |
|
|
366
|
+
| Collection | Content model plus its `dynamic` page |
|
|
367
|
+
| Variant, layout | Components sharing one `sectionType` ([components.md](components.md)) |
|
|
368
|
+
| Listing | `collection` block (§4) |
|
|
369
|
+
| Picks | `multi-reference` |
|
|
370
|
+
| Polymorphic array, modules | one component per shape |
|
|
371
|
+
|
|
372
|
+
Tools and reports use the production word. Labels and help an editor reads never say
|
|
373
|
+
"document": the `document` field is labelled Body.
|
|
374
|
+
|
|
375
|
+
## Acceptance
|
|
376
|
+
|
|
377
|
+
- Every imported route and collection has one clear home; one dynamic template per collection.
|
|
378
|
+
- Sidebar moves keep every public route and record id; URL changes carry explicit redirects
|
|
379
|
+
(`create_redirect`).
|
|
380
|
+
- Repeated data is modelled once, with references or reusable components.
|
|
381
|
+
- Editors find and understand the fields without reading source code.
|
|
382
|
+
- Model changes compile in each consuming site and keep the published output unchanged.
|