@typeroll/mcp-server 0.42.2 → 0.43.0

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.
@@ -13,7 +13,7 @@ function existingCollectionArg(args) {
13
13
  export const collectionTools = [
14
14
  {
15
15
  name: 'create_collection',
16
- description: 'Create a new content collection with a field schema and (optionally) per-item URLs. Pass `route_template` (e.g. "/restaurants/{slug}") to give each published item its own URL — omit to default to "/{name}/{slug}", set to "" to disable per-item URLs entirely.',
16
+ description: 'Create a new content collection with a field schema and (optionally) per-item URLs. Pass `route_template` (e.g. "/restaurants/{slug}") to give each published item its own URL — omit to default to "/{name}/{slug}", set to "" to disable per-item URLs entirely. Pass template_kind for a polished native item template, or item_template_blocks for a custom block tree.',
17
17
  inputSchema: {
18
18
  name: z.string().regex(/^[a-z][a-z0-9_-]{0,62}$/),
19
19
  label_singular: z.string().min(1),
@@ -34,6 +34,11 @@ export const collectionTools = [
34
34
  sort_dir: z.enum(['asc', 'desc']).optional(),
35
35
  route_template: z.string().optional(),
36
36
  item_template_html: z.string().optional(),
37
+ template_kind: z
38
+ .enum(['blog', 'article', 'checklist', 'team', 'events', 'products', 'custom'])
39
+ .optional()
40
+ .describe('Native starter for item pages. article includes breadcrumbs + body outline; checklist includes optional PDF CTA + explicit previous/next fields.'),
41
+ item_template_blocks: z.array(z.any()).optional().describe('Explicit item-template block tree. Overrides template_kind.'),
37
42
  version: versionParam,
38
43
  },
39
44
  handler: withErrorBoundary(async (args, { client, siteId }) => {
@@ -32,15 +32,32 @@ export const migrationTools = [
32
32
  },
33
33
  {
34
34
  name: 'get_migration_readiness',
35
- description: "Preflight for an import: is this site actually ready to receive a migration? CALL THIS FIRST, before moving any content. Every check exists because its failure is INVISIBLE afterwards — the pages import, the previews render, the customer signs off, and something is quietly wrong. The blockers: media storage (without it every <img> keeps its original URL, so the shiny new site is still served images by the old host, and the day that hosting is cancelled every image breaks at once) and the hosting adapter (without credentials, deploys return a job id and publish nothing while reporting success). Warnings cover the pre-cutover verification URL, AI reconstruction, form notification email and whether the target has a design to rebuild INTO. Returns { ready, blockers[], warnings[], checks[] } — each with a `fix`. If `ready` is false, stop and report the blockers to the user rather than starting the import; the content work would have to be redone.",
35
+ description: "Preflight for an import: is this site actually ready to receive a migration? CALL THIS FIRST, before moving any content. Every check exists because its failure is INVISIBLE afterwards — the pages import, the previews render, the customer signs off, and something is quietly wrong. Pass proposed compositions to also inventory their block, field, and native-feature dependencies before implementation. A generic custom block, raw-HTML fallback, per-instance CSS workaround, missing block type, or missing declared field returns waiting_for_native_support and makes ready=false. Do not implement those workarounds; report the gap and wait for Core support. Business-specific custom blocks are allowed only when explicitly listed. Infrastructure warnings cover the pre-cutover verification URL, AI reconstruction, form notification email and whether the target has a design to rebuild INTO.",
36
36
  inputSchema: {
37
37
  source_url: z
38
38
  .string()
39
39
  .optional()
40
40
  .describe('The site you are migrating FROM, e.g. "https://oldsite.com". When given, the source is probed too: unreachable or bot-blocked (403/429) is a BLOCKER because an import from a host that refuses our requests produces empty pages, and whether /wp-json answers is reported as a warning (without it the importer must scrape HTML and loses ACF/custom fields).'),
41
+ compositions: z
42
+ .array(z.object({
43
+ id: z.string().optional(),
44
+ name: z.string().min(1),
45
+ fields: z.array(z.object({ name: z.string().min(1), type: z.string().optional() })).optional(),
46
+ blocks: z.array(z.any()),
47
+ business_specific_block_types: z.array(z.string()).optional(),
48
+ }))
49
+ .min(1)
50
+ .max(50)
51
+ .optional()
52
+ .describe('Proposed block compositions to review before build. Include fields when you want missing schema bindings detected. Explicitly list only genuinely business-specific custom block types.'),
41
53
  },
42
54
  handler: withErrorBoundary(async (args, { client, siteId }) => {
43
- const res = await client.get(siteId, 'migration-preflight', args.source_url ? { source_url: args.source_url } : undefined);
55
+ const res = args.compositions
56
+ ? await client.post(siteId, 'migration-preflight', {
57
+ ...(args.source_url ? { source_url: args.source_url } : {}),
58
+ compositions: args.compositions,
59
+ })
60
+ : await client.get(siteId, 'migration-preflight', args.source_url ? { source_url: args.source_url } : undefined);
44
61
  return ok(res);
45
62
  }),
46
63
  },
@@ -107,14 +107,13 @@ export const pageTools = [
107
107
  },
108
108
  {
109
109
  name: 'update_page',
110
- description: 'Shallow-merge update on a page (only the fields you pass change). BUFFER MODEL: content fields land in the page\'s unsaved DRAFT (working copy) — invisible to deploys and default previews until saved; `status`/`date_published` apply immediately. Pass save:true to commit in the same call, or commit_working_copy later after the user approves. Returns the draft view of the page. For "replace this page entirely", use replace_page. To switch content_mode safely, prefer `set_page_mode`.',
110
+ description: 'Shallow-merge update on a page (only the fields you pass change). BUFFER MODEL: content fields land in the page\'s unsaved DRAFT (working copy) — invisible to deploys and default previews until saved; `status`/`date_published` apply immediately. Pass save:true to commit in the same call, or commit_working_copy later after the user approves. Returns the draft view of the page. For "replace this page entirely", use replace_page. This tool does not accept content_mode; switch modes with `set_page_mode`.',
111
111
  inputSchema: {
112
112
  page_id: z.string(),
113
113
  patch: z
114
114
  .object({
115
115
  title: z.string().optional(),
116
116
  slug: z.string().optional(),
117
- content_mode: z.enum(['blocks', 'html']).optional().describe('Changing this raw skips revision snapshotting — for safe switches use set_page_mode.'),
118
117
  html_content: z.string().optional(),
119
118
  blocks: z.array(z.any()).optional().describe('Block tree, only used when content_mode="blocks".'),
120
119
  status: z.enum(['draft', 'review', 'unlisted', 'published']).optional(),
@@ -177,7 +176,7 @@ export const pageTools = [
177
176
  },
178
177
  {
179
178
  name: 'batch_update_pages',
180
- description: 'Apply per-page patches in one call (up to 200 entries). Each entry is { page_id, patch, save? }; failures are reported per-row, the rest still apply. BUFFER MODEL: content patches land in each page\'s unsaved draft; per-entry save:true (or the top-level save flag) commits — typical for a user-approved sweep.',
179
+ description: 'Apply per-page patches in one call (up to 200 entries). Each entry is { page_id, patch, save? }; failures are reported per-row, the rest still apply. BUFFER MODEL: content patches land in each page\'s unsaved draft; per-entry save:true (or the top-level save flag) commits — typical for a user-approved sweep. content_mode is rejected per-row; switch each page with set_page_mode.',
181
180
  inputSchema: {
182
181
  updates: z
183
182
  .array(z.object({
@@ -22,7 +22,7 @@ export const partialTools = [
22
22
  },
23
23
  {
24
24
  name: 'read_partial',
25
- description: 'Fetch one global block including its full HTML content.',
25
+ description: 'Fetch one global block including its content_mode and the active HTML or block tree.',
26
26
  inputSchema: {
27
27
  partial_id: z.string(),
28
28
  version: versionParam,
@@ -34,13 +34,14 @@ export const partialTools = [
34
34
  },
35
35
  {
36
36
  name: 'update_partial',
37
- description: 'Shallow-merge update on a global block. Use partial_id "header" or "footer" for the auto-injected layout blocks, or a kebab-case id for a free block. HTML is sanitized server-side. BUFFER MODEL: content lands in the partial\'s unsaved DRAFT (status applies immediately); pass save:true to commit in the same call, or commit_working_copy later.',
37
+ description: 'Shallow-merge update on a global block. Use partial_id "header" or "footer" for the auto-injected layout blocks, or a kebab-case id for a free block. HTML is sanitized server-side. This tool does not accept content_mode; switch it with set_partial_mode. BUFFER MODEL: content lands in the partial\'s unsaved DRAFT (status applies immediately); pass save:true to commit in the same call, or commit_working_copy later.',
38
38
  inputSchema: {
39
39
  partial_id: z.string(),
40
40
  patch: z
41
41
  .object({
42
42
  name: z.string().optional(),
43
43
  html_content: z.string().optional(),
44
+ blocks: z.array(z.any()).optional().describe('Block tree to stage before switching the partial to blocks mode.'),
44
45
  status: z.enum(['draft', 'published']).optional(),
45
46
  kind: z.enum(['header', 'footer', 'free']).optional(),
46
47
  })
@@ -53,6 +54,20 @@ export const partialTools = [
53
54
  return ok(res);
54
55
  }),
55
56
  },
57
+ {
58
+ name: 'set_partial_mode',
59
+ description: 'Switch a header, footer, or free partial between HTML and native blocks through the revision-safe mode endpoint. To author a native partial deterministically: update_partial with blocks + save:true, then set_partial_mode to="blocks", then read_partial and verify the returned mode/tree. Set convert:true only for heuristic HTML-to-block conversion.',
60
+ inputSchema: {
61
+ partial_id: z.string(),
62
+ to: z.enum(['blocks', 'html']),
63
+ convert: z.boolean().optional(),
64
+ version: versionParam,
65
+ },
66
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
67
+ const res = await client.post(siteId, `partials/${encodeURIComponent(args.partial_id)}/mode`, { to: args.to, convert: args.convert ?? false }, v(args.version));
68
+ return ok(res);
69
+ }),
70
+ },
56
71
  {
57
72
  name: 'replace_partial',
58
73
  description: 'Full replace of a global block (PUT). Pass html_content (and optionally name/status) directly — no wrapper object needed. ' +
@@ -74,6 +74,14 @@ export const settingsTools = [
74
74
  .passthrough()
75
75
  .optional(),
76
76
  social: z.record(z.string()).optional(),
77
+ cookie_consent: z.object({
78
+ enabled: z.boolean().optional(),
79
+ text: z.string().optional().describe('Localized consent copy. May include a privacy-policy link.'),
80
+ privacy_policy_url: z.string().optional(),
81
+ scripts_necessary: z.string().optional().describe('Trusted script markup that runs before consent.'),
82
+ scripts_optional: z.string().optional().describe('Trusted script markup activated only after full consent.'),
83
+ reload_after_consent: z.boolean().optional(),
84
+ }).optional().describe('Native consent banner configuration. Partial updates preserve omitted fields. Script fields execute in visitor browsers and are accepted under the API key authority.'),
77
85
  },
78
86
  handler: withErrorBoundary(async (args, { client, siteId }) => {
79
87
  const { version, ...body } = args;
package/dist/version.js CHANGED
@@ -8,4 +8,4 @@
8
8
  //
9
9
  // Keep it in lockstep with package.json: tests/version.test.ts asserts
10
10
  // VERSION === package.json.version, so a bump that forgets this line fails CI.
11
- export const VERSION = '0.42.2';
11
+ export const VERSION = '0.43.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@typeroll/mcp-server",
3
- "version": "0.42.2",
3
+ "version": "0.43.0",
4
4
  "description": "Model Context Protocol server for the Typeroll public API. Use with Claude Code or any MCP-compatible client to manage a Typeroll site.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/skills/tr-blog.md CHANGED
@@ -13,7 +13,11 @@ description: Use when the user wants to set up a blog, news section, podcast fee
13
13
  > `trigger_deploy`. Preview your drafts with `include_working_copy: true`.
14
14
 
15
15
 
16
- A blog in Typeroll is a **collection with `item_template_html` + `route_template`**. Every published item materialises as its own static page at build time — there is **no need to call `create_page` per article**. The detail design lives once in `item_template_html`; the listing lives once in a page with a `<!-- typeroll:listing -->` marker that `regenerate_collection_listing` refreshes.
16
+ A blog in Typeroll is a **collection with `item_template_blocks` +
17
+ `route_template`**. Every published item materialises as its own static page at
18
+ build time — there is **no need to call `create_page` per article**. Start with
19
+ the native `article` preset; the listing is a block-mode page with
20
+ `core/collection_list` and updates at build time.
17
21
 
18
22
  If you find yourself about to create 20 pages for 20 articles, stop — you're using the old pattern. The recipe below is the right one.
19
23
 
@@ -25,7 +29,7 @@ If you find yourself about to create 20 pages for 20 articles, stop — you're u
25
29
 
26
30
  ## Recipe
27
31
 
28
- ### 1. Create the collection with detail template baked in
32
+ ### 1. Create the collection with a native item composition
29
33
 
30
34
  ```
31
35
  create_collection {
@@ -34,27 +38,25 @@ create_collection {
34
38
  "label_plural": "Artiklar",
35
39
  "icon": "📝",
36
40
  "slug_field": "slug",
37
- "sort_field": "date",
41
+ "sort_field": "published_at",
38
42
  "sort_dir": "desc",
39
43
  "route_template": "/blog/{slug}",
40
- "item_template_html": "<article class=\"post\">\n <header class=\"post__header\">\n <time>{{date}}</time>\n <h1>{{title}}</h1>\n {{#author}}<p class=\"byline\">av {{author}}</p>{{/author}}\n </header>\n {{#image}}<img class=\"post__hero\" src=\"{{image}}\" alt=\"{{title}}\" />{{/image}}\n <div class=\"post__body\">{{{body}}}</div>\n</article>\n<style>\n.post{max-width:42rem;margin:3rem auto;padding:0 1rem}\n.post__header time{color:var(--color-text-light);font-size:0.85rem}\n.post__header h1{font-family:var(--font-heading);font-size:2.25rem;margin:0.25rem 0}\n.byline{color:var(--color-text-light);font-size:0.9rem}\n.post__hero{width:100%;aspect-ratio:16/9;object-fit:cover;border-radius:0.5rem;margin:2rem 0}\n.post__body{font-size:1.05rem;line-height:1.7}\n.post__body h2{font-family:var(--font-heading);margin-top:2rem}\n.post__body p{margin-bottom:1.25rem}\n</style>",
44
+ "template_kind": "article",
41
45
  "fields": [
42
46
  {"name": "title", "type": "text", "label": "Rubrik", "required": true},
43
47
  {"name": "slug", "type": "text", "label": "URL-slug", "required": true},
44
- {"name": "date", "type": "date", "label": "Datum", "required": true},
48
+ {"name": "published_at", "type": "date", "label": "Datum", "required": true},
45
49
  {"name": "author", "type": "text", "label": "Författare"},
46
50
  {"name": "excerpt", "type": "textarea", "label": "Ingress"},
47
51
  {"name": "body", "type": "richtext", "label": "Brödtext"},
48
- {"name": "image", "type": "image", "label": "Omslagsbild"}
52
+ {"name": "featured_image", "type": "image", "label": "Omslagsbild"}
49
53
  ]
50
54
  }
51
55
  ```
52
56
 
53
- **About `item_template_html`:**
54
- - `{{field}}` HTML-escapes the value (use for plain text).
55
- - `{{{field}}}` leaves it raw (use for `body` and any richtext).
56
- - `{{#field}}...{{/field}}` is a conditional — render the block only if the field is truthy. Useful for optional images, authors, etc.
57
- - **No loops, no nested conditionals.** If you need either, pre-render the HTML in a field on the item itself (see tr-collection-template for patterns).
57
+ The preset writes an ordinary, editable `item_template_blocks` tree with
58
+ breadcrumbs, title/date, body, and a server-rendered outline. Localize labels
59
+ or change field mappings in the collection template editor.
58
60
 
59
61
  **Field name rule:** ASCII only, lowercase, `[a-z][a-z0-9_-]*`. `ä→a`, `ö→o`, `å→a` for the `name`; the `label` can be anything.
60
62
 
@@ -64,84 +66,46 @@ create_collection {
64
66
  create_collection_item collection="blog" status="published" fields={
65
67
  "title": "Vår designfilosofi",
66
68
  "slug": "var-designfilosofi",
67
- "date": "2025-05-15",
69
+ "published_at": "2025-05-15",
68
70
  "author": "Anna Lindström",
69
71
  "excerpt": "Vi tror på enkelhet med syfte — varje beslut ska kunna motiveras.",
70
72
  "body": "<p>Lång brödtext här...</p><h2>En underrubrik</h2><p>Mer text...</p>",
71
- "image": "https://cdn.typeroll.com/..."
73
+ "featured_image": "https://cdn.typeroll.com/..."
72
74
  }
73
75
  ```
74
76
 
75
- If `image` is a URL from elsewhere, upload it first via `upload_media_from_url` and use the returned CDN URL.
77
+ If `featured_image` is a URL from elsewhere, upload it first via `upload_media_from_url` and use the returned CDN URL.
76
78
 
77
79
  Each published item with this collection's `route_template` automatically becomes `/blog/{slug}` at deploy time — you do **not** need to call `create_page`.
78
80
 
79
- ### 3. Build the listing page (once)
80
-
81
- Create a single page that hosts the listing. The HTML between the `typeroll:listing` markers gets regenerated whenever the collection changes:
82
-
83
- ```
84
- create_page title="Artiklar" slug="blog" status="published" content_mode="html"
85
- html_content="<section class=\"blog-listing\">
86
- <div class=\"container\">
87
- <h1 class=\"section-title\">Artiklar</h1>
88
- <!-- typeroll:listing:blog -->
89
- <!-- /typeroll:listing:blog -->
90
- </div>
91
- </section>
92
- <style>
93
- .blog-listing{padding:4rem 0}
94
- .blog-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:2rem;margin-top:2rem}
95
- .blog-card{border:1px solid var(--color-surface);border-radius:0.5rem;overflow:hidden}
96
- .blog-card a{text-decoration:none;display:block;color:var(--color-text)}
97
- .blog-card img{width:100%;aspect-ratio:16/9;object-fit:cover}
98
- .blog-card__body{padding:1.5rem}
99
- .blog-card__date{font-size:0.8rem;color:var(--color-text-light);display:block;margin-bottom:0.5rem}
100
- .blog-card__title{font-family:var(--font-heading);font-size:1.25rem;margin-bottom:0.5rem}
101
- .blog-card__excerpt{color:var(--color-text-light);font-size:0.9rem;margin-bottom:1rem}
102
- .blog-card__cta{color:var(--color-accent);font-size:0.85rem;font-weight:600}
103
- </style>"
104
- ```
105
-
106
- ### 4. Populate the listing (and re-run after every change)
81
+ ### 3. Build the native listing page once
107
82
 
108
83
  ```
109
- regenerate_collection_listing
110
- collection="blog"
111
- page_id="blog"
112
- item_template="<article class=\"blog-card\">
113
- <a href=\"{{url}}\">
114
- {{#image}}<img src=\"{{image}}\" alt=\"{{title}}\">{{/image}}
115
- <div class=\"blog-card__body\">
116
- <time class=\"blog-card__date\">{{date}}</time>
117
- <h2 class=\"blog-card__title\">{{title}}</h2>
118
- <p class=\"blog-card__excerpt\">{{excerpt}}</p>
119
- <span class=\"blog-card__cta\">Läs mer →</span>
120
- </div>
121
- </a>
122
- </article>"
123
- wrap_open="<div class=\"blog-grid\">"
124
- wrap_close="</div>"
84
+ create_page title="Artiklar" slug="blog" status="published" content_mode="blocks" blocks=[
85
+ {"id":"articles-heading","type":"core/heading","data":{"text":"Artiklar","level":"h1","size":"auto","align":"left"}},
86
+ {"id":"articles-list","type":"core/collection_list","data":{"collection":"blog","sort_by":"published_at","sort_order":"desc","item_block":"core/post_card","layout":"grid","cols":3,"gap":"md"}}
87
+ ]
125
88
  ```
126
89
 
127
- `{{url}}` resolves through the collection's `route_template`. Only the content between the markers is replaced; everything else on the page stays put. Re-run this whenever items are added, edited, or unpublished.
90
+ The list resolves from current collection data on every preview/build. No
91
+ generated HTML marker or regeneration call is needed.
128
92
 
129
- ### 5. Update the header partial to link to the listing
93
+ ### 4. Update the header partial to link to the listing
130
94
 
131
95
  ```
132
96
  read_partial partial_id="header"
133
97
  replace_partial partial_id="header" html_content="<updated with /blog link>"
134
98
  ```
135
99
 
136
- ### 6. Preview a single article
100
+ ### 5. Preview a single article
137
101
 
138
102
  ```
139
103
  get_preview_link collection_name="blog" item_id="<id>"
140
104
  ```
141
105
 
142
- The returned URL renders the item through `item_template_html` exactly as it'll appear in production.
106
+ The returned URL renders the native item block tree from current database content.
143
107
 
144
- ### 7. Deploy
108
+ ### 6. Deploy
145
109
 
146
110
  ```
147
111
  trigger_deploy
@@ -154,20 +118,22 @@ The build produces one HTML file per published article at `/blog/<slug>` plus th
154
118
 
155
119
  ```
156
120
  create_collection_item collection="blog" status="published" fields={ ... }
157
- regenerate_collection_listing collection="blog" page_id="blog" item_template="..." wrap_open="..." wrap_close="..."
158
121
  trigger_deploy
159
122
  ```
160
123
 
161
- Three calls. No per-article `create_page`. No HTML diffing by hand.
124
+ Two calls. No per-article `create_page`, listing regeneration, or HTML diffing.
162
125
 
163
126
  ## Pitfalls
164
127
 
165
- - **Don't fall back to "one page per article".** That was the pre-`item_template_html` pattern. It's strictly worse now: design changes mean editing N pages, you lose `{{url}}` resolution in listings, sitemap doesn't include items, previews can't surface a per-item URL — and the API will reject your attempt anyway. `create_page` rejects slugs containing slashes ("Invalid slug … slugs must not contain slashes"), so `slug: "blog/foo"` doesn't even get through. The collection's `route_template` is the only path to nested URLs.
166
- - **Slugs must be unique within the collection.** `regenerate_collection_listing` will silently drop items where `slug` is missing; the listing count will be lower than the item count.
128
+ - **Don't fall back to "one page per article".** Collection item routes keep
129
+ design, sitemap, preview, and nested URL handling centralized. `create_page`
130
+ rejects slashes in `slug`; use the collection's `route_template`.
131
+ - **Slugs must be unique within the collection.** An item whose route tokens
132
+ are missing cannot receive a static detail URL.
167
133
  - **Don't use non-ASCII field names.** `datum` not `Datum`; `forfattare` not `författare` in the `name`. The `label` is free-form.
168
- - **Listing goes stale if you forget step 4.** Every item change needs `regenerate_collection_listing`. Add it to your mental checklist after every `create/update_collection_item`.
169
- - **`{{#field}}...{{/field}}` only checks truthiness.** Empty string and the field being absent both count as falsy. If you need "render this block when `published_at` is later than today", do it in the data step — set a flag field.
170
- - **Template too clever.** Mustache substitution has no loops or arithmetic. For an article with chapter timestamps, multiple authors, a guest with nested links — pre-render the HTML into a single field at `create_collection_item` time. See `tr-collection-template` for concrete patterns.
134
+ - **Template too clever.** First compose current native blocks and run the
135
+ composition preflight. Use custom/HTML-backed behavior only when it is
136
+ genuinely business-specific, not to repair a generic Core gap.
171
137
 
172
138
  ## When you want a page that ISN'T a collection item
173
139
 
@@ -7,9 +7,46 @@ description: Use when building a rich per-item detail page for a Typeroll collec
7
7
 
8
8
  Prefer `item_template_blocks` when the design fits the block system. It can
9
9
  include `template/item_navigation`, whose previous/next URLs and titles are
10
- derived from the collection's `sort_field` and `sort_dir`; do not precompute
11
- four navigation fields per item. Use the HTML patterns below when the detail
12
- page genuinely needs richer loops or markup than the block schema provides.
10
+ derived from the collection's `sort_field` and `sort_dir`. When imported
11
+ navigation deliberately differs, bind its explicit URL/title fields instead.
12
+ The native `article` and `checklist` `template_kind` presets already compose
13
+ breadcrumbs, selected rich text, server-rendered outlines, optional PDF, and
14
+ navigation. Start there.
15
+
16
+ For an archive page, place `core/collection_list` and map its card contract
17
+ through `item_overrides` instead of pre-rendering cards:
18
+
19
+ ```json
20
+ {
21
+ "type": "core/collection_list",
22
+ "data": {
23
+ "collection": "checklists",
24
+ "layout": "grid",
25
+ "cols": { "mobile": 1, "tablet": 2, "desktop": 3 },
26
+ "item_overrides": {
27
+ "title_field": "title",
28
+ "excerpt_field": "excerpt",
29
+ "image_field": "image",
30
+ "image_alt_field": "image_alt",
31
+ "href_field": "url",
32
+ "heading_level": "h2",
33
+ "download_url_field": "pdf_url",
34
+ "download_label": "Download PDF"
35
+ }
36
+ }
37
+ }
38
+ ```
39
+
40
+ `core/post_card` omits its image when the mapped image field is empty and
41
+ keeps the title and download as separate links. A missing PDF similarly emits
42
+ no download action. Use `item_overrides.show_image: false` when the archive
43
+ design has no media at all.
44
+
45
+ Before using the HTML patterns below, submit the proposed composition through
46
+ `get_migration_readiness compositions=[…]`. If it reports a generic platform
47
+ gap, wait for native support rather than shipping a generic replacement block,
48
+ raw page HTML, or tenant CSS. The patterns below are for genuinely
49
+ business-specific structured behavior.
13
50
 
14
51
  `item_template_html` uses lightweight Mustache substitution:
15
52