@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,509 +1,509 @@
1
- ---
2
- title: "Worked examples: entries, chrome and nested blocks"
3
- description: "Three more conversions from source HTML to a production schema, value and bound template: a blog post as a collection entry, a mega menu in the Global Layout, and tabs holding columns of cards."
4
- ---
5
-
6
- # Worked examples, continued: entries, chrome and nested blocks
7
-
8
- Examples 1 to 5, the table of which shape to use, and the rules every example follows are in
9
- [worked-examples.md](worked-examples.md). Each example here runs the same five steps: source
10
- markup (trimmed), reasoning, schema, value, bound template. Rules behind the shapes:
11
- [content-modeling.md](content-modeling.md), [components.md](components.md),
12
- [bindings.md](bindings.md), [portable-text.md](portable-text.md).
13
-
14
- ## Contents
15
-
16
- - [6. Blog post as a collection entry](#6-blog-post-as-a-collection-entry)
17
- - [7. Mega menu in the Global Layout](#7-mega-menu-in-the-global-layout)
18
- - [8. Tabs holding columns of cards](#8-tabs-holding-columns-of-cards)
19
- - [Checks](#checks)
20
-
21
- ## 6. Blog post as a collection entry
22
-
23
- Source: one post of many, on `/blog/why-your-month-end-close-takes-eight-days`.
24
-
25
- ```html
26
- <article class="post" itemscope itemtype="https://schema.org/BlogPosting">
27
- <header class="post__header">
28
- <a class="post__topic" href="/blog/topic/finance-ops">Finance ops</a>
29
- <h1 itemprop="headline">Why your month-end close takes eight days</h1>
30
- <p class="post__dek">Three small problems repeat every month. Here is how to remove them.</p>
31
- <div class="byline">
32
- <a rel="author" href="/authors/priya-shah">Priya Shah</a>
33
- <time datetime="2026-09-18T09:00:00-04:00">September 18, 2026</time> · <span>7 min read</span>
34
- </div>
35
- </header>
36
- <figure class="post__cover">
37
- <img src="/blog/close-cover.jpg" alt="A calendar with the last week of the month circled in red" width="1600" height="900">
38
- <figcaption>Photo: Alder Studio</figcaption>
39
- </figure>
40
- <div class="post__body prose">
41
- <p>Most month-end closes do not run late because of hard problems. They run late because of three small ones that repeat every month.</p>
42
- <h2 id="where-the-days-go">Where the days go</h2>
43
- <p>We timed 41 finance teams through a close. On average, <strong>62% of the time</strong> went to matching and chasing, not to judgement.</p>
44
- <ul><li>Bank lines without a reference</li><li>Card spend with no receipt attached</li></ul>
45
- <figure class="wp-block-image"><img src="/blog/close-time-chart.png" alt="Bar chart: matching takes 3.1 days, review 1.2 days, reporting 0.8 days"><figcaption>Median time per close stage, 41 teams, Q2 2026.</figcaption></figure>
46
- <table class="wp-block-table"><thead><tr><th>Stage</th><th>Manual</th></tr></thead><tbody><tr><td>Matching</td><td>3.1 days</td></tr></tbody></table>
47
- <figure class="wp-block-embed is-provider-youtube"><iframe src="https://www.youtube.com/embed/dQw4w9WgXcQ" title="Ten-minute close walkthrough"></iframe></figure>
48
- <blockquote class="pullquote"><p>The close got shorter the week we stopped exporting CSVs.</p></blockquote>
49
- <pre><code class="language-sql">SELECT * FROM bank_lines WHERE reference IS NULL;</code></pre>
50
- <p>Want the checklist we use? <a href="/resources/close-checklist">Download it here</a>.</p>
51
- </div>
52
- <aside class="share">...</aside>
53
- <section class="related">...</section>
54
- </article>
55
- ```
56
-
57
- Reasoning:
58
-
59
- 1. An `<article>` with its own URL, a byline and a date, one of many: an entry of a content model
60
- with one dynamic page. Never a page per post.
61
- 2. Title, summary, cover and caption are fields. The author and the topic are entries of their
62
- own models, referenced by id: find or create `/authors/priya-shah` and the topic first.
63
- 3. The date comes from `<time datetime>`: ISO 8601 with its offset, exactly as written.
64
- 4. "7 min read" is computed by the template from the body. Share buttons and related posts are
65
- template parts. None of the three is stored.
66
- 5. The body is the one `document` field. Headings start at `h2` (the title is the `h1`). Lists
67
- become flat list blocks; the figure, the embed, the table and the code block become block
68
- objects.
69
- 6. No SEO fields and no microdata. Meta values go in the entry's `meta`; BlogPosting markup is
70
- automatic for a collection with a body, a date and an author.
71
-
72
- Schema (`create_content_model`):
73
-
74
- ```json
75
- {
76
- "name": "Post",
77
- "slug": "post",
78
- "urlPattern": "/blog/:slug",
79
- "fields": [
80
- { "key": "title", "label": "Title", "type": "text", "richText": false, "required": true, "placement": "title" },
81
- { "key": "excerpt", "label": "Summary", "type": "text", "richText": false, "placement": "excerpt" },
82
- { "key": "cover", "label": "Cover image", "type": "image", "placement": "cover" },
83
- { "key": "cover_caption", "label": "Cover caption", "type": "text", "richText": false },
84
- { "key": "author", "label": "Author", "type": "reference", "required": true, "config": { "contentModelId": "<author model id>" } },
85
- { "key": "topics", "label": "Topics", "type": "multi-reference", "config": { "contentModelId": "<topic model id>", "max": 3 } },
86
- { "key": "date", "label": "Publish date", "type": "datetime" },
87
- { "key": "body", "label": "Body", "type": "document" }
88
- ]
89
- }
90
- ```
91
-
92
- Then the template page: `create_page` with `pageType: "dynamic"`, and `update_page` with
93
- `dynamic: { "previewPath": "/blog/{slug}", "modelId": "<post model id>" }`.
94
-
95
- Value (`create_content_entry`; `body` is the Portable Text array below):
96
-
97
- ```jsonc
98
- {
99
- "contentModelId": "<post model id>",
100
- "slug": "why-your-month-end-close-takes-eight-days",
101
- "data": {
102
- "title": "Why your month-end close takes eight days",
103
- "excerpt": "Three small problems repeat every month. Here is how to remove them.",
104
- "cover": { "url": "/blog/close-cover.jpg", "altText": "A calendar with the last week of the month circled in red" },
105
- "cover_caption": "Photo: Alder Studio",
106
- "author": "<priya-shah entry id>",
107
- "topics": ["<finance-ops entry id>"],
108
- "date": "2026-09-18T09:00:00-04:00",
109
- "body": [ /* the array below */ ]
110
- }
111
- }
112
- ```
113
-
114
- <!-- pt-example -->
115
- ```json
116
- [
117
- { "_type": "block", "_key": "b01", "style": "normal", "markDefs": [],
118
- "children": [{ "_type": "span", "_key": "b01s1", "text": "Most month-end closes do not run late because of hard problems. They run late because of three small ones that repeat every month.", "marks": [] }] },
119
- { "_type": "block", "_key": "b02", "style": "h2", "markDefs": [],
120
- "children": [{ "_type": "span", "_key": "b02s1", "text": "Where the days go", "marks": [] }] },
121
- { "_type": "block", "_key": "b03", "style": "normal", "markDefs": [],
122
- "children": [
123
- { "_type": "span", "_key": "b03s1", "text": "We timed 41 finance teams through a close. On average, ", "marks": [] },
124
- { "_type": "span", "_key": "b03s2", "text": "62% of the time", "marks": ["strong"] },
125
- { "_type": "span", "_key": "b03s3", "text": " went to matching and chasing, not to judgement.", "marks": [] }
126
- ] },
127
- { "_type": "block", "_key": "b04", "style": "normal", "listItem": "bullet", "level": 1, "markDefs": [],
128
- "children": [{ "_type": "span", "_key": "b04s1", "text": "Bank lines without a reference", "marks": [] }] },
129
- { "_type": "block", "_key": "b05", "style": "normal", "listItem": "bullet", "level": 1, "markDefs": [],
130
- "children": [{ "_type": "span", "_key": "b05s1", "text": "Card spend with no receipt attached", "marks": [] }] },
131
- { "_type": "bcmsBlock", "_key": "b06", "schemaKey": "builtin:image", "schemaLabel": "Image",
132
- "fields": { "src": "/blog/close-time-chart.png",
133
- "alt": "Bar chart: matching takes 3.1 days, review 1.2 days, reporting 0.8 days",
134
- "caption": "Median time per close stage, 41 teams, Q2 2026." } },
135
- { "_type": "table", "_key": "b07", "rows": [
136
- { "_key": "b07r1", "head": true, "cells": [
137
- { "_key": "b07c1", "header": true, "content": [
138
- { "_type": "block", "_key": "b07c1b", "style": "normal", "markDefs": [],
139
- "children": [{ "_type": "span", "_key": "b07c1s", "text": "Stage", "marks": [] }] } ] },
140
- { "_key": "b07c2", "header": true, "content": [
141
- { "_type": "block", "_key": "b07c2b", "style": "normal", "markDefs": [],
142
- "children": [{ "_type": "span", "_key": "b07c2s", "text": "Manual", "marks": [] }] } ] }
143
- ] },
144
- { "_key": "b07r2", "cells": [
145
- { "_key": "b07c3", "content": [
146
- { "_type": "block", "_key": "b07c3b", "style": "normal", "markDefs": [],
147
- "children": [{ "_type": "span", "_key": "b07c3s", "text": "Matching", "marks": [] }] } ] },
148
- { "_key": "b07c4", "content": [
149
- { "_type": "block", "_key": "b07c4b", "style": "normal", "markDefs": [],
150
- "children": [{ "_type": "span", "_key": "b07c4s", "text": "3.1 days", "marks": [] }] } ] }
151
- ] }
152
- ] },
153
- { "_type": "bcmsBlock", "_key": "b08", "schemaKey": "builtin:video", "schemaLabel": "Video",
154
- "fields": { "url": "https://www.youtube.com/embed/dQw4w9WgXcQ", "title": "Ten-minute close walkthrough" } },
155
- { "_type": "block", "_key": "b09", "style": "blockquote", "markDefs": [],
156
- "children": [{ "_type": "span", "_key": "b09s1", "text": "The close got shorter the week we stopped exporting CSVs.", "marks": [] }] },
157
- { "_type": "bcmsCode", "_key": "b10", "language": "sql", "code": "SELECT * FROM bank_lines WHERE reference IS NULL;" },
158
- { "_type": "block", "_key": "b11", "style": "normal",
159
- "markDefs": [{ "_type": "link", "_key": "b11l1", "href": "/resources/close-checklist" }],
160
- "children": [
161
- { "_type": "span", "_key": "b11s1", "text": "Want the checklist we use? ", "marks": [] },
162
- { "_type": "span", "_key": "b11s2", "text": "Download it here", "marks": ["b11l1"] },
163
- { "_type": "span", "_key": "b11s3", "text": ".", "marks": [] }
164
- ] }
165
- ]
166
- ```
167
-
168
- Bound template (the entry's field keys are the addresses):
169
-
170
- ```html
171
- <article class="post">
172
- <h1 data-bcms-field="title" data-bcms-kind="text">Why your month-end close takes eight days</h1>
173
- <p class="post__dek" data-bcms-field="excerpt" data-bcms-kind="text">Three small problems repeat every month. ...</p>
174
- <div class="byline">
175
- <a rel="author" href="/authors/priya-shah">Priya Shah</a>
176
- <time datetime="2026-09-18T09:00:00-04:00">September 18, 2026</time> · <span>7 min read</span>
177
- </div>
178
- <figure class="post__cover">
179
- <img src="/blog/close-cover.jpg" alt="A calendar with the last week of the month circled in red"
180
- data-bcms-field="cover" data-bcms-kind="image">
181
- <figcaption data-bcms-field="cover_caption" data-bcms-kind="text">Photo: Alder Studio</figcaption>
182
- </figure>
183
- <div class="post__body prose" data-bcms-field="body" data-bcms-kind="document">
184
- <p data-bcms-kind="richtext">Most month-end closes do not run late ...</p>
185
- <h2 data-bcms-kind="richtext">Where the days go</h2>
186
- <!-- one element per block, in order -->
187
- </div>
188
- </article>
189
- ```
190
-
191
- - The body wrapper is kind `document`, never `richtext`: the rich text kind would repaint the
192
- whole article from plain text and lose its blocks.
193
- - The byline, the date and the reading time are not bound. The author is edited on the author
194
- entry, the date in the side panel, and the reading time is recomputed on every render.
195
- - Publish the author and topic entries, then the post, then the template page.
196
- - For hundreds of posts, run the import as code against the Management API; keep the MCP for
197
- reading state and fixing single entries.
198
-
199
- Not available today: a stored reading time or any computed field. A URL pattern with more than
200
- one segment variable (`/blog/:year/:slug`): the pattern takes `:slug` once. A caption on a video
201
- block (image blocks have one). "Related posts" by topic on a hosted template is a `collection`
202
- block with `source`, a filter whose value is a `{{field}}` of the post shown, and
203
- `excludeCurrent`; a template in the repo's code builds the list itself.
204
-
205
- ## 7. Mega menu in the Global Layout
206
-
207
- Source: a developer-tools header on every page.
208
-
209
- ```html
210
- <header class="site-header sticky top-0 z-50">
211
- <nav class="nav" aria-label="Main">
212
- <a class="nav__logo" href="/"><img src="/brand/relay-logo.svg" alt="Relay" width="96" height="28"></a>
213
- <ul class="nav__menu">
214
- <li class="has-mega">
215
- <button class="nav__trigger" aria-expanded="false" aria-controls="mega-product">Product</button>
216
- <div class="mega" id="mega-product" hidden>
217
- <div class="mega__col">
218
- <p class="mega__title">Platform</p>
219
- <ul>
220
- <li><a href="/product/queues"><span class="mega__label">Queues</span><span class="mega__desc">Durable jobs with retries</span></a></li>
221
- <li><a href="/product/workflows"><span class="mega__label">Workflows</span><span class="badge">New</span><span class="mega__desc">Multi-step jobs with state</span></a></li>
222
- </ul>
223
- </div>
224
- <div class="mega__col">
225
- <p class="mega__title">Integrations</p>
226
- <ul><li><a href="/integrations/vercel">Vercel</a></li><li><a href="/integrations/supabase">Supabase</a></li></ul>
227
- </div>
228
- </div>
229
- </li>
230
- <li><a href="/pricing">Pricing</a></li>
231
- </ul>
232
- <a class="nav__signin" href="https://app.relay.dev/login">Sign in</a>
233
- <a class="btn btn-primary btn-sm" href="https://app.relay.dev/signup">Start for free</a>
234
- </nav>
235
- </header>
236
- ```
237
-
238
- Reasoning:
239
-
240
- 1. The same `<header>` is on every page: chrome. It lives in the Global Layout's reserved
241
- `navigation` Section, edited once. Never a page field, never a section placed on each page.
242
- 2. Each top-level `<li>` is one menu row. A `<button aria-expanded>` opens a panel and has no
243
- link; a plain `<a>` is a row with a link and no panel links.
244
- 3. A panel is columns of links: three nested lists (menu, column, link). Store two. The column
245
- title becomes a value on each link row, and the template groups links by it. Two list levels
246
- is the depth chrome is proven to bind at, and it is also the cap for a component's props.
247
- 4. `sticky`, colours and height are design and stay in the code. `aria-expanded` is behaviour.
248
- 5. The logo, "Sign in" and the button use the Section's existing logo, link and button fields.
249
- Read them with `get_layout`; add only what is missing.
250
-
251
- Schema (`update_layout`, one `add-field` command, `ifMatch` from `get_layout`):
252
-
253
- ```json
254
- {
255
- "scope": "global",
256
- "ifMatch": 12,
257
- "commands": [
258
- { "type": "add-field", "sectionId": "navigation", "field": {
259
- "id": "navigation.menus", "slug": "menus", "label": "Menus", "type": "repeater",
260
- "fields": [
261
- { "id": "navigation.menus.label", "slug": "label", "label": "Label", "type": "text" },
262
- { "id": "navigation.menus.destination", "slug": "destination", "label": "Link", "type": "link" },
263
- { "id": "navigation.menus.links", "slug": "links", "label": "Panel links", "type": "repeater",
264
- "fields": [
265
- { "id": "navigation.menus.links.column", "slug": "column", "label": "Column title", "type": "text" },
266
- { "id": "navigation.menus.links.label", "slug": "label", "label": "Label", "type": "text" },
267
- { "id": "navigation.menus.links.description", "slug": "description", "label": "Description", "type": "text" },
268
- { "id": "navigation.menus.links.badge", "slug": "badge", "label": "Badge", "type": "text" },
269
- { "id": "navigation.menus.links.destination", "slug": "destination", "label": "Link", "type": "link" }
270
- ] }
271
- ] } }
272
- ]
273
- }
274
- ```
275
-
276
- Value (`update_layout`). A nested value lives inside its parent, keyed by child id. Prefer
277
- `set-field-value` on the top-level field: `set-section-values` replaces every value of the
278
- Section, the logo and the button included.
279
-
280
- ```json
281
- {
282
- "scope": "global",
283
- "ifMatch": 13,
284
- "command": { "type": "set-field-value", "sectionId": "navigation", "fieldId": "navigation.menus", "value": [
285
- { "navigation.menus.label": "Product", "navigation.menus.links": [
286
- { "navigation.menus.links.column": "Platform", "navigation.menus.links.label": "Queues",
287
- "navigation.menus.links.description": "Durable jobs with retries", "navigation.menus.links.destination": "/product/queues" },
288
- { "navigation.menus.links.column": "Platform", "navigation.menus.links.label": "Workflows",
289
- "navigation.menus.links.description": "Multi-step jobs with state", "navigation.menus.links.badge": "New",
290
- "navigation.menus.links.destination": "/product/workflows" },
291
- { "navigation.menus.links.column": "Integrations", "navigation.menus.links.label": "Vercel",
292
- "navigation.menus.links.destination": "/integrations/vercel" },
293
- { "navigation.menus.links.column": "Integrations", "navigation.menus.links.label": "Supabase",
294
- "navigation.menus.links.destination": "/integrations/supabase" }
295
- ] },
296
- { "navigation.menus.label": "Pricing", "navigation.menus.destination": "/pricing" }
297
- ] }
298
- }
299
- ```
300
-
301
- Each command returns the next revision. The draft goes live with `publish_layout`; confirm with
302
- `get_layout` and `copy: "published"`.
303
-
304
- Bound template (the trigger and one link):
305
-
306
- ```html
307
- <button class="nav__trigger" aria-expanded="false" aria-controls="mega-product"
308
- data-bcms-layout-field="layout:navigation:navigation.menus[0].navigation.menus.label">Product</button>
309
- <p class="mega__title">Platform</p>
310
- <a href="/product/queues"
311
- data-bcms-props="layout:navigation:navigation.menus[0].navigation.menus.links[0].navigation.menus.links.destination|url|href">
312
- <span class="mega__label"
313
- data-bcms-layout-field="layout:navigation:navigation.menus[0].navigation.menus.links[0].navigation.menus.links.label">Queues</span>
314
- <span class="mega__desc"
315
- data-bcms-layout-field="layout:navigation:navigation.menus[0].navigation.menus.links[0].navigation.menus.links.description">Durable jobs with retries</span>
316
- </a>
317
- ```
318
-
319
- - Chrome uses `data-bcms-layout-field`, never `data-bcms-field`. A row is the field id, a
320
- bracketed index, then the child's full id. Do not write these by hand: `get_conversion_brief`
321
- lists every chrome path; copy each one exactly.
322
- - The column title is not bound: one value shows once per group but is stored on every link.
323
- It is edited in the side panel.
324
- - The label sits in its own `<span>`; a binding on the `<a>` would erase the description.
325
- - A page that draws its own header gets no Layout copy on top, and a nav placed on a page as a
326
- section renders nowhere. Remove page-level copies.
327
-
328
- If the header is a component placed with `add-component`: declare a `table` prop for the menus
329
- whose rows hold one nested `table` of links, with `column` as a text sub-field, and bind its
330
- inputs to these Section fields with ids read from `get_layout`. Publish the component before the
331
- Layout.
332
-
333
- Not available today: a mega menu in the built-in navbar, which is flat (`logo`, `links` of label
334
- and link, one `cta`). Three nested lists in a component prop: a `table` inside a `table` saves, a
335
- third level is refused. A featured card per panel fits as a group on the menu row (image, eyebrow,
336
- title, link), not as a list of cards. A typed link value with `rel` or "opens in a new tab" on a
337
- menu link.
338
-
339
- ## 8. Tabs holding columns of cards
340
-
341
- Source: a product tour made in a page builder. One page uses it.
342
-
343
- ```html
344
- <section class="tour" id="tour">
345
- <p class="eyebrow">How it works</p>
346
- <h2>One platform, three jobs</h2>
347
- <div class="tabs" data-tabs>
348
- <div role="tablist" aria-label="Use cases">
349
- <button role="tab" aria-controls="panel-plan" aria-selected="true"><i class="ti ti-calendar" aria-hidden="true"></i>Plan</button>
350
- <button role="tab" aria-controls="panel-publish" aria-selected="false"><i class="ti ti-send" aria-hidden="true"></i>Publish</button>
351
- </div>
352
- <div role="tabpanel" id="panel-plan">
353
- <div class="row row--gap-md">
354
- <div class="col col-md-6">
355
- <div class="card card--bordered">
356
- <img src="/img/tour/calendar.png" alt="Content calendar with posts across a week">
357
- <span class="card__eyebrow">Calendar</span>
358
- <h3 class="card__title">See every channel in one week view</h3>
359
- <p>Drag posts between days. Gaps and clashes show before they go live.</p>
360
- <a class="btn btn-primary" href="/product/calendar">Explore the calendar</a>
361
- <a class="btn btn-link" href="https://www.youtube.com/watch?v=dQw4w9WgXcQ">Watch 2-min demo</a>
362
- </div>
363
- </div>
364
- <div class="col col-md-6">
365
- <div class="stat"><span class="stat__value">3.4x</span><span class="stat__label">more posts shipped per editor</span></div>
366
- </div>
367
- </div>
368
- </div>
369
- <div role="tabpanel" id="panel-publish" hidden>
370
- <div class="row">
371
- <div class="col"><div class="card"><h3 class="card__title">Approvals that do not stall</h3>
372
- <p>Reviewers get one link. Nothing goes out without a yes.</p>
373
- <a class="btn btn-primary" href="/product/approvals">See approvals</a></div></div>
374
- <div class="col"><blockquote class="quote"><p>We cut review time in half.</p><cite>Lena Ortiz, Social Lead</cite></blockquote></div>
375
- </div>
376
- </div>
377
- </div>
378
- </section>
379
- ```
380
-
381
- Reasoning:
382
-
383
- 1. Count levels where the content changes kind, not `<div>`s. `.row`, `.col` and the action
384
- wrapper are layout. The real levels: section, tab, column, the thing in the column.
385
- 2. Tabs and columns are native container blocks. A `tabs` block holds tabs of a label and child
386
- blocks; a `columns` block holds a list of columns, each a list of blocks. Use them; do not
387
- model tabs as a table prop.
388
- 3. A column holds different things (a card, a stat, a quote). Each is a small component placed as
389
- an instance, so its copy is click-to-edit and the card can be reused. The card's two looks
390
- (plain, bordered) are one `variant` select prop, not two components.
391
- 4. One page uses the band, so the outer shape is a `section` block, not a component.
392
- 5. `aria-selected` only says which tab opens first: behaviour. Column widths are layout.
393
-
394
- Schema: the card component (`create_component`, then `publish_component`). The stat (`value`,
395
- `label`) and the quote (`quote`, `cite`) components are built the same way.
396
-
397
- ```json
398
- {
399
- "name": "Tour card",
400
- "slug": "tour-card",
401
- "category": "content",
402
- "description": "Card with an image, a title, a short text and up to two buttons.",
403
- "blockJson": [
404
- { "type": "section", "id": "card-root", "props": { "children": [
405
- { "type": "image", "id": "card-image", "props": { "src": "", "alt": "" } },
406
- { "type": "text", "id": "card-eyebrow", "props": { "html": "" } },
407
- { "type": "heading", "id": "card-title", "props": { "text": "", "level": 3 } },
408
- { "type": "text", "id": "card-body", "props": { "html": "" } },
409
- { "type": "button", "id": "card-primary", "props": { "text": "", "href": "" } },
410
- { "type": "button", "id": "card-secondary", "props": { "text": "", "href": "", "variant": "link" } }
411
- ] } }
412
- ],
413
- "props": [
414
- { "key": "variant", "label": "Look", "type": "select", "defaultValue": "plain",
415
- "config": { "options": ["plain", "bordered"] }, "target": { "blockId": "card-root", "path": "props.variant" } },
416
- { "key": "image", "label": "Image", "type": "image", "target": { "blockId": "card-image", "path": "props.src" } },
417
- { "key": "imageAlt", "label": "Image alt text", "type": "text", "target": { "blockId": "card-image", "path": "props.alt" } },
418
- { "key": "eyebrow", "label": "Eyebrow", "type": "text", "target": { "blockId": "card-eyebrow", "path": "props.html" } },
419
- { "key": "title", "label": "Title", "type": "text", "target": { "blockId": "card-title", "path": "props.text" } },
420
- { "key": "body", "label": "Text", "type": "text", "target": { "blockId": "card-body", "path": "props.html" } },
421
- { "key": "primaryLabel", "label": "Primary button label", "type": "text", "target": { "blockId": "card-primary", "path": "props.text" } },
422
- { "key": "primaryUrl", "label": "Primary button link", "type": "url", "target": { "blockId": "card-primary", "path": "props.href" } },
423
- { "key": "secondaryLabel", "label": "Secondary button label", "type": "text", "target": { "blockId": "card-secondary", "path": "props.text" } },
424
- { "key": "secondaryUrl", "label": "Secondary button link", "type": "url", "target": { "blockId": "card-secondary", "path": "props.href" } }
425
- ]
426
- }
427
- ```
428
-
429
- Value: the section block, sent inside the page's whole `blockJson` (`update_page` with `ifMatch`,
430
- or `compose_pages`):
431
-
432
- ```json
433
- { "type": "section", "id": "tour", "label": "Product tour", "props": { "children": [
434
- { "type": "text", "id": "tour-eyebrow", "props": { "html": "How it works" } },
435
- { "type": "heading", "id": "tour-title", "props": { "text": "One platform, three jobs", "level": 2 } },
436
- { "type": "tabs", "id": "tour-tabs", "props": { "tabs": [
437
- { "id": "plan", "label": "Plan", "children": [
438
- { "type": "columns", "id": "plan-cols", "props": { "gap": 24, "columns": [
439
- [ { "type": "component", "id": "plan-card", "props": { "componentId": "<tour card id>", "overrides": {
440
- "variant": "bordered",
441
- "image": "/img/tour/calendar.png", "imageAlt": "Content calendar with posts across a week",
442
- "eyebrow": "Calendar", "title": "See every channel in one week view",
443
- "body": "Drag posts between days. Gaps and clashes show before they go live.",
444
- "primaryLabel": "Explore the calendar", "primaryUrl": "/product/calendar",
445
- "secondaryLabel": "Watch 2-min demo", "secondaryUrl": "https://www.youtube.com/watch?v=dQw4w9WgXcQ" } } } ],
446
- [ { "type": "component", "id": "plan-stat", "props": { "componentId": "<stat id>", "overrides": {
447
- "value": "3.4x", "label": "more posts shipped per editor" } } } ]
448
- ] } }
449
- ] },
450
- { "id": "publish", "label": "Publish", "children": [
451
- { "type": "columns", "id": "publish-cols", "props": { "columns": [
452
- [ { "type": "component", "id": "publish-card", "props": { "componentId": "<tour card id>", "overrides": {
453
- "title": "Approvals that do not stall", "body": "Reviewers get one link. Nothing goes out without a yes.",
454
- "primaryLabel": "See approvals", "primaryUrl": "/product/approvals" } } } ],
455
- [ { "type": "component", "id": "publish-quote", "props": { "componentId": "<quote id>", "overrides": {
456
- "quote": "We cut review time in half.", "cite": "Lena Ortiz, Social Lead" } } } ]
457
- ] } }
458
- ] }
459
- ] } }
460
- ] } }
461
- ```
462
-
463
- Bound template: render the blocks with `BcmsBlocks` and pass the component definitions; it emits
464
- the attributes. What it writes:
465
-
466
- ```html
467
- <section data-bcms-block="tour">
468
- <h2 data-bcms-field="tour-title__text" data-bcms-kind="text">One platform, three jobs</h2>
469
- <button role="tab" data-bcms-field="tour-tabs__tabs[0].label" data-bcms-kind="text">Plan</button>
470
- <div data-bcms-block="plan-card">
471
- <h3 data-bcms-field="plan-card__overrides.title" data-bcms-kind="text">See every channel in one week view</h3>
472
- <a href="/product/calendar" data-bcms-props="plan-card__overrides.primaryUrl|url|href">
473
- <span data-bcms-field="plan-card__overrides.primaryLabel" data-bcms-kind="text">Explore the calendar</span></a>
474
- </div>
475
- </section>
476
- ```
477
-
478
- - Give every block, tab and instance a stable `id`. An id is the address; changing one orphans
479
- the edits made against it.
480
- - A block the section owns is addressed `<blockId>__<prop>`; a value inside an instance is
481
- `<instanceId>__overrides.<key>`. Two instances of the card never share an address.
482
- - Leave an unused prop out of `overrides` (the second card has no image and one button). The
483
- definition's empty default renders nothing.
484
- - Without the definitions passed to the renderer, every instance draws empty: check that before
485
- debugging a card.
486
-
487
- A slot can hold several children. Give the `slot` prop `config: { multiple: true }` and point its
488
- target at a container's child list (`props.children` on a `section`, `props.columns.<n>` on a
489
- `columns` block); a placement then lists the children, each a published component of this project:
490
-
491
- ```json
492
- { "type": "component", "id": "tour-cards", "props": { "componentId": "<card grid id>", "overrides": {
493
- "cards": [
494
- { "componentId": "<tour card id>", "overrides": { "title": "Plan" } },
495
- { "componentId": "<stat id>", "overrides": { "value": "3.4x" }, "style": { "hideOn": ["mobile"] } }
496
- ] } } }
497
- ```
498
-
499
- A fixed layout of different blocks that the editor never reorders is still a `columns` block, as
500
- in the tabs above. Rules and refusal codes: [components.md](components.md).
501
-
502
- Not available today: a tab icon (a tab has a label only) and a
503
- "first open tab" setting. Per-column widths: columns are equal, and `gap` is a whole number of
504
- pixels, not a named size. Per-breakpoint sizes are not available; `style.hideOn` (`["mobile"]`, `["tablet","desktop"]`) hides a block at those breakpoints, under 768, 768-1023 and 1024 and up, as `display:none` that keeps it in the HTML; all three hides it on the page, and no published page, Markdown twin or search index carries it.
505
-
506
- ## Checks
507
-
508
- Run the five checks at the end of [worked-examples.md](worked-examples.md). For chrome, also read
509
- `get_layout` with `copy: "published"` after `publish_layout`: the draft is never the receipt.
1
+ ---
2
+ title: "Worked examples: entries, chrome and nested blocks"
3
+ description: "Three more conversions from source HTML to a production schema, value and bound template: a blog post as a collection entry, a mega menu in the Global Layout, and tabs holding columns of cards."
4
+ ---
5
+
6
+ # Worked examples, continued: entries, chrome and nested blocks
7
+
8
+ Examples 1 to 5, the table of which shape to use, and the rules every example follows are in
9
+ [worked-examples.md](worked-examples.md). Each example here runs the same five steps: source
10
+ markup (trimmed), reasoning, schema, value, bound template. Rules behind the shapes:
11
+ [content-modeling.md](content-modeling.md), [components.md](components.md),
12
+ [bindings.md](bindings.md), [portable-text.md](portable-text.md).
13
+
14
+ ## Contents
15
+
16
+ - [6. Blog post as a collection entry](#6-blog-post-as-a-collection-entry)
17
+ - [7. Mega menu in the Global Layout](#7-mega-menu-in-the-global-layout)
18
+ - [8. Tabs holding columns of cards](#8-tabs-holding-columns-of-cards)
19
+ - [Checks](#checks)
20
+
21
+ ## 6. Blog post as a collection entry
22
+
23
+ Source: one post of many, on `/blog/why-your-month-end-close-takes-eight-days`.
24
+
25
+ ```html
26
+ <article class="post" itemscope itemtype="https://schema.org/BlogPosting">
27
+ <header class="post__header">
28
+ <a class="post__topic" href="/blog/topic/finance-ops">Finance ops</a>
29
+ <h1 itemprop="headline">Why your month-end close takes eight days</h1>
30
+ <p class="post__dek">Three small problems repeat every month. Here is how to remove them.</p>
31
+ <div class="byline">
32
+ <a rel="author" href="/authors/priya-shah">Priya Shah</a>
33
+ <time datetime="2026-09-18T09:00:00-04:00">September 18, 2026</time> · <span>7 min read</span>
34
+ </div>
35
+ </header>
36
+ <figure class="post__cover">
37
+ <img src="/blog/close-cover.jpg" alt="A calendar with the last week of the month circled in red" width="1600" height="900">
38
+ <figcaption>Photo: Alder Studio</figcaption>
39
+ </figure>
40
+ <div class="post__body prose">
41
+ <p>Most month-end closes do not run late because of hard problems. They run late because of three small ones that repeat every month.</p>
42
+ <h2 id="where-the-days-go">Where the days go</h2>
43
+ <p>We timed 41 finance teams through a close. On average, <strong>62% of the time</strong> went to matching and chasing, not to judgement.</p>
44
+ <ul><li>Bank lines without a reference</li><li>Card spend with no receipt attached</li></ul>
45
+ <figure class="wp-block-image"><img src="/blog/close-time-chart.png" alt="Bar chart: matching takes 3.1 days, review 1.2 days, reporting 0.8 days"><figcaption>Median time per close stage, 41 teams, Q2 2026.</figcaption></figure>
46
+ <table class="wp-block-table"><thead><tr><th>Stage</th><th>Manual</th></tr></thead><tbody><tr><td>Matching</td><td>3.1 days</td></tr></tbody></table>
47
+ <figure class="wp-block-embed is-provider-youtube"><iframe src="https://www.youtube.com/embed/dQw4w9WgXcQ" title="Ten-minute close walkthrough"></iframe></figure>
48
+ <blockquote class="pullquote"><p>The close got shorter the week we stopped exporting CSVs.</p></blockquote>
49
+ <pre><code class="language-sql">SELECT * FROM bank_lines WHERE reference IS NULL;</code></pre>
50
+ <p>Want the checklist we use? <a href="/resources/close-checklist">Download it here</a>.</p>
51
+ </div>
52
+ <aside class="share">...</aside>
53
+ <section class="related">...</section>
54
+ </article>
55
+ ```
56
+
57
+ Reasoning:
58
+
59
+ 1. An `<article>` with its own URL, a byline and a date, one of many: an entry of a content model
60
+ with one dynamic page. Never a page per post.
61
+ 2. Title, summary, cover and caption are fields. The author and the topic are entries of their
62
+ own models, referenced by id: find or create `/authors/priya-shah` and the topic first.
63
+ 3. The date comes from `<time datetime>`: ISO 8601 with its offset, exactly as written.
64
+ 4. "7 min read" is computed by the template from the body. Share buttons and related posts are
65
+ template parts. None of the three is stored.
66
+ 5. The body is the one `document` field. Headings start at `h2` (the title is the `h1`). Lists
67
+ become flat list blocks; the figure, the embed, the table and the code block become block
68
+ objects.
69
+ 6. No SEO fields and no microdata. Meta values go in the entry's `meta`; BlogPosting markup is
70
+ automatic for a collection with a body, a date and an author.
71
+
72
+ Schema (`create_content_model`):
73
+
74
+ ```json
75
+ {
76
+ "name": "Post",
77
+ "slug": "post",
78
+ "urlPattern": "/blog/:slug",
79
+ "fields": [
80
+ { "key": "title", "label": "Title", "type": "text", "richText": false, "required": true, "placement": "title" },
81
+ { "key": "excerpt", "label": "Summary", "type": "text", "richText": false, "placement": "excerpt" },
82
+ { "key": "cover", "label": "Cover image", "type": "image", "placement": "cover" },
83
+ { "key": "cover_caption", "label": "Cover caption", "type": "text", "richText": false },
84
+ { "key": "author", "label": "Author", "type": "reference", "required": true, "config": { "contentModelId": "<author model id>" } },
85
+ { "key": "topics", "label": "Topics", "type": "multi-reference", "config": { "contentModelId": "<topic model id>", "max": 3 } },
86
+ { "key": "date", "label": "Publish date", "type": "datetime" },
87
+ { "key": "body", "label": "Body", "type": "document" }
88
+ ]
89
+ }
90
+ ```
91
+
92
+ Then the template page: `create_page` with `pageType: "dynamic"`, and `update_page` with
93
+ `dynamic: { "previewPath": "/blog/{slug}", "modelId": "<post model id>" }`.
94
+
95
+ Value (`create_content_entry`; `body` is the Portable Text array below):
96
+
97
+ ```jsonc
98
+ {
99
+ "contentModelId": "<post model id>",
100
+ "slug": "why-your-month-end-close-takes-eight-days",
101
+ "data": {
102
+ "title": "Why your month-end close takes eight days",
103
+ "excerpt": "Three small problems repeat every month. Here is how to remove them.",
104
+ "cover": { "url": "/blog/close-cover.jpg", "altText": "A calendar with the last week of the month circled in red" },
105
+ "cover_caption": "Photo: Alder Studio",
106
+ "author": "<priya-shah entry id>",
107
+ "topics": ["<finance-ops entry id>"],
108
+ "date": "2026-09-18T09:00:00-04:00",
109
+ "body": [ /* the array below */ ]
110
+ }
111
+ }
112
+ ```
113
+
114
+ <!-- pt-example -->
115
+ ```json
116
+ [
117
+ { "_type": "block", "_key": "b01", "style": "normal", "markDefs": [],
118
+ "children": [{ "_type": "span", "_key": "b01s1", "text": "Most month-end closes do not run late because of hard problems. They run late because of three small ones that repeat every month.", "marks": [] }] },
119
+ { "_type": "block", "_key": "b02", "style": "h2", "markDefs": [],
120
+ "children": [{ "_type": "span", "_key": "b02s1", "text": "Where the days go", "marks": [] }] },
121
+ { "_type": "block", "_key": "b03", "style": "normal", "markDefs": [],
122
+ "children": [
123
+ { "_type": "span", "_key": "b03s1", "text": "We timed 41 finance teams through a close. On average, ", "marks": [] },
124
+ { "_type": "span", "_key": "b03s2", "text": "62% of the time", "marks": ["strong"] },
125
+ { "_type": "span", "_key": "b03s3", "text": " went to matching and chasing, not to judgement.", "marks": [] }
126
+ ] },
127
+ { "_type": "block", "_key": "b04", "style": "normal", "listItem": "bullet", "level": 1, "markDefs": [],
128
+ "children": [{ "_type": "span", "_key": "b04s1", "text": "Bank lines without a reference", "marks": [] }] },
129
+ { "_type": "block", "_key": "b05", "style": "normal", "listItem": "bullet", "level": 1, "markDefs": [],
130
+ "children": [{ "_type": "span", "_key": "b05s1", "text": "Card spend with no receipt attached", "marks": [] }] },
131
+ { "_type": "bcmsBlock", "_key": "b06", "schemaKey": "builtin:image", "schemaLabel": "Image",
132
+ "fields": { "src": "/blog/close-time-chart.png",
133
+ "alt": "Bar chart: matching takes 3.1 days, review 1.2 days, reporting 0.8 days",
134
+ "caption": "Median time per close stage, 41 teams, Q2 2026." } },
135
+ { "_type": "table", "_key": "b07", "rows": [
136
+ { "_key": "b07r1", "head": true, "cells": [
137
+ { "_key": "b07c1", "header": true, "content": [
138
+ { "_type": "block", "_key": "b07c1b", "style": "normal", "markDefs": [],
139
+ "children": [{ "_type": "span", "_key": "b07c1s", "text": "Stage", "marks": [] }] } ] },
140
+ { "_key": "b07c2", "header": true, "content": [
141
+ { "_type": "block", "_key": "b07c2b", "style": "normal", "markDefs": [],
142
+ "children": [{ "_type": "span", "_key": "b07c2s", "text": "Manual", "marks": [] }] } ] }
143
+ ] },
144
+ { "_key": "b07r2", "cells": [
145
+ { "_key": "b07c3", "content": [
146
+ { "_type": "block", "_key": "b07c3b", "style": "normal", "markDefs": [],
147
+ "children": [{ "_type": "span", "_key": "b07c3s", "text": "Matching", "marks": [] }] } ] },
148
+ { "_key": "b07c4", "content": [
149
+ { "_type": "block", "_key": "b07c4b", "style": "normal", "markDefs": [],
150
+ "children": [{ "_type": "span", "_key": "b07c4s", "text": "3.1 days", "marks": [] }] } ] }
151
+ ] }
152
+ ] },
153
+ { "_type": "bcmsBlock", "_key": "b08", "schemaKey": "builtin:video", "schemaLabel": "Video",
154
+ "fields": { "url": "https://www.youtube.com/embed/dQw4w9WgXcQ", "title": "Ten-minute close walkthrough" } },
155
+ { "_type": "block", "_key": "b09", "style": "blockquote", "markDefs": [],
156
+ "children": [{ "_type": "span", "_key": "b09s1", "text": "The close got shorter the week we stopped exporting CSVs.", "marks": [] }] },
157
+ { "_type": "bcmsCode", "_key": "b10", "language": "sql", "code": "SELECT * FROM bank_lines WHERE reference IS NULL;" },
158
+ { "_type": "block", "_key": "b11", "style": "normal",
159
+ "markDefs": [{ "_type": "link", "_key": "b11l1", "href": "/resources/close-checklist" }],
160
+ "children": [
161
+ { "_type": "span", "_key": "b11s1", "text": "Want the checklist we use? ", "marks": [] },
162
+ { "_type": "span", "_key": "b11s2", "text": "Download it here", "marks": ["b11l1"] },
163
+ { "_type": "span", "_key": "b11s3", "text": ".", "marks": [] }
164
+ ] }
165
+ ]
166
+ ```
167
+
168
+ Bound template (the entry's field keys are the addresses):
169
+
170
+ ```html
171
+ <article class="post">
172
+ <h1 data-bcms-field="title" data-bcms-kind="text">Why your month-end close takes eight days</h1>
173
+ <p class="post__dek" data-bcms-field="excerpt" data-bcms-kind="text">Three small problems repeat every month. ...</p>
174
+ <div class="byline">
175
+ <a rel="author" href="/authors/priya-shah">Priya Shah</a>
176
+ <time datetime="2026-09-18T09:00:00-04:00">September 18, 2026</time> · <span>7 min read</span>
177
+ </div>
178
+ <figure class="post__cover">
179
+ <img src="/blog/close-cover.jpg" alt="A calendar with the last week of the month circled in red"
180
+ data-bcms-field="cover" data-bcms-kind="image">
181
+ <figcaption data-bcms-field="cover_caption" data-bcms-kind="text">Photo: Alder Studio</figcaption>
182
+ </figure>
183
+ <div class="post__body prose" data-bcms-field="body" data-bcms-kind="document">
184
+ <p data-bcms-kind="richtext">Most month-end closes do not run late ...</p>
185
+ <h2 data-bcms-kind="richtext">Where the days go</h2>
186
+ <!-- one element per block, in order -->
187
+ </div>
188
+ </article>
189
+ ```
190
+
191
+ - The body wrapper is kind `document`, never `richtext`: the rich text kind would repaint the
192
+ whole article from plain text and lose its blocks.
193
+ - The byline, the date and the reading time are not bound. The author is edited on the author
194
+ entry, the date in the side panel, and the reading time is recomputed on every render.
195
+ - Publish the author and topic entries, then the post, then the template page.
196
+ - For hundreds of posts, run the import as code against the Management API; keep the MCP for
197
+ reading state and fixing single entries.
198
+
199
+ Not available today: a stored reading time or any computed field. A URL pattern with more than
200
+ one segment variable (`/blog/:year/:slug`): the pattern takes `:slug` once. A caption on a video
201
+ block (image blocks have one). "Related posts" by topic on a hosted template is a `collection`
202
+ block with `source`, a filter whose value is a `{{field}}` of the post shown, and
203
+ `excludeCurrent`; a template in the repo's code builds the list itself.
204
+
205
+ ## 7. Mega menu in the Global Layout
206
+
207
+ Source: a developer-tools header on every page.
208
+
209
+ ```html
210
+ <header class="site-header sticky top-0 z-50">
211
+ <nav class="nav" aria-label="Main">
212
+ <a class="nav__logo" href="/"><img src="/brand/relay-logo.svg" alt="Relay" width="96" height="28"></a>
213
+ <ul class="nav__menu">
214
+ <li class="has-mega">
215
+ <button class="nav__trigger" aria-expanded="false" aria-controls="mega-product">Product</button>
216
+ <div class="mega" id="mega-product" hidden>
217
+ <div class="mega__col">
218
+ <p class="mega__title">Platform</p>
219
+ <ul>
220
+ <li><a href="/product/queues"><span class="mega__label">Queues</span><span class="mega__desc">Durable jobs with retries</span></a></li>
221
+ <li><a href="/product/workflows"><span class="mega__label">Workflows</span><span class="badge">New</span><span class="mega__desc">Multi-step jobs with state</span></a></li>
222
+ </ul>
223
+ </div>
224
+ <div class="mega__col">
225
+ <p class="mega__title">Integrations</p>
226
+ <ul><li><a href="/integrations/vercel">Vercel</a></li><li><a href="/integrations/supabase">Supabase</a></li></ul>
227
+ </div>
228
+ </div>
229
+ </li>
230
+ <li><a href="/pricing">Pricing</a></li>
231
+ </ul>
232
+ <a class="nav__signin" href="https://app.relay.dev/login">Sign in</a>
233
+ <a class="btn btn-primary btn-sm" href="https://app.relay.dev/signup">Start for free</a>
234
+ </nav>
235
+ </header>
236
+ ```
237
+
238
+ Reasoning:
239
+
240
+ 1. The same `<header>` is on every page: chrome. It lives in the Global Layout's reserved
241
+ `navigation` Section, edited once. Never a page field, never a section placed on each page.
242
+ 2. Each top-level `<li>` is one menu row. A `<button aria-expanded>` opens a panel and has no
243
+ link; a plain `<a>` is a row with a link and no panel links.
244
+ 3. A panel is columns of links: three nested lists (menu, column, link). Store two. The column
245
+ title becomes a value on each link row, and the template groups links by it. Two list levels
246
+ is the depth chrome is proven to bind at, and it is also the cap for a component's props.
247
+ 4. `sticky`, colours and height are design and stay in the code. `aria-expanded` is behaviour.
248
+ 5. The logo, "Sign in" and the button use the Section's existing logo, link and button fields.
249
+ Read them with `get_layout`; add only what is missing.
250
+
251
+ Schema (`update_layout`, one `add-field` command, `ifMatch` from `get_layout`):
252
+
253
+ ```json
254
+ {
255
+ "scope": "global",
256
+ "ifMatch": 12,
257
+ "commands": [
258
+ { "type": "add-field", "sectionId": "navigation", "field": {
259
+ "id": "navigation.menus", "slug": "menus", "label": "Menus", "type": "repeater",
260
+ "fields": [
261
+ { "id": "navigation.menus.label", "slug": "label", "label": "Label", "type": "text" },
262
+ { "id": "navigation.menus.destination", "slug": "destination", "label": "Link", "type": "link" },
263
+ { "id": "navigation.menus.links", "slug": "links", "label": "Panel links", "type": "repeater",
264
+ "fields": [
265
+ { "id": "navigation.menus.links.column", "slug": "column", "label": "Column title", "type": "text" },
266
+ { "id": "navigation.menus.links.label", "slug": "label", "label": "Label", "type": "text" },
267
+ { "id": "navigation.menus.links.description", "slug": "description", "label": "Description", "type": "text" },
268
+ { "id": "navigation.menus.links.badge", "slug": "badge", "label": "Badge", "type": "text" },
269
+ { "id": "navigation.menus.links.destination", "slug": "destination", "label": "Link", "type": "link" }
270
+ ] }
271
+ ] } }
272
+ ]
273
+ }
274
+ ```
275
+
276
+ Value (`update_layout`). A nested value lives inside its parent, keyed by child id. Prefer
277
+ `set-field-value` on the top-level field: `set-section-values` replaces every value of the
278
+ Section, the logo and the button included.
279
+
280
+ ```json
281
+ {
282
+ "scope": "global",
283
+ "ifMatch": 13,
284
+ "command": { "type": "set-field-value", "sectionId": "navigation", "fieldId": "navigation.menus", "value": [
285
+ { "navigation.menus.label": "Product", "navigation.menus.links": [
286
+ { "navigation.menus.links.column": "Platform", "navigation.menus.links.label": "Queues",
287
+ "navigation.menus.links.description": "Durable jobs with retries", "navigation.menus.links.destination": "/product/queues" },
288
+ { "navigation.menus.links.column": "Platform", "navigation.menus.links.label": "Workflows",
289
+ "navigation.menus.links.description": "Multi-step jobs with state", "navigation.menus.links.badge": "New",
290
+ "navigation.menus.links.destination": "/product/workflows" },
291
+ { "navigation.menus.links.column": "Integrations", "navigation.menus.links.label": "Vercel",
292
+ "navigation.menus.links.destination": "/integrations/vercel" },
293
+ { "navigation.menus.links.column": "Integrations", "navigation.menus.links.label": "Supabase",
294
+ "navigation.menus.links.destination": "/integrations/supabase" }
295
+ ] },
296
+ { "navigation.menus.label": "Pricing", "navigation.menus.destination": "/pricing" }
297
+ ] }
298
+ }
299
+ ```
300
+
301
+ Each command returns the next revision. The draft goes live with `publish_layout`; confirm with
302
+ `get_layout` and `copy: "published"`.
303
+
304
+ Bound template (the trigger and one link):
305
+
306
+ ```html
307
+ <button class="nav__trigger" aria-expanded="false" aria-controls="mega-product"
308
+ data-bcms-layout-field="layout:navigation:navigation.menus[0].navigation.menus.label">Product</button>
309
+ <p class="mega__title">Platform</p>
310
+ <a href="/product/queues"
311
+ data-bcms-props="layout:navigation:navigation.menus[0].navigation.menus.links[0].navigation.menus.links.destination|url|href">
312
+ <span class="mega__label"
313
+ data-bcms-layout-field="layout:navigation:navigation.menus[0].navigation.menus.links[0].navigation.menus.links.label">Queues</span>
314
+ <span class="mega__desc"
315
+ data-bcms-layout-field="layout:navigation:navigation.menus[0].navigation.menus.links[0].navigation.menus.links.description">Durable jobs with retries</span>
316
+ </a>
317
+ ```
318
+
319
+ - Chrome uses `data-bcms-layout-field`, never `data-bcms-field`. A row is the field id, a
320
+ bracketed index, then the child's full id. Do not write these by hand: `get_conversion_brief`
321
+ lists every chrome path; copy each one exactly.
322
+ - The column title is not bound: one value shows once per group but is stored on every link.
323
+ It is edited in the side panel.
324
+ - The label sits in its own `<span>`; a binding on the `<a>` would erase the description.
325
+ - A page that draws its own header gets no Layout copy on top, and a nav placed on a page as a
326
+ section renders nowhere. Remove page-level copies.
327
+
328
+ If the header is a component placed with `add-component`: declare a `table` prop for the menus
329
+ whose rows hold one nested `table` of links, with `column` as a text sub-field, and bind its
330
+ inputs to these Section fields with ids read from `get_layout`. Publish the component before the
331
+ Layout.
332
+
333
+ Not available today: a mega menu in the built-in navbar, which is flat (`logo`, `links` of label
334
+ and link, one `cta`). Three nested lists in a component prop: a `table` inside a `table` saves, a
335
+ third level is refused. A featured card per panel fits as a group on the menu row (image, eyebrow,
336
+ title, link), not as a list of cards. A typed link value with `rel` or "opens in a new tab" on a
337
+ menu link.
338
+
339
+ ## 8. Tabs holding columns of cards
340
+
341
+ Source: a product tour made in a page builder. One page uses it.
342
+
343
+ ```html
344
+ <section class="tour" id="tour">
345
+ <p class="eyebrow">How it works</p>
346
+ <h2>One platform, three jobs</h2>
347
+ <div class="tabs" data-tabs>
348
+ <div role="tablist" aria-label="Use cases">
349
+ <button role="tab" aria-controls="panel-plan" aria-selected="true"><i class="ti ti-calendar" aria-hidden="true"></i>Plan</button>
350
+ <button role="tab" aria-controls="panel-publish" aria-selected="false"><i class="ti ti-send" aria-hidden="true"></i>Publish</button>
351
+ </div>
352
+ <div role="tabpanel" id="panel-plan">
353
+ <div class="row row--gap-md">
354
+ <div class="col col-md-6">
355
+ <div class="card card--bordered">
356
+ <img src="/img/tour/calendar.png" alt="Content calendar with posts across a week">
357
+ <span class="card__eyebrow">Calendar</span>
358
+ <h3 class="card__title">See every channel in one week view</h3>
359
+ <p>Drag posts between days. Gaps and clashes show before they go live.</p>
360
+ <a class="btn btn-primary" href="/product/calendar">Explore the calendar</a>
361
+ <a class="btn btn-link" href="https://www.youtube.com/watch?v=dQw4w9WgXcQ">Watch 2-min demo</a>
362
+ </div>
363
+ </div>
364
+ <div class="col col-md-6">
365
+ <div class="stat"><span class="stat__value">3.4x</span><span class="stat__label">more posts shipped per editor</span></div>
366
+ </div>
367
+ </div>
368
+ </div>
369
+ <div role="tabpanel" id="panel-publish" hidden>
370
+ <div class="row">
371
+ <div class="col"><div class="card"><h3 class="card__title">Approvals that do not stall</h3>
372
+ <p>Reviewers get one link. Nothing goes out without a yes.</p>
373
+ <a class="btn btn-primary" href="/product/approvals">See approvals</a></div></div>
374
+ <div class="col"><blockquote class="quote"><p>We cut review time in half.</p><cite>Lena Ortiz, Social Lead</cite></blockquote></div>
375
+ </div>
376
+ </div>
377
+ </div>
378
+ </section>
379
+ ```
380
+
381
+ Reasoning:
382
+
383
+ 1. Count levels where the content changes kind, not `<div>`s. `.row`, `.col` and the action
384
+ wrapper are layout. The real levels: section, tab, column, the thing in the column.
385
+ 2. Tabs and columns are native container blocks. A `tabs` block holds tabs of a label and child
386
+ blocks; a `columns` block holds a list of columns, each a list of blocks. Use them; do not
387
+ model tabs as a table prop.
388
+ 3. A column holds different things (a card, a stat, a quote). Each is a small component placed as
389
+ an instance, so its copy is click-to-edit and the card can be reused. The card's two looks
390
+ (plain, bordered) are one `variant` select prop, not two components.
391
+ 4. One page uses the band, so the outer shape is a `section` block, not a component.
392
+ 5. `aria-selected` only says which tab opens first: behaviour. Column widths are layout.
393
+
394
+ Schema: the card component (`create_component`, then `publish_component`). The stat (`value`,
395
+ `label`) and the quote (`quote`, `cite`) components are built the same way.
396
+
397
+ ```json
398
+ {
399
+ "name": "Tour card",
400
+ "slug": "tour-card",
401
+ "category": "content",
402
+ "description": "Card with an image, a title, a short text and up to two buttons.",
403
+ "blockJson": [
404
+ { "type": "section", "id": "card-root", "props": { "children": [
405
+ { "type": "image", "id": "card-image", "props": { "src": "", "alt": "" } },
406
+ { "type": "text", "id": "card-eyebrow", "props": { "html": "" } },
407
+ { "type": "heading", "id": "card-title", "props": { "text": "", "level": 3 } },
408
+ { "type": "text", "id": "card-body", "props": { "html": "" } },
409
+ { "type": "button", "id": "card-primary", "props": { "text": "", "href": "" } },
410
+ { "type": "button", "id": "card-secondary", "props": { "text": "", "href": "", "variant": "link" } }
411
+ ] } }
412
+ ],
413
+ "props": [
414
+ { "key": "variant", "label": "Look", "type": "select", "defaultValue": "plain",
415
+ "config": { "options": ["plain", "bordered"] }, "target": { "blockId": "card-root", "path": "props.variant" } },
416
+ { "key": "image", "label": "Image", "type": "image", "target": { "blockId": "card-image", "path": "props.src" } },
417
+ { "key": "imageAlt", "label": "Image alt text", "type": "text", "target": { "blockId": "card-image", "path": "props.alt" } },
418
+ { "key": "eyebrow", "label": "Eyebrow", "type": "text", "target": { "blockId": "card-eyebrow", "path": "props.html" } },
419
+ { "key": "title", "label": "Title", "type": "text", "target": { "blockId": "card-title", "path": "props.text" } },
420
+ { "key": "body", "label": "Text", "type": "text", "target": { "blockId": "card-body", "path": "props.html" } },
421
+ { "key": "primaryLabel", "label": "Primary button label", "type": "text", "target": { "blockId": "card-primary", "path": "props.text" } },
422
+ { "key": "primaryUrl", "label": "Primary button link", "type": "url", "target": { "blockId": "card-primary", "path": "props.href" } },
423
+ { "key": "secondaryLabel", "label": "Secondary button label", "type": "text", "target": { "blockId": "card-secondary", "path": "props.text" } },
424
+ { "key": "secondaryUrl", "label": "Secondary button link", "type": "url", "target": { "blockId": "card-secondary", "path": "props.href" } }
425
+ ]
426
+ }
427
+ ```
428
+
429
+ Value: the section block, sent inside the page's whole `blockJson` (`update_page` with `ifMatch`,
430
+ or `compose_pages`):
431
+
432
+ ```json
433
+ { "type": "section", "id": "tour", "label": "Product tour", "props": { "children": [
434
+ { "type": "text", "id": "tour-eyebrow", "props": { "html": "How it works" } },
435
+ { "type": "heading", "id": "tour-title", "props": { "text": "One platform, three jobs", "level": 2 } },
436
+ { "type": "tabs", "id": "tour-tabs", "props": { "tabs": [
437
+ { "id": "plan", "label": "Plan", "children": [
438
+ { "type": "columns", "id": "plan-cols", "props": { "gap": 24, "columns": [
439
+ [ { "type": "component", "id": "plan-card", "props": { "componentId": "<tour card id>", "overrides": {
440
+ "variant": "bordered",
441
+ "image": "/img/tour/calendar.png", "imageAlt": "Content calendar with posts across a week",
442
+ "eyebrow": "Calendar", "title": "See every channel in one week view",
443
+ "body": "Drag posts between days. Gaps and clashes show before they go live.",
444
+ "primaryLabel": "Explore the calendar", "primaryUrl": "/product/calendar",
445
+ "secondaryLabel": "Watch 2-min demo", "secondaryUrl": "https://www.youtube.com/watch?v=dQw4w9WgXcQ" } } } ],
446
+ [ { "type": "component", "id": "plan-stat", "props": { "componentId": "<stat id>", "overrides": {
447
+ "value": "3.4x", "label": "more posts shipped per editor" } } } ]
448
+ ] } }
449
+ ] },
450
+ { "id": "publish", "label": "Publish", "children": [
451
+ { "type": "columns", "id": "publish-cols", "props": { "columns": [
452
+ [ { "type": "component", "id": "publish-card", "props": { "componentId": "<tour card id>", "overrides": {
453
+ "title": "Approvals that do not stall", "body": "Reviewers get one link. Nothing goes out without a yes.",
454
+ "primaryLabel": "See approvals", "primaryUrl": "/product/approvals" } } } ],
455
+ [ { "type": "component", "id": "publish-quote", "props": { "componentId": "<quote id>", "overrides": {
456
+ "quote": "We cut review time in half.", "cite": "Lena Ortiz, Social Lead" } } } ]
457
+ ] } }
458
+ ] }
459
+ ] } }
460
+ ] } }
461
+ ```
462
+
463
+ Bound template: render the blocks with `BcmsBlocks` and pass the component definitions; it emits
464
+ the attributes. What it writes:
465
+
466
+ ```html
467
+ <section data-bcms-block="tour">
468
+ <h2 data-bcms-field="tour-title__text" data-bcms-kind="text">One platform, three jobs</h2>
469
+ <button role="tab" data-bcms-field="tour-tabs__tabs[0].label" data-bcms-kind="text">Plan</button>
470
+ <div data-bcms-block="plan-card">
471
+ <h3 data-bcms-field="plan-card__overrides.title" data-bcms-kind="text">See every channel in one week view</h3>
472
+ <a href="/product/calendar" data-bcms-props="plan-card__overrides.primaryUrl|url|href">
473
+ <span data-bcms-field="plan-card__overrides.primaryLabel" data-bcms-kind="text">Explore the calendar</span></a>
474
+ </div>
475
+ </section>
476
+ ```
477
+
478
+ - Give every block, tab and instance a stable `id`. An id is the address; changing one orphans
479
+ the edits made against it.
480
+ - A block the section owns is addressed `<blockId>__<prop>`; a value inside an instance is
481
+ `<instanceId>__overrides.<key>`. Two instances of the card never share an address.
482
+ - Leave an unused prop out of `overrides` (the second card has no image and one button). The
483
+ definition's empty default renders nothing.
484
+ - Without the definitions passed to the renderer, every instance draws empty: check that before
485
+ debugging a card.
486
+
487
+ A slot can hold several children. Give the `slot` prop `config: { multiple: true }` and point its
488
+ target at a container's child list (`props.children` on a `section`, `props.columns.<n>` on a
489
+ `columns` block); a placement then lists the children, each a published component of this project:
490
+
491
+ ```json
492
+ { "type": "component", "id": "tour-cards", "props": { "componentId": "<card grid id>", "overrides": {
493
+ "cards": [
494
+ { "componentId": "<tour card id>", "overrides": { "title": "Plan" } },
495
+ { "componentId": "<stat id>", "overrides": { "value": "3.4x" }, "style": { "hideOn": ["mobile"] } }
496
+ ] } } }
497
+ ```
498
+
499
+ A fixed layout of different blocks that the editor never reorders is still a `columns` block, as
500
+ in the tabs above. Rules and refusal codes: [components.md](components.md).
501
+
502
+ Not available today: a tab icon (a tab has a label only) and a
503
+ "first open tab" setting. Per-column widths: columns are equal, and `gap` is a whole number of
504
+ pixels, not a named size. Per-breakpoint sizes are not available; `style.hideOn` (`["mobile"]`, `["tablet","desktop"]`) hides a block at those breakpoints, under 768, 768-1023 and 1024 and up, as `display:none` that keeps it in the HTML; all three hides it on the page, and no published page, Markdown twin or search index carries it.
505
+
506
+ ## Checks
507
+
508
+ Run the five checks at the end of [worked-examples.md](worked-examples.md). For chrome, also read
509
+ `get_layout` with `copy: "published"` after `publish_layout`: the draft is never the receipt.