@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,213 +1,213 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: "Field types and Portable Text"
|
|
3
|
-
description: "Choosing field types, the Portable Text write contract and grammar, converting HTML or Markdown structurally, and serializing rich text."
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Field types and Portable Text
|
|
7
|
-
|
|
8
|
-
**Outcome.** The editor shows the right control for each visible value, the page keeps its
|
|
9
|
-
semantic HTML, a button or card title cannot turn into the wrong heading level, and every rich
|
|
10
|
-
text value you write is stored and rendered as written. Binding grammar:
|
|
11
|
-
[bindings.md](bindings.md); field and schema doctrine: `bettercms://playbook/schema`.
|
|
12
|
-
|
|
13
|
-
## 1. Decide what the user is editing
|
|
14
|
-
|
|
15
|
-
Inspect the rendered element, its source component, role, ancestors, repeated instances and
|
|
16
|
-
responsive variants, and the existing schema. Keep four concerns apart; they must not collapse
|
|
17
|
-
into one rich-text field:
|
|
18
|
-
|
|
19
|
-
- **content** (the words),
|
|
20
|
-
- **semantic tag** (H1, H2, p, button),
|
|
21
|
-
- **presentation** (font, size, colour),
|
|
22
|
-
- **behaviour** (href, modal trigger).
|
|
23
|
-
|
|
24
|
-
Choose a field's type, label, validation and allowed rich-text features from its context on the
|
|
25
|
-
page, not from a rule that every string is rich text. When one source value renders in two
|
|
26
|
-
semantic contexts, split the display role from the content, or make the level a controlled
|
|
27
|
-
component prop, never an open toolbar choice.
|
|
28
|
-
|
|
29
|
-
## 2. Semantic field matrix
|
|
30
|
-
|
|
31
|
-
The model's field types are `text`, `richtext`, `document` (the article body, at most one per
|
|
32
|
-
collection), `image`, `boolean`, `number`, `select` and references, among others. The matrix is
|
|
33
|
-
intended UX; confirm each control exists in the connected editor before promising it.
|
|
34
|
-
|
|
35
|
-
| Rendered role | Field | Editor options | Render rule |
|
|
36
|
-
|---|---|---|---|
|
|
37
|
-
| Page or hero title | text, or one-block richtext | title role only; inline emphasis if the design needs it | one H1 per page; a title shown twice is not two H1s |
|
|
38
|
-
| Section heading | text | no heading picker; level comes from the component | usually H2, H3 when nested |
|
|
39
|
-
| Card or item title | text | no toolbar | H3/H4 by nesting; a reused card never adds an H1 |
|
|
40
|
-
| Eyebrow, badge, button label, nav item, form label | text | plain | keep the real element (`span`, `a`, `button`, `label`) |
|
|
41
|
-
| Summary | text; richtext only when inline links are needed | normal paragraphs, few marks | `p`; no heading or list controls |
|
|
42
|
-
| Article body | `document` | the schema's allowed blocks | keep the whole outline and every supported node |
|
|
43
|
-
| FAQ question | text | plain | inside an accessible trigger; the component owns the level |
|
|
44
|
-
| FAQ answer, modal body | narrowed richtext | paragraphs, lists, links, emphasis | focus and ARIA live outside the value |
|
|
45
|
-
| Image | image | asset, alt text, caption | bind `src` and `alt` separately |
|
|
46
|
-
| Link | link | the destination | no raw HTML, no `javascript:` URL |
|
|
47
|
-
| Button / call to action | one `button` field | label, link, style, size, arrow, icon, new tab | render with `BcmsButton`; never a text + link pair |
|
|
48
|
-
|
|
49
|
-
"Disabled" controls mean the field's accept list removes irrelevant formatting; the field stays
|
|
50
|
-
editable. Keep a source's unusual but valid hierarchy when changing it would alter meaning, and
|
|
51
|
-
flag it for review. The page title is the page's `h1`; body rich text starts at `h2`. Group the
|
|
52
|
-
side panel by what the editor sees (`Button → Label, Link, Style`;
|
|
53
|
-
`Image → Asset, Alt text, Caption`) and show the instance scope.
|
|
54
|
-
|
|
55
|
-
## 3. The write contract
|
|
56
|
-
|
|
57
|
-
- Write a richtext value as a Portable Text array. An array passes straight through to storage.
|
|
58
|
-
- Never write a bare string to a richtext field. On write a bare string is parsed as HTML or
|
|
59
|
-
Markdown (a conversion you did not review), while the reader treats a bare string as literal
|
|
60
|
-
text, so the same string means two different things depending on the path.
|
|
61
|
-
- The stored value is an envelope `{ format: "portable-text-1", value, html }`. `value` is the
|
|
62
|
-
truth; `html` is a derived cache. Never write or patch `html`.
|
|
63
|
-
- A per-paragraph address (`base#<_key>`) is an edit target, not a write path: write the whole
|
|
64
|
-
field ([bindings.md](bindings.md) §3).
|
|
65
|
-
- The `/presence/pt-patch` endpoint relays live edits between people in the editor. It is not a
|
|
66
|
-
save. Persist through the content write tools.
|
|
67
|
-
|
|
68
|
-
## 4. Portable Text rules
|
|
69
|
-
|
|
70
|
-
Names come from `packages/richtext/src/schema.ts`. A field's `accept` list narrows them; use only
|
|
71
|
-
names the field accepts.
|
|
72
|
-
|
|
73
|
-
| Part | Allowed names | Granted by accept element |
|
|
74
|
-
|---|---|---|
|
|
75
|
-
| block `style` | `normal` always; `h1`…`h6`; `blockquote` | heading element; `p` |
|
|
76
|
-
| decorators (span `marks`) | `strong`, `em`, `underline`, `strike-through`, `code`, `highlight` | `b`, `i`, `u`, `s`, `code`, `mark` |
|
|
77
|
-
| annotations (`markDefs`) | `link` {href, target, className}; `styled` {tag, className, attrs} | `link`/`internalLink`; always |
|
|
78
|
-
| `listItem` | `bullet`, `number`, `check` | `ul`, `ol`, `checklist` |
|
|
79
|
-
| block objects | `bcmsDivider`, `pullQuote` {quote, name, role?, link?}; `keyTakeaways` {title?, points}; `bcmsCode` {code, language}; `bcmsBlock` {schemaKey, schemaLabel, fields}; `table` {rows} | `p`; `ul`; `pre`; `image`/`embed`/`columns`; `table` |
|
|
80
|
-
| inline objects | `bcmsMention` {label} | always |
|
|
81
|
-
|
|
82
|
-
- **Keys**: every block, span, markDef, row and cell has `_key` matching `^[A-Za-z0-9-]{1,32}$`,
|
|
83
|
-
unique in the document. Keys are editor addresses, so keep existing ones.
|
|
84
|
-
- **Marks**: a span's `marks` entry is a decorator name or the `_key` of a markDef in the same
|
|
85
|
-
block. Every markDef is referenced by at least one span.
|
|
86
|
-
- **Lists are flat**: a list item is a normal block with `listItem` and `level` (1 = top). Never
|
|
87
|
-
nest blocks inside a list container. A `check` item carries `checked: true|false`.
|
|
88
|
-
- **Pull quote**: `{_type:"pullQuote", _key, quote, name, role?, link?}`; `quote` and `name` are
|
|
89
|
-
strings, `link` is a URL (or a link object with `href`) the name links to. A `pullQuote` without
|
|
90
|
-
string `quote` and `name` is refused on write. Renders `<figure data-rt="pullquote">`.
|
|
91
|
-
- **Key takeaways**: `{_type:"keyTakeaways", _key, title?, points}`; `points` is an array of plain
|
|
92
|
-
strings, one per bullet. More than 8 points or a title over 40 characters is refused on write,
|
|
93
|
-
by code and path. Renders `<aside data-rt="takeaways">`.
|
|
94
|
-
- **Tables**: a cell holds blocks in `content`, not a string. `header: true` marks a `<th>` cell;
|
|
95
|
-
`head: true` puts a row in `<thead>`.
|
|
96
|
-
- **`styled`** carries a site's own inline markup; `tag` is one of `span`, `small`, `sup`, `sub`,
|
|
97
|
-
`kbd`, `abbr`, `time`, or a decorator tag (`strong`, `em`, `b`, `i`, `u`, `s`, `mark`, `code`, …)
|
|
98
|
-
that carries a class. A `link` keeps its class as `className`.
|
|
99
|
-
- **Top-level `_type`** is one of `block`, `bcmsBlock`, `table`, `bcmsCode`, `bcmsDivider`,
|
|
100
|
-
`bcmsMention`, `pullQuote`, `keyTakeaways`. A name outside that list is refused on write, by
|
|
101
|
-
name and `_key`, because the block would disappear from the live site. The refusal is **top level
|
|
102
|
-
only**: a foreign mark passes and its text renders unmarked; a foreign inline child passes
|
|
103
|
-
and renders nothing. Check those yourself.
|
|
104
|
-
|
|
105
|
-
A paragraph with a bold word and a link:
|
|
106
|
-
|
|
107
|
-
<!-- pt-example -->
|
|
108
|
-
```json
|
|
109
|
-
[
|
|
110
|
-
{
|
|
111
|
-
"_type": "block",
|
|
112
|
-
"_key": "intro",
|
|
113
|
-
"style": "normal",
|
|
114
|
-
"markDefs": [{ "_type": "link", "_key": "lnk1", "href": "/pricing" }],
|
|
115
|
-
"children": [
|
|
116
|
-
{ "_type": "span", "_key": "s1", "text": "See ", "marks": [] },
|
|
117
|
-
{ "_type": "span", "_key": "s2", "text": "pricing", "marks": ["strong", "lnk1"] }
|
|
118
|
-
]
|
|
119
|
-
}
|
|
120
|
-
]
|
|
121
|
-
```
|
|
122
|
-
|
|
123
|
-
A flat two-level bullet list:
|
|
124
|
-
|
|
125
|
-
<!-- pt-example -->
|
|
126
|
-
```json
|
|
127
|
-
[
|
|
128
|
-
{ "_type": "block", "_key": "li1", "style": "normal", "listItem": "bullet", "level": 1, "markDefs": [],
|
|
129
|
-
"children": [{ "_type": "span", "_key": "li1s", "text": "Plans", "marks": [] }] },
|
|
130
|
-
{ "_type": "block", "_key": "li2", "style": "normal", "listItem": "bullet", "level": 2, "markDefs": [],
|
|
131
|
-
"children": [{ "_type": "span", "_key": "li2s", "text": "Monthly", "marks": [] }] }
|
|
132
|
-
]
|
|
133
|
-
```
|
|
134
|
-
|
|
135
|
-
A table with a header row:
|
|
136
|
-
|
|
137
|
-
<!-- pt-example -->
|
|
138
|
-
```json
|
|
139
|
-
[
|
|
140
|
-
{
|
|
141
|
-
"_type": "table",
|
|
142
|
-
"_key": "tbl",
|
|
143
|
-
"rows": [
|
|
144
|
-
{ "_key": "r1", "head": true, "cells": [
|
|
145
|
-
{ "_key": "c1", "header": true, "content": [
|
|
146
|
-
{ "_type": "block", "_key": "c1b", "style": "normal", "markDefs": [],
|
|
147
|
-
"children": [{ "_type": "span", "_key": "c1s", "text": "Plan", "marks": [] }] }
|
|
148
|
-
] }
|
|
149
|
-
] },
|
|
150
|
-
{ "_key": "r2", "cells": [
|
|
151
|
-
{ "_key": "c2", "content": [
|
|
152
|
-
{ "_type": "block", "_key": "c2b", "style": "normal", "markDefs": [],
|
|
153
|
-
"children": [{ "_type": "span", "_key": "c2s", "text": "Pro", "marks": [] }] }
|
|
154
|
-
] }
|
|
155
|
-
] }
|
|
156
|
-
]
|
|
157
|
-
}
|
|
158
|
-
]
|
|
159
|
-
```
|
|
160
|
-
|
|
161
|
-
Site markup kept through `styled`:
|
|
162
|
-
|
|
163
|
-
<!-- pt-example -->
|
|
164
|
-
```json
|
|
165
|
-
[
|
|
166
|
-
{
|
|
167
|
-
"_type": "block",
|
|
168
|
-
"_key": "price",
|
|
169
|
-
"style": "h2",
|
|
170
|
-
"markDefs": [{ "_type": "styled", "_key": "sm1", "tag": "small", "className": "muted" }],
|
|
171
|
-
"children": [
|
|
172
|
-
{ "_type": "span", "_key": "p1", "text": "$29 ", "marks": [] },
|
|
173
|
-
{ "_type": "span", "_key": "p2", "text": "per month", "marks": ["sm1"] }
|
|
174
|
-
]
|
|
175
|
-
}
|
|
176
|
-
]
|
|
177
|
-
```
|
|
178
|
-
|
|
179
|
-
## 5. Converting into Portable Text
|
|
180
|
-
|
|
181
|
-
1. Parse HTML, Markdown, MDX or source-CMS blocks structurally; never flatten markup with regex.
|
|
182
|
-
2. Map headings, paragraphs, lists, links, marks, tables, images and embedded components to
|
|
183
|
-
supported nodes. Resolve internal links to page or entry references where possible.
|
|
184
|
-
3. Sanitise HTML and URLs. Keep the source path and write a loss report for every unsupported
|
|
185
|
-
node, embed or script. A foreign custom block needs a renderer and a mapping, or a visible
|
|
186
|
-
conversion warning.
|
|
187
|
-
4. Generate deterministic keys on the first import; preserve them on later edits and re-imports.
|
|
188
|
-
5. Compare text, link count and targets, heading outline, list levels and media references
|
|
189
|
-
between source and result.
|
|
190
|
-
|
|
191
|
-
## 6. Serializing out of Portable Text
|
|
192
|
-
|
|
193
|
-
1. Render every allowed style, list type, decorator, annotation, inline object and custom block
|
|
194
|
-
explicitly. Report unknown nodes; never drop them silently.
|
|
195
|
-
2. Use a context-aware renderer for internal links, images and embeds, and keep their
|
|
196
|
-
accessibility labels.
|
|
197
|
-
3. Build plain-text and SEO excerpts from the semantic text, with preview markers stripped
|
|
198
|
-
(`stripStega`) and no HTML or hidden JSON.
|
|
199
|
-
4. Markdown export loses interactive custom blocks; emit typed references or a loss report, and
|
|
200
|
-
never claim a lossless round trip.
|
|
201
|
-
|
|
202
|
-
The public Portable Text specification describes the general block model. BetterCMS's own schema
|
|
203
|
-
and accept list decide the subset; do not paste Sanity-only types or serializers.
|
|
204
|
-
|
|
205
|
-
## Acceptance
|
|
206
|
-
|
|
207
|
-
- Page title, section title, card title, button label, summary and article body each open the
|
|
208
|
-
expected controls; a reused card on a second page adds no H1.
|
|
209
|
-
- Rich text keeps links, nested marks, lists, tables, custom blocks and stable keys; unknown
|
|
210
|
-
nodes are logged, and nothing disappears silently.
|
|
211
|
-
- Changing a label, a URL or a style changes only that property on that instance.
|
|
212
|
-
- Long, empty and translated text render acceptably.
|
|
213
|
-
- After writing rich text, `get_binding_report` for the route is clean.
|
|
1
|
+
---
|
|
2
|
+
title: "Field types and Portable Text"
|
|
3
|
+
description: "Choosing field types, the Portable Text write contract and grammar, converting HTML or Markdown structurally, and serializing rich text."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Field types and Portable Text
|
|
7
|
+
|
|
8
|
+
**Outcome.** The editor shows the right control for each visible value, the page keeps its
|
|
9
|
+
semantic HTML, a button or card title cannot turn into the wrong heading level, and every rich
|
|
10
|
+
text value you write is stored and rendered as written. Binding grammar:
|
|
11
|
+
[bindings.md](bindings.md); field and schema doctrine: `bettercms://playbook/schema`.
|
|
12
|
+
|
|
13
|
+
## 1. Decide what the user is editing
|
|
14
|
+
|
|
15
|
+
Inspect the rendered element, its source component, role, ancestors, repeated instances and
|
|
16
|
+
responsive variants, and the existing schema. Keep four concerns apart; they must not collapse
|
|
17
|
+
into one rich-text field:
|
|
18
|
+
|
|
19
|
+
- **content** (the words),
|
|
20
|
+
- **semantic tag** (H1, H2, p, button),
|
|
21
|
+
- **presentation** (font, size, colour),
|
|
22
|
+
- **behaviour** (href, modal trigger).
|
|
23
|
+
|
|
24
|
+
Choose a field's type, label, validation and allowed rich-text features from its context on the
|
|
25
|
+
page, not from a rule that every string is rich text. When one source value renders in two
|
|
26
|
+
semantic contexts, split the display role from the content, or make the level a controlled
|
|
27
|
+
component prop, never an open toolbar choice.
|
|
28
|
+
|
|
29
|
+
## 2. Semantic field matrix
|
|
30
|
+
|
|
31
|
+
The model's field types are `text`, `richtext`, `document` (the article body, at most one per
|
|
32
|
+
collection), `image`, `boolean`, `number`, `select` and references, among others. The matrix is
|
|
33
|
+
intended UX; confirm each control exists in the connected editor before promising it.
|
|
34
|
+
|
|
35
|
+
| Rendered role | Field | Editor options | Render rule |
|
|
36
|
+
|---|---|---|---|
|
|
37
|
+
| Page or hero title | text, or one-block richtext | title role only; inline emphasis if the design needs it | one H1 per page; a title shown twice is not two H1s |
|
|
38
|
+
| Section heading | text | no heading picker; level comes from the component | usually H2, H3 when nested |
|
|
39
|
+
| Card or item title | text | no toolbar | H3/H4 by nesting; a reused card never adds an H1 |
|
|
40
|
+
| Eyebrow, badge, button label, nav item, form label | text | plain | keep the real element (`span`, `a`, `button`, `label`) |
|
|
41
|
+
| Summary | text; richtext only when inline links are needed | normal paragraphs, few marks | `p`; no heading or list controls |
|
|
42
|
+
| Article body | `document` | the schema's allowed blocks | keep the whole outline and every supported node |
|
|
43
|
+
| FAQ question | text | plain | inside an accessible trigger; the component owns the level |
|
|
44
|
+
| FAQ answer, modal body | narrowed richtext | paragraphs, lists, links, emphasis | focus and ARIA live outside the value |
|
|
45
|
+
| Image | image | asset, alt text, caption | bind `src` and `alt` separately |
|
|
46
|
+
| Link | link | the destination | no raw HTML, no `javascript:` URL |
|
|
47
|
+
| Button / call to action | one `button` field | label, link, style, size, arrow, icon, new tab | render with `BcmsButton`; never a text + link pair |
|
|
48
|
+
|
|
49
|
+
"Disabled" controls mean the field's accept list removes irrelevant formatting; the field stays
|
|
50
|
+
editable. Keep a source's unusual but valid hierarchy when changing it would alter meaning, and
|
|
51
|
+
flag it for review. The page title is the page's `h1`; body rich text starts at `h2`. Group the
|
|
52
|
+
side panel by what the editor sees (`Button → Label, Link, Style`;
|
|
53
|
+
`Image → Asset, Alt text, Caption`) and show the instance scope.
|
|
54
|
+
|
|
55
|
+
## 3. The write contract
|
|
56
|
+
|
|
57
|
+
- Write a richtext value as a Portable Text array. An array passes straight through to storage.
|
|
58
|
+
- Never write a bare string to a richtext field. On write a bare string is parsed as HTML or
|
|
59
|
+
Markdown (a conversion you did not review), while the reader treats a bare string as literal
|
|
60
|
+
text, so the same string means two different things depending on the path.
|
|
61
|
+
- The stored value is an envelope `{ format: "portable-text-1", value, html }`. `value` is the
|
|
62
|
+
truth; `html` is a derived cache. Never write or patch `html`.
|
|
63
|
+
- A per-paragraph address (`base#<_key>`) is an edit target, not a write path: write the whole
|
|
64
|
+
field ([bindings.md](bindings.md) §3).
|
|
65
|
+
- The `/presence/pt-patch` endpoint relays live edits between people in the editor. It is not a
|
|
66
|
+
save. Persist through the content write tools.
|
|
67
|
+
|
|
68
|
+
## 4. Portable Text rules
|
|
69
|
+
|
|
70
|
+
Names come from `packages/richtext/src/schema.ts`. A field's `accept` list narrows them; use only
|
|
71
|
+
names the field accepts.
|
|
72
|
+
|
|
73
|
+
| Part | Allowed names | Granted by accept element |
|
|
74
|
+
|---|---|---|
|
|
75
|
+
| block `style` | `normal` always; `h1`…`h6`; `blockquote` | heading element; `p` |
|
|
76
|
+
| decorators (span `marks`) | `strong`, `em`, `underline`, `strike-through`, `code`, `highlight` | `b`, `i`, `u`, `s`, `code`, `mark` |
|
|
77
|
+
| annotations (`markDefs`) | `link` {href, target, className}; `styled` {tag, className, attrs} | `link`/`internalLink`; always |
|
|
78
|
+
| `listItem` | `bullet`, `number`, `check` | `ul`, `ol`, `checklist` |
|
|
79
|
+
| block objects | `bcmsDivider`, `pullQuote` {quote, name, role?, link?}; `keyTakeaways` {title?, points}; `bcmsCode` {code, language}; `bcmsBlock` {schemaKey, schemaLabel, fields}; `table` {rows} | `p`; `ul`; `pre`; `image`/`embed`/`columns`; `table` |
|
|
80
|
+
| inline objects | `bcmsMention` {label} | always |
|
|
81
|
+
|
|
82
|
+
- **Keys**: every block, span, markDef, row and cell has `_key` matching `^[A-Za-z0-9-]{1,32}$`,
|
|
83
|
+
unique in the document. Keys are editor addresses, so keep existing ones.
|
|
84
|
+
- **Marks**: a span's `marks` entry is a decorator name or the `_key` of a markDef in the same
|
|
85
|
+
block. Every markDef is referenced by at least one span.
|
|
86
|
+
- **Lists are flat**: a list item is a normal block with `listItem` and `level` (1 = top). Never
|
|
87
|
+
nest blocks inside a list container. A `check` item carries `checked: true|false`.
|
|
88
|
+
- **Pull quote**: `{_type:"pullQuote", _key, quote, name, role?, link?}`; `quote` and `name` are
|
|
89
|
+
strings, `link` is a URL (or a link object with `href`) the name links to. A `pullQuote` without
|
|
90
|
+
string `quote` and `name` is refused on write. Renders `<figure data-rt="pullquote">`.
|
|
91
|
+
- **Key takeaways**: `{_type:"keyTakeaways", _key, title?, points}`; `points` is an array of plain
|
|
92
|
+
strings, one per bullet. More than 8 points or a title over 40 characters is refused on write,
|
|
93
|
+
by code and path. Renders `<aside data-rt="takeaways">`.
|
|
94
|
+
- **Tables**: a cell holds blocks in `content`, not a string. `header: true` marks a `<th>` cell;
|
|
95
|
+
`head: true` puts a row in `<thead>`.
|
|
96
|
+
- **`styled`** carries a site's own inline markup; `tag` is one of `span`, `small`, `sup`, `sub`,
|
|
97
|
+
`kbd`, `abbr`, `time`, or a decorator tag (`strong`, `em`, `b`, `i`, `u`, `s`, `mark`, `code`, …)
|
|
98
|
+
that carries a class. A `link` keeps its class as `className`.
|
|
99
|
+
- **Top-level `_type`** is one of `block`, `bcmsBlock`, `table`, `bcmsCode`, `bcmsDivider`,
|
|
100
|
+
`bcmsMention`, `pullQuote`, `keyTakeaways`. A name outside that list is refused on write, by
|
|
101
|
+
name and `_key`, because the block would disappear from the live site. The refusal is **top level
|
|
102
|
+
only**: a foreign mark passes and its text renders unmarked; a foreign inline child passes
|
|
103
|
+
and renders nothing. Check those yourself.
|
|
104
|
+
|
|
105
|
+
A paragraph with a bold word and a link:
|
|
106
|
+
|
|
107
|
+
<!-- pt-example -->
|
|
108
|
+
```json
|
|
109
|
+
[
|
|
110
|
+
{
|
|
111
|
+
"_type": "block",
|
|
112
|
+
"_key": "intro",
|
|
113
|
+
"style": "normal",
|
|
114
|
+
"markDefs": [{ "_type": "link", "_key": "lnk1", "href": "/pricing" }],
|
|
115
|
+
"children": [
|
|
116
|
+
{ "_type": "span", "_key": "s1", "text": "See ", "marks": [] },
|
|
117
|
+
{ "_type": "span", "_key": "s2", "text": "pricing", "marks": ["strong", "lnk1"] }
|
|
118
|
+
]
|
|
119
|
+
}
|
|
120
|
+
]
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
A flat two-level bullet list:
|
|
124
|
+
|
|
125
|
+
<!-- pt-example -->
|
|
126
|
+
```json
|
|
127
|
+
[
|
|
128
|
+
{ "_type": "block", "_key": "li1", "style": "normal", "listItem": "bullet", "level": 1, "markDefs": [],
|
|
129
|
+
"children": [{ "_type": "span", "_key": "li1s", "text": "Plans", "marks": [] }] },
|
|
130
|
+
{ "_type": "block", "_key": "li2", "style": "normal", "listItem": "bullet", "level": 2, "markDefs": [],
|
|
131
|
+
"children": [{ "_type": "span", "_key": "li2s", "text": "Monthly", "marks": [] }] }
|
|
132
|
+
]
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
A table with a header row:
|
|
136
|
+
|
|
137
|
+
<!-- pt-example -->
|
|
138
|
+
```json
|
|
139
|
+
[
|
|
140
|
+
{
|
|
141
|
+
"_type": "table",
|
|
142
|
+
"_key": "tbl",
|
|
143
|
+
"rows": [
|
|
144
|
+
{ "_key": "r1", "head": true, "cells": [
|
|
145
|
+
{ "_key": "c1", "header": true, "content": [
|
|
146
|
+
{ "_type": "block", "_key": "c1b", "style": "normal", "markDefs": [],
|
|
147
|
+
"children": [{ "_type": "span", "_key": "c1s", "text": "Plan", "marks": [] }] }
|
|
148
|
+
] }
|
|
149
|
+
] },
|
|
150
|
+
{ "_key": "r2", "cells": [
|
|
151
|
+
{ "_key": "c2", "content": [
|
|
152
|
+
{ "_type": "block", "_key": "c2b", "style": "normal", "markDefs": [],
|
|
153
|
+
"children": [{ "_type": "span", "_key": "c2s", "text": "Pro", "marks": [] }] }
|
|
154
|
+
] }
|
|
155
|
+
] }
|
|
156
|
+
]
|
|
157
|
+
}
|
|
158
|
+
]
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Site markup kept through `styled`:
|
|
162
|
+
|
|
163
|
+
<!-- pt-example -->
|
|
164
|
+
```json
|
|
165
|
+
[
|
|
166
|
+
{
|
|
167
|
+
"_type": "block",
|
|
168
|
+
"_key": "price",
|
|
169
|
+
"style": "h2",
|
|
170
|
+
"markDefs": [{ "_type": "styled", "_key": "sm1", "tag": "small", "className": "muted" }],
|
|
171
|
+
"children": [
|
|
172
|
+
{ "_type": "span", "_key": "p1", "text": "$29 ", "marks": [] },
|
|
173
|
+
{ "_type": "span", "_key": "p2", "text": "per month", "marks": ["sm1"] }
|
|
174
|
+
]
|
|
175
|
+
}
|
|
176
|
+
]
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## 5. Converting into Portable Text
|
|
180
|
+
|
|
181
|
+
1. Parse HTML, Markdown, MDX or source-CMS blocks structurally; never flatten markup with regex.
|
|
182
|
+
2. Map headings, paragraphs, lists, links, marks, tables, images and embedded components to
|
|
183
|
+
supported nodes. Resolve internal links to page or entry references where possible.
|
|
184
|
+
3. Sanitise HTML and URLs. Keep the source path and write a loss report for every unsupported
|
|
185
|
+
node, embed or script. A foreign custom block needs a renderer and a mapping, or a visible
|
|
186
|
+
conversion warning.
|
|
187
|
+
4. Generate deterministic keys on the first import; preserve them on later edits and re-imports.
|
|
188
|
+
5. Compare text, link count and targets, heading outline, list levels and media references
|
|
189
|
+
between source and result.
|
|
190
|
+
|
|
191
|
+
## 6. Serializing out of Portable Text
|
|
192
|
+
|
|
193
|
+
1. Render every allowed style, list type, decorator, annotation, inline object and custom block
|
|
194
|
+
explicitly. Report unknown nodes; never drop them silently.
|
|
195
|
+
2. Use a context-aware renderer for internal links, images and embeds, and keep their
|
|
196
|
+
accessibility labels.
|
|
197
|
+
3. Build plain-text and SEO excerpts from the semantic text, with preview markers stripped
|
|
198
|
+
(`stripStega`) and no HTML or hidden JSON.
|
|
199
|
+
4. Markdown export loses interactive custom blocks; emit typed references or a loss report, and
|
|
200
|
+
never claim a lossless round trip.
|
|
201
|
+
|
|
202
|
+
The public Portable Text specification describes the general block model. BetterCMS's own schema
|
|
203
|
+
and accept list decide the subset; do not paste Sanity-only types or serializers.
|
|
204
|
+
|
|
205
|
+
## Acceptance
|
|
206
|
+
|
|
207
|
+
- Page title, section title, card title, button label, summary and article body each open the
|
|
208
|
+
expected controls; a reused card on a second page adds no H1.
|
|
209
|
+
- Rich text keeps links, nested marks, lists, tables, custom blocks and stable keys; unknown
|
|
210
|
+
nodes are logged, and nothing disappears silently.
|
|
211
|
+
- Changing a label, a URL or a style changes only that property on that instance.
|
|
212
|
+
- Long, empty and translated text render acceptably.
|
|
213
|
+
- After writing rich text, `get_binding_report` for the route is clean.
|
|
@@ -1,98 +1,98 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: "Programmatic SEO"
|
|
3
|
-
description: "Page families from data: deciding a family should exist, generating from a dataset through a reviewed job, and the indexability gate."
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Programmatic SEO: page families from data
|
|
7
|
-
|
|
8
|
-
**Outcome.** A maintainable family of genuinely useful pages, each with a clear search intent
|
|
9
|
-
and verifiable content, without flooding the index with near-duplicates.
|
|
10
|
-
|
|
11
|
-
This supplements [site-blueprints.md](site-blueprints.md) (route and model shape) and
|
|
12
|
-
[seo.md](seo.md) (native meta, structured data, sitemap). It never grants permission to publish a large batch.
|
|
13
|
-
|
|
14
|
-
## 1. Decide whether a family should exist
|
|
15
|
-
|
|
16
|
-
Build an intent matrix before a model. For each family record audience, intent, the unique
|
|
17
|
-
answer, data source, owner, update cadence, expected URL count and one real example.
|
|
18
|
-
|
|
19
|
-
| Family | A good reason for a distinct page | Often a poor reason |
|
|
20
|
-
|---|---|---|
|
|
21
|
-
| Glossary term | Precise definition, examples, related concepts, source and review owner | A reworded dictionary sentence per keyword |
|
|
22
|
-
| Solution × industry | Different workflow, constraints, capability and proof | The same template with the industry name swapped |
|
|
23
|
-
| Integration | A shipped connector, setup, limitations, status | An unshipped logo or speculative partnership |
|
|
24
|
-
| Comparison | Fair, current criteria and evidence | Generated competitor assertions |
|
|
25
|
-
| Location | Real local presence, service terms, team or availability | A city name substituted into generic copy |
|
|
26
|
-
|
|
27
|
-
If a candidate has no unique value, keep it unpublished or fold it into a stronger parent.
|
|
28
|
-
Google's [spam policies](https://developers.google.com/search/docs/essentials/spam-policies)
|
|
29
|
-
name large volumes of unoriginal pages made for rankings as scaled content abuse, and its
|
|
30
|
-
[AI-search guidance](https://developers.google.com/search/docs/fundamentals/ai-optimization-guide)
|
|
31
|
-
warns against a page for every phrasing of a query.
|
|
32
|
-
|
|
33
|
-
## 2. Model once, generate selectively
|
|
34
|
-
|
|
35
|
-
1. **One entity collection per durable subject** (`GlossaryTerm`, `Integration`, `Industry`)
|
|
36
|
-
with one dynamic detail template. The collection's `urlPattern` (for example
|
|
37
|
-
`/glossary/:slug`) is its entry URL, set with `create_content_model` or
|
|
38
|
-
`update_content_model`. It must contain `:slug` exactly once, and when an entry's slug
|
|
39
|
-
changes the platform adds the 301 from the old URL. Connect subjects with references so each
|
|
40
|
-
fact lives once.
|
|
41
|
-
2. **Cross-product pages need an approved pair record.** Never generate the Cartesian product of
|
|
42
|
-
two collections. The pair record holds the pair-specific insight, evidence, related entities,
|
|
43
|
-
owner, last review date and status.
|
|
44
|
-
3. **Fields by editorial responsibility:** title and slug, a concise answer, original body or
|
|
45
|
-
sections, facts and sources, related links, imagery with alt text, and review state. Meta is
|
|
46
|
-
native ([seo.md](seo.md)), never a model field. Shape rules: [content-modeling.md](content-modeling.md).
|
|
47
|
-
4. **Routes from stable ids and slugs.** Check case, punctuation, locale, reserved names,
|
|
48
|
-
collisions with static routes and previously published slugs. Any other moved URL gets a
|
|
49
|
-
redirect with `create_redirect`. A slug is an address, not a keyword field.
|
|
50
|
-
5. **Everything stays draft until reviewed.** Record provenance for data and AI-assisted copy.
|
|
51
|
-
Never invent sources, testimonials, integrations or capabilities.
|
|
52
|
-
|
|
53
|
-
### Generating from a dataset
|
|
54
|
-
|
|
55
|
-
The `generate_landing_pages` prompt is the guided flow for this step (one template plus a
|
|
56
|
-
dataset; for per-account pages it hands over to the `bettercms-abm` skill's order).
|
|
57
|
-
`generate_pages_from_dataset` turns rows into pages from a template page, a mapping (model, a
|
|
58
|
-
slug template such as `for-{{term}}`, and per-field values that are a column or an AI prompt)
|
|
59
|
-
and the rows. Always run it with `dryRun: true` first and show the user the sample: the dry run
|
|
60
|
-
writes and charges nothing. A real run waits for approval, and only the user's yes releases it
|
|
61
|
-
through `approve_ai_job`; never approve your own plan.
|
|
62
|
-
|
|
63
|
-
## 3. Indexability gate
|
|
64
|
-
|
|
65
|
-
Decide `publish`, `publish-noindex`, `merge/redirect` or `omit` for every candidate. An
|
|
66
|
-
indexable page needs:
|
|
67
|
-
|
|
68
|
-
- a working 200 route with substantial distinct value for its intent;
|
|
69
|
-
- a stable canonical that matches the internal-link and sitemap form;
|
|
70
|
-
- visible facts consistent with the title, description and any structured data;
|
|
71
|
-
- no conflict with a stronger parent, a duplicate, a locale variant or a filtered URL;
|
|
72
|
-
- a path from relevant hubs and child pages, not only the XML sitemap;
|
|
73
|
-
- an accountable owner and review date when facts can change.
|
|
74
|
-
|
|
75
|
-
A noindex page is left out of the hosted sitemap; keep drafts, empty records, duplicate
|
|
76
|
-
combinations, internal search and facet URLs out too. `robots.txt` does not remove a page from
|
|
77
|
-
the index, and a blocked URL does not belong in the sitemap.
|
|
78
|
-
|
|
79
|
-
## 4. Navigation, sitemaps and scale
|
|
80
|
-
|
|
81
|
-
- Give each family a readable hub (`/glossary/`, `/integrations/`) with crawlable `<a href>`
|
|
82
|
-
links to its children. A sitemap is an inventory aid, not navigation.
|
|
83
|
-
- Split sitemap files before Google's 50,000 URL / 50 MB limit: [sitemap guide](https://developers.google.com/search/docs/crawling-indexing/sitemaps/build-sitemap).
|
|
84
|
-
- Keep one path pattern per family, and do not move an established path to fit it. Treat
|
|
85
|
-
locales as explicit equivalents: [URL guidance](https://developers.google.com/search/docs/crawling-indexing/url-structure).
|
|
86
|
-
- At volume, generate inventory and validation reports with deterministic code. Use the MCP for
|
|
87
|
-
project state, model setup, samples and targeted repairs. Dry-run creates, updates and
|
|
88
|
-
deletes, confirm exact counts, batch with resumable ids, and reconcile later changes without
|
|
89
|
-
overwriting editor edits ([publishing.md](publishing.md) §5).
|
|
90
|
-
|
|
91
|
-
## Acceptance and reporting
|
|
92
|
-
|
|
93
|
-
Sample one page per family plus edge cases: shortest and longest record, no image, no evidence,
|
|
94
|
-
duplicate slug, archived entry, locale variant, empty hub. Verify edit → preview → public HTML,
|
|
95
|
-
head and JSON-LD, indexability, internal links, sitemap inclusion and redirects.
|
|
96
|
-
|
|
97
|
-
Report candidates, published and indexable, published noindex, merged, omitted, broken, and
|
|
98
|
-
pending review. A successful bulk write alone never completes the family.
|
|
1
|
+
---
|
|
2
|
+
title: "Programmatic SEO"
|
|
3
|
+
description: "Page families from data: deciding a family should exist, generating from a dataset through a reviewed job, and the indexability gate."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Programmatic SEO: page families from data
|
|
7
|
+
|
|
8
|
+
**Outcome.** A maintainable family of genuinely useful pages, each with a clear search intent
|
|
9
|
+
and verifiable content, without flooding the index with near-duplicates.
|
|
10
|
+
|
|
11
|
+
This supplements [site-blueprints.md](site-blueprints.md) (route and model shape) and
|
|
12
|
+
[seo.md](seo.md) (native meta, structured data, sitemap). It never grants permission to publish a large batch.
|
|
13
|
+
|
|
14
|
+
## 1. Decide whether a family should exist
|
|
15
|
+
|
|
16
|
+
Build an intent matrix before a model. For each family record audience, intent, the unique
|
|
17
|
+
answer, data source, owner, update cadence, expected URL count and one real example.
|
|
18
|
+
|
|
19
|
+
| Family | A good reason for a distinct page | Often a poor reason |
|
|
20
|
+
|---|---|---|
|
|
21
|
+
| Glossary term | Precise definition, examples, related concepts, source and review owner | A reworded dictionary sentence per keyword |
|
|
22
|
+
| Solution × industry | Different workflow, constraints, capability and proof | The same template with the industry name swapped |
|
|
23
|
+
| Integration | A shipped connector, setup, limitations, status | An unshipped logo or speculative partnership |
|
|
24
|
+
| Comparison | Fair, current criteria and evidence | Generated competitor assertions |
|
|
25
|
+
| Location | Real local presence, service terms, team or availability | A city name substituted into generic copy |
|
|
26
|
+
|
|
27
|
+
If a candidate has no unique value, keep it unpublished or fold it into a stronger parent.
|
|
28
|
+
Google's [spam policies](https://developers.google.com/search/docs/essentials/spam-policies)
|
|
29
|
+
name large volumes of unoriginal pages made for rankings as scaled content abuse, and its
|
|
30
|
+
[AI-search guidance](https://developers.google.com/search/docs/fundamentals/ai-optimization-guide)
|
|
31
|
+
warns against a page for every phrasing of a query.
|
|
32
|
+
|
|
33
|
+
## 2. Model once, generate selectively
|
|
34
|
+
|
|
35
|
+
1. **One entity collection per durable subject** (`GlossaryTerm`, `Integration`, `Industry`)
|
|
36
|
+
with one dynamic detail template. The collection's `urlPattern` (for example
|
|
37
|
+
`/glossary/:slug`) is its entry URL, set with `create_content_model` or
|
|
38
|
+
`update_content_model`. It must contain `:slug` exactly once, and when an entry's slug
|
|
39
|
+
changes the platform adds the 301 from the old URL. Connect subjects with references so each
|
|
40
|
+
fact lives once.
|
|
41
|
+
2. **Cross-product pages need an approved pair record.** Never generate the Cartesian product of
|
|
42
|
+
two collections. The pair record holds the pair-specific insight, evidence, related entities,
|
|
43
|
+
owner, last review date and status.
|
|
44
|
+
3. **Fields by editorial responsibility:** title and slug, a concise answer, original body or
|
|
45
|
+
sections, facts and sources, related links, imagery with alt text, and review state. Meta is
|
|
46
|
+
native ([seo.md](seo.md)), never a model field. Shape rules: [content-modeling.md](content-modeling.md).
|
|
47
|
+
4. **Routes from stable ids and slugs.** Check case, punctuation, locale, reserved names,
|
|
48
|
+
collisions with static routes and previously published slugs. Any other moved URL gets a
|
|
49
|
+
redirect with `create_redirect`. A slug is an address, not a keyword field.
|
|
50
|
+
5. **Everything stays draft until reviewed.** Record provenance for data and AI-assisted copy.
|
|
51
|
+
Never invent sources, testimonials, integrations or capabilities.
|
|
52
|
+
|
|
53
|
+
### Generating from a dataset
|
|
54
|
+
|
|
55
|
+
The `generate_landing_pages` prompt is the guided flow for this step (one template plus a
|
|
56
|
+
dataset; for per-account pages it hands over to the `bettercms-abm` skill's order).
|
|
57
|
+
`generate_pages_from_dataset` turns rows into pages from a template page, a mapping (model, a
|
|
58
|
+
slug template such as `for-{{term}}`, and per-field values that are a column or an AI prompt)
|
|
59
|
+
and the rows. Always run it with `dryRun: true` first and show the user the sample: the dry run
|
|
60
|
+
writes and charges nothing. A real run waits for approval, and only the user's yes releases it
|
|
61
|
+
through `approve_ai_job`; never approve your own plan.
|
|
62
|
+
|
|
63
|
+
## 3. Indexability gate
|
|
64
|
+
|
|
65
|
+
Decide `publish`, `publish-noindex`, `merge/redirect` or `omit` for every candidate. An
|
|
66
|
+
indexable page needs:
|
|
67
|
+
|
|
68
|
+
- a working 200 route with substantial distinct value for its intent;
|
|
69
|
+
- a stable canonical that matches the internal-link and sitemap form;
|
|
70
|
+
- visible facts consistent with the title, description and any structured data;
|
|
71
|
+
- no conflict with a stronger parent, a duplicate, a locale variant or a filtered URL;
|
|
72
|
+
- a path from relevant hubs and child pages, not only the XML sitemap;
|
|
73
|
+
- an accountable owner and review date when facts can change.
|
|
74
|
+
|
|
75
|
+
A noindex page is left out of the hosted sitemap; keep drafts, empty records, duplicate
|
|
76
|
+
combinations, internal search and facet URLs out too. `robots.txt` does not remove a page from
|
|
77
|
+
the index, and a blocked URL does not belong in the sitemap.
|
|
78
|
+
|
|
79
|
+
## 4. Navigation, sitemaps and scale
|
|
80
|
+
|
|
81
|
+
- Give each family a readable hub (`/glossary/`, `/integrations/`) with crawlable `<a href>`
|
|
82
|
+
links to its children. A sitemap is an inventory aid, not navigation.
|
|
83
|
+
- Split sitemap files before Google's 50,000 URL / 50 MB limit: [sitemap guide](https://developers.google.com/search/docs/crawling-indexing/sitemaps/build-sitemap).
|
|
84
|
+
- Keep one path pattern per family, and do not move an established path to fit it. Treat
|
|
85
|
+
locales as explicit equivalents: [URL guidance](https://developers.google.com/search/docs/crawling-indexing/url-structure).
|
|
86
|
+
- At volume, generate inventory and validation reports with deterministic code. Use the MCP for
|
|
87
|
+
project state, model setup, samples and targeted repairs. Dry-run creates, updates and
|
|
88
|
+
deletes, confirm exact counts, batch with resumable ids, and reconcile later changes without
|
|
89
|
+
overwriting editor edits ([publishing.md](publishing.md) §5).
|
|
90
|
+
|
|
91
|
+
## Acceptance and reporting
|
|
92
|
+
|
|
93
|
+
Sample one page per family plus edge cases: shortest and longest record, no image, no evidence,
|
|
94
|
+
duplicate slug, archived entry, locale variant, empty hub. Verify edit → preview → public HTML,
|
|
95
|
+
head and JSON-LD, indexability, internal links, sitemap inclusion and redirects.
|
|
96
|
+
|
|
97
|
+
Report candidates, published and indexable, published noindex, merged, omitted, broken, and
|
|
98
|
+
pending review. A successful bulk write alone never completes the family.
|