@bettercms-ai/mcp 0.83.0 → 0.83.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +86 -86
- package/SKILL.md +160 -160
- package/dist/index.js +38 -9
- package/dist/index.js.map +1 -1
- package/dist/kit-catalog.json +1509 -1509
- package/dist/skills/bettercms/SKILL.md +206 -206
- package/dist/skills/bettercms/references/accessibility.md +171 -171
- package/dist/skills/bettercms/references/animation.md +296 -296
- package/dist/skills/bettercms/references/astro.md +148 -148
- package/dist/skills/bettercms/references/bindings.md +366 -366
- package/dist/skills/bettercms/references/componentize.md +185 -185
- package/dist/skills/bettercms/references/components.md +415 -415
- package/dist/skills/bettercms/references/content-experiments.md +62 -62
- package/dist/skills/bettercms/references/content-modeling.md +382 -382
- package/dist/skills/bettercms/references/detection.md +465 -465
- package/dist/skills/bettercms/references/dock-input.md +121 -121
- package/dist/skills/bettercms/references/editor-chrome.md +92 -92
- package/dist/skills/bettercms/references/external-content.md +78 -78
- package/dist/skills/bettercms/references/finish-site.md +377 -377
- package/dist/skills/bettercms/references/forms-and-interactions.md +245 -245
- package/dist/skills/bettercms/references/frameworks.md +159 -159
- package/dist/skills/bettercms/references/getting-started.md +156 -156
- package/dist/skills/bettercms/references/integrations.md +364 -364
- package/dist/skills/bettercms/references/kit-catalog.md +3259 -3259
- package/dist/skills/bettercms/references/kit-scenarios.md +712 -712
- package/dist/skills/bettercms/references/localization.md +85 -85
- package/dist/skills/bettercms/references/media-and-brand.md +181 -181
- package/dist/skills/bettercms/references/next.md +189 -189
- package/dist/skills/bettercms/references/other-frameworks.md +85 -85
- package/dist/skills/bettercms/references/portable-text.md +213 -213
- package/dist/skills/bettercms/references/programmatic-seo.md +98 -98
- package/dist/skills/bettercms/references/publishing.md +289 -289
- package/dist/skills/bettercms/references/seo.md +357 -357
- package/dist/skills/bettercms/references/shopify-headless.md +52 -52
- package/dist/skills/bettercms/references/site-blueprints.md +895 -895
- package/dist/skills/bettercms/references/site-review.md +74 -74
- package/dist/skills/bettercms/references/worked-examples-advanced.md +509 -509
- package/dist/skills/bettercms/references/worked-examples.md +885 -885
- package/dist/skills/bettercms/references/write-limits.md +217 -217
- package/package.json +1 -1
|
@@ -1,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.
|