@bettercms-ai/mcp 0.83.0 → 0.83.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +86 -86
  2. package/SKILL.md +160 -160
  3. package/dist/index.js +38 -9
  4. package/dist/index.js.map +1 -1
  5. package/dist/kit-catalog.json +1509 -1509
  6. package/dist/skills/bettercms/SKILL.md +206 -206
  7. package/dist/skills/bettercms/references/accessibility.md +171 -171
  8. package/dist/skills/bettercms/references/animation.md +296 -296
  9. package/dist/skills/bettercms/references/astro.md +148 -148
  10. package/dist/skills/bettercms/references/bindings.md +366 -366
  11. package/dist/skills/bettercms/references/componentize.md +185 -185
  12. package/dist/skills/bettercms/references/components.md +415 -415
  13. package/dist/skills/bettercms/references/content-experiments.md +62 -62
  14. package/dist/skills/bettercms/references/content-modeling.md +382 -382
  15. package/dist/skills/bettercms/references/detection.md +465 -465
  16. package/dist/skills/bettercms/references/dock-input.md +121 -121
  17. package/dist/skills/bettercms/references/editor-chrome.md +92 -92
  18. package/dist/skills/bettercms/references/external-content.md +78 -78
  19. package/dist/skills/bettercms/references/finish-site.md +377 -377
  20. package/dist/skills/bettercms/references/forms-and-interactions.md +245 -245
  21. package/dist/skills/bettercms/references/frameworks.md +159 -159
  22. package/dist/skills/bettercms/references/getting-started.md +156 -156
  23. package/dist/skills/bettercms/references/integrations.md +364 -364
  24. package/dist/skills/bettercms/references/kit-catalog.md +3259 -3259
  25. package/dist/skills/bettercms/references/kit-scenarios.md +712 -712
  26. package/dist/skills/bettercms/references/localization.md +85 -85
  27. package/dist/skills/bettercms/references/media-and-brand.md +181 -181
  28. package/dist/skills/bettercms/references/next.md +189 -189
  29. package/dist/skills/bettercms/references/other-frameworks.md +85 -85
  30. package/dist/skills/bettercms/references/portable-text.md +213 -213
  31. package/dist/skills/bettercms/references/programmatic-seo.md +98 -98
  32. package/dist/skills/bettercms/references/publishing.md +289 -289
  33. package/dist/skills/bettercms/references/seo.md +357 -357
  34. package/dist/skills/bettercms/references/shopify-headless.md +52 -52
  35. package/dist/skills/bettercms/references/site-blueprints.md +895 -895
  36. package/dist/skills/bettercms/references/site-review.md +74 -74
  37. package/dist/skills/bettercms/references/worked-examples-advanced.md +509 -509
  38. package/dist/skills/bettercms/references/worked-examples.md +885 -885
  39. package/dist/skills/bettercms/references/write-limits.md +217 -217
  40. package/package.json +1 -1
@@ -1,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.