@typeroll/mcp-server 0.7.7 → 0.7.8

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/skills/tr-blog.md CHANGED
@@ -1,103 +1,89 @@
1
1
  ---
2
2
  name: tr-blog
3
- description: Use when the user wants to set up a blog, news section, or article feed on a Typeroll site. Triggers on "add a blog", "set up news", "article section", "create posts", "inlägg", "nyheter", or when the user wants to manage a list of dated articles.
3
+ description: Use when the user wants to set up a blog, news section, podcast feed, or any time-ordered article-style content on a Typeroll site. Triggers on "add a blog", "set up news", "article section", "create posts", "podcast", "inlägg", "nyheter", "avsnitt", or any feed-of-dated-entries pattern.
4
4
  ---
5
5
 
6
6
  # Set up a blog / news section
7
7
 
8
- Typeroll doesn't have a built-in bloginstead the AI writes listing HTML
9
- directly into a page, sourced from a collection. This skill wires the whole
10
- flow: collection schema seed items listing page individual item URLs
11
- via `route_template` → deploy.
8
+ 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.
9
+
10
+ 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.
12
11
 
13
12
  ## Preconditions
14
13
 
15
14
  - Site exists with working header/footer.
16
- - You know the desired collection name (e.g. `blog`, `news`, `artiklar`).
15
+ - Collection name picked (`blog`, `news`, `artiklar`, `podcast`, `avsnitt`).
16
+ - URL structure picked: `/blog/{slug}`, `/news/{slug}`, `/podd/{slug}`. Changing later renames every URL.
17
17
 
18
18
  ## Recipe
19
19
 
20
- ### 1. Create the collection
20
+ ### 1. Create the collection with detail template baked in
21
21
 
22
22
  ```
23
23
  create_collection {
24
24
  "name": "blog",
25
25
  "label_singular": "Artikel",
26
26
  "label_plural": "Artiklar",
27
+ "icon": "📝",
27
28
  "slug_field": "slug",
28
29
  "sort_field": "date",
29
30
  "sort_dir": "desc",
30
31
  "route_template": "/blog/{slug}",
32
+ "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>",
31
33
  "fields": [
32
- {"name": "title", "type": "text", "label": "Rubrik", "required": true},
33
- {"name": "slug", "type": "text", "label": "URL-slug", "required": true},
34
- {"name": "date", "type": "date", "label": "Datum", "required": true},
35
- {"name": "author", "type": "text", "label": "Författare"},
36
- {"name": "excerpt", "type": "textarea", "label": "Ingress"},
37
- {"name": "body", "type": "richtext", "label": "Brödtext"},
38
- {"name": "image", "type": "image", "label": "Omslagsbild"},
39
- {"name": "tags", "type": "text", "label": "Taggar (kommaseparerade)"}
34
+ {"name": "title", "type": "text", "label": "Rubrik", "required": true},
35
+ {"name": "slug", "type": "text", "label": "URL-slug", "required": true},
36
+ {"name": "date", "type": "date", "label": "Datum", "required": true},
37
+ {"name": "author", "type": "text", "label": "Författare"},
38
+ {"name": "excerpt", "type": "textarea", "label": "Ingress"},
39
+ {"name": "body", "type": "richtext", "label": "Brödtext"},
40
+ {"name": "image", "type": "image", "label": "Omslagsbild"}
40
41
  ]
41
42
  }
42
43
  ```
43
44
 
44
- **Field name rule:** ASCII only, lowercase. `ä→a`, `ö→o`, `å→a`.
45
+ **About `item_template_html`:**
46
+ - `{{field}}` HTML-escapes the value (use for plain text).
47
+ - `{{{field}}}` leaves it raw (use for `body` and any richtext).
48
+ - `{{#field}}...{{/field}}` is a conditional — render the block only if the field is truthy. Useful for optional images, authors, etc.
49
+ - **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).
45
50
 
46
- ### 2. Seed with real content
51
+ **Field name rule:** ASCII only, lowercase, `[a-z][a-z0-9_-]*`. `ä→a`, `ö→o`, `å→a` for the `name`; the `label` can be anything.
47
52
 
48
- Add 2–3 initial articles. Use `create_collection_item`:
53
+ ### 2. Seed with real content
49
54
 
50
55
  ```
51
- create_collection_item collection="blog" fields={
56
+ create_collection_item collection="blog" status="published" fields={
52
57
  "title": "Vår designfilosofi",
53
58
  "slug": "var-designfilosofi",
54
59
  "date": "2025-05-15",
55
60
  "author": "Anna Lindström",
56
61
  "excerpt": "Vi tror på enkelhet med syfte — varje beslut ska kunna motiveras.",
57
- "body": "<p>...</p>",
62
+ "body": "<p>Lång brödtext här...</p><h2>En underrubrik</h2><p>Mer text...</p>",
58
63
  "image": "https://cdn.typeroll.com/..."
59
64
  }
60
65
  ```
61
66
 
62
- If `image` is a URL, upload it first via `upload_media_from_url` and use
63
- the returned CDN URL.
64
-
65
- ### 3. List all items for the listing page
66
-
67
- ```
68
- list_collection_items collection="blog"
69
- ```
67
+ If `image` is a URL from elsewhere, upload it first via `upload_media_from_url` and use the returned CDN URL.
70
68
 
71
- Use the response to generate the listing HTML. The static site has no
72
- template engine — the HTML is the listing.
69
+ Each published item with this collection's `route_template` automatically becomes `/blog/{slug}` at deploy time — you do **not** need to call `create_page`.
73
70
 
74
- ### 4. Create the listing page
71
+ ### 3. Build the listing page (once)
75
72
 
76
- Build HTML manually from the collection items. Include all the data you
77
- want visible without a detail click. Example structure:
73
+ Create a single page that hosts the listing. The HTML between the `typeroll:listing` markers gets regenerated whenever the collection changes:
78
74
 
79
- ```html
80
- <section class="blog-listing">
81
- <div class="container">
82
- <h1 class="section-title">Artiklar</h1>
83
- <div class="blog-grid">
84
- <!-- one .blog-card per item -->
85
- <article class="blog-card">
86
- <a href="/blog/var-designfilosofi">
87
- <img src="https://cdn.typeroll.com/..." alt="Omslagsbild">
88
- <div class="blog-card__body">
89
- <time class="blog-card__date">15 maj 2025</time>
90
- <h2 class="blog-card__title">Vår designfilosofi</h2>
91
- <p class="blog-card__excerpt">Vi tror på enkelhet med syfte...</p>
92
- <span class="blog-card__cta">Läs mer →</span>
93
- </div>
94
- </a>
95
- </article>
96
- </div>
75
+ ```
76
+ create_page title="Artiklar" slug="blog" status="published" content_mode="html"
77
+ html_content="<section class=\"blog-listing\">
78
+ <div class=\"container\">
79
+ <h1 class=\"section-title\">Artiklar</h1>
80
+ <!-- typeroll:listing:blog -->
81
+ <!-- /typeroll:listing:blog -->
97
82
  </div>
98
83
  </section>
99
84
  <style>
100
- .blog-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:2rem}
85
+ .blog-listing{padding:4rem 0}
86
+ .blog-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:2rem;margin-top:2rem}
101
87
  .blog-card{border:1px solid var(--color-surface);border-radius:0.5rem;overflow:hidden}
102
88
  .blog-card a{text-decoration:none;display:block;color:var(--color-text)}
103
89
  .blog-card img{width:100%;aspect-ratio:16/9;object-fit:cover}
@@ -106,72 +92,80 @@ want visible without a detail click. Example structure:
106
92
  .blog-card__title{font-family:var(--font-heading);font-size:1.25rem;margin-bottom:0.5rem}
107
93
  .blog-card__excerpt{color:var(--color-text-light);font-size:0.9rem;margin-bottom:1rem}
108
94
  .blog-card__cta{color:var(--color-accent);font-size:0.85rem;font-weight:600}
109
- </style>
95
+ </style>"
110
96
  ```
111
97
 
98
+ ### 4. Populate the listing (and re-run after every change)
99
+
112
100
  ```
113
- create_page title="Artiklar" slug="blog"
114
- html_content="<listing HTML>"
115
- content_mode="html" status="published"
101
+ regenerate_collection_listing
102
+ collection="blog"
103
+ page_id="blog"
104
+ item_template="<article class=\"blog-card\">
105
+ <a href=\"{{url}}\">
106
+ {{#image}}<img src=\"{{image}}\" alt=\"{{title}}\">{{/image}}
107
+ <div class=\"blog-card__body\">
108
+ <time class=\"blog-card__date\">{{date}}</time>
109
+ <h2 class=\"blog-card__title\">{{title}}</h2>
110
+ <p class=\"blog-card__excerpt\">{{excerpt}}</p>
111
+ <span class=\"blog-card__cta\">Läs mer →</span>
112
+ </div>
113
+ </a>
114
+ </article>"
115
+ wrap_open="<div class=\"blog-grid\">"
116
+ wrap_close="</div>"
116
117
  ```
117
118
 
118
- ### 5. Create individual article pages
119
+ `{{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.
119
120
 
120
- Each collection item with `route_template: "/blog/{slug}"` gets its own
121
- URL at deploy time IF you create a matching page for each article. Create
122
- one page per article:
121
+ ### 5. Update the header partial to link to the listing
123
122
 
124
123
  ```
125
- create_page title="Vår designfilosofi" slug="blog/var-designfilosofi"
126
- html_content="<full article HTML>"
127
- content_mode="html" kind="article"
128
- author="Anna Lindström"
129
- seo_title="Vår designfilosofi — Acme Studio"
130
- seo_description="Ingress here"
131
- status="published"
124
+ read_partial partial_id="header"
125
+ replace_partial partial_id="header" html_content="<updated with /blog link>"
132
126
  ```
133
127
 
134
- The slug `blog/var-designfilosofi` maps to URL `/blog/var-designfilosofi`.
135
-
136
- **Important:** Typeroll does NOT auto-generate detail pages from collection
137
- items. You write the detail HTML per article — this gives full control over
138
- the design but means each article is a separate `create_page` call.
139
-
140
- ### 6. Update nav to link to the blog
128
+ ### 6. Preview a single article
141
129
 
142
- Read the header partial and add a "Blog" or "Artiklar" link:
143
130
  ```
144
- read_partial partial_id="header"
145
- replace_partial partial_id="header" html_content="<updated with blog link>"
131
+ get_preview_link collection_name="blog" item_id="<id>"
146
132
  ```
147
133
 
148
- ### 7. Keep listings in sync
134
+ The returned URL renders the item through `item_template_html` exactly as it'll appear in production.
135
+
136
+ ### 7. Deploy
149
137
 
150
- When new articles are added later, the AI must:
151
- 1. `create_collection_item` with the article data
152
- 2. `create_page` for the article's detail URL
153
- 3. Re-read the full collection via `list_collection_items`
154
- 4. Regenerate the listing HTML and `update_page` the listing page
138
+ ```
139
+ trigger_deploy
140
+ get_deploy_status job_id=<id>
141
+ ```
155
142
 
156
- Or call `regenerate_collection_listing collection="blog"` if the site has
157
- that route configured — it reruns the listing generation.
143
+ The build produces one HTML file per published article at `/blog/<slug>` plus the listing at `/blog`, and includes them all in `sitemap.xml`.
158
144
 
159
- ### 8. Deploy
145
+ ## Adding a new article later
160
146
 
161
147
  ```
148
+ create_collection_item collection="blog" status="published" fields={ ... }
149
+ regenerate_collection_listing collection="blog" page_id="blog" item_template="..." wrap_open="..." wrap_close="..."
162
150
  trigger_deploy
163
- get_deploy_status job_id=<id>
164
151
  ```
165
152
 
153
+ Three calls. No per-article `create_page`. No HTML diffing by hand.
154
+
166
155
  ## Pitfalls
167
156
 
168
- - **Listing page goes stale.** Every new article needs the listing page
169
- regenerated. There's no auto-sync; you must update the HTML.
170
- - **Don't use non-ASCII field names.** `datum` not `datum`, `rubrik` not
171
- `Rubrik` as the field name. The `label` can be anything; the `name` must
172
- be `[a-z][a-z0-9_-]*`.
173
- - **Article slugs must be globally unique.** `/blog/` prefix in the slug
174
- avoids collisions with non-blog pages.
175
- - **`route_template` is decorative without matching pages.** The template
176
- declares the URL structure; actual routing depends on pages existing at
177
- those paths. Always create the page.
157
+ - **Don't fall back to "one page per article".** That was the pre-`item_template_html` pattern. It works but it's strictly worse: design changes mean editing N pages, you lose `{{url}}` resolution in listings, sitemap doesn't include items, and previews can't surface a per-item URL. If you find yourself writing `create_page` with `slug: "blog/foo"`, stop and reconsider — the only legitimate reason is a one-off "about the editorial team" page that isn't an article.
158
+ - **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.
159
+ - **Don't use non-ASCII field names.** `datum` not `Datum`; `forfattare` not `författare` in the `name`. The `label` is free-form.
160
+ - **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`.
161
+ - **`{{#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.
162
+ - **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.
163
+
164
+ ## When you want a page that ISN'T a collection item
165
+
166
+ A normal `create_page` is still right for:
167
+ - The blog's about/contact pages.
168
+ - Editorial standalone features.
169
+ - Anything that doesn't fit the "list of dated entries" mould.
170
+
171
+ Just don't use `create_page` *for the entries themselves*.
@@ -0,0 +1,262 @@
1
+ ---
2
+ name: tr-collection-template
3
+ description: Use when building a rich per-item detail page for a Typeroll collection — podcast episodes with audio players and chapter timestamps, case studies with guest cards and metric tiles, products with image galleries and spec tables, anything where the detail template would normally want loops or nested data. Covers the "pre-render into a field" pattern that gets you past the template's no-loops-no-conditionals limit.
4
+ ---
5
+
6
+ # Rich detail templates for collections
7
+
8
+ `item_template_html` uses lightweight Mustache substitution:
9
+
10
+ - `{{field}}` — HTML-escaped value
11
+ - `{{{field}}}` — raw value (for richtext / pre-rendered HTML)
12
+ - `{{#field}}…{{/field}}` — conditional, render block when field is truthy
13
+ - `{{url}}` — only meaningful in `regenerate_collection_listing`'s `item_template` (resolves through `route_template`)
14
+
15
+ **No loops, no nested field access, no arithmetic.** `{{chapters[0].title}}` doesn't work. `{{#chapters}}{{title}}{{/chapters}}` doesn't either — the section syntax is truthiness-only, not iteration.
16
+
17
+ The pattern that gets you everywhere: **pre-render the HTML into a single string field on the item itself.** The agent (you) does the loop in JavaScript/Python during data prep, then writes the resulting HTML into a richtext field like `chapters_html` or `gallery_html`. The template renders it raw with `{{{chapters_html}}}`.
18
+
19
+ This skill catalogues the patterns we hit most often, with copy-paste recipes.
20
+
21
+ ## Pattern 1 — Audio player + chapter list (podcast episodes)
22
+
23
+ **Data shape going in:**
24
+
25
+ ```json
26
+ {
27
+ "title": "Avsnitt 17 — Designsystem på riktigt",
28
+ "slug": "17-designsystem-pa-riktigt",
29
+ "date": "2025-05-15",
30
+ "audio_url": "https://cdn.example.com/avsnitt-17.mp3",
31
+ "duration_min": 42,
32
+ "chapters": [
33
+ { "time_seconds": 0, "title": "Intro" },
34
+ { "time_seconds": 132, "title": "Vad är ett designsystem?" },
35
+ { "time_seconds": 845, "title": "Tokens vs. komponenter" },
36
+ { "time_seconds": 1820, "title": "Vanliga fällor" }
37
+ ]
38
+ }
39
+ ```
40
+
41
+ **Pre-render `chapters_html` before calling `create_collection_item`:**
42
+
43
+ ```js
44
+ const formatTime = (s) =>
45
+ s < 3600
46
+ ? `${Math.floor(s/60)}:${String(s%60).padStart(2,'0')}`
47
+ : `${Math.floor(s/3600)}:${String(Math.floor(s%3600/60)).padStart(2,'0')}:${String(s%60).padStart(2,'0')}`;
48
+
49
+ const chaptersHtml = `
50
+ <ol class="chapters">
51
+ ${item.chapters.map(c => `
52
+ <li>
53
+ <button type="button" data-jump-to="${c.time_seconds}">
54
+ <span class="chapters__time">${formatTime(c.time_seconds)}</span>
55
+ <span class="chapters__title">${escapeHtml(c.title)}</span>
56
+ </button>
57
+ </li>
58
+ `).join('')}
59
+ </ol>`;
60
+ ```
61
+
62
+ **Schema (note `chapters_html: richtext` — it carries HTML):**
63
+
64
+ ```
65
+ create_collection {
66
+ "name": "avsnitt",
67
+ "label_singular": "Avsnitt",
68
+ "label_plural": "Avsnitt",
69
+ "slug_field": "slug",
70
+ "sort_field": "date",
71
+ "sort_dir": "desc",
72
+ "route_template": "/podd/{slug}",
73
+ "fields": [
74
+ {"name":"title", "type":"text", "required":true},
75
+ {"name":"slug", "type":"text", "required":true},
76
+ {"name":"date", "type":"date", "required":true},
77
+ {"name":"audio_url", "type":"text", "required":true},
78
+ {"name":"duration_min", "type":"number"},
79
+ {"name":"excerpt", "type":"textarea"},
80
+ {"name":"body", "type":"richtext"},
81
+ {"name":"chapters_html", "type":"richtext", "label":"Kapitellista (genereras)"}
82
+ ],
83
+ "item_template_html": "<article class=\"episode\">\n <header class=\"episode__hero\">\n <p class=\"episode__date\">{{date}} · {{duration_min}} min</p>\n <h1>{{title}}</h1>\n <p class=\"episode__excerpt\">{{excerpt}}</p>\n </header>\n <div class=\"episode__player\">\n <audio controls preload=\"metadata\" src=\"{{audio_url}}\"></audio>\n </div>\n {{#chapters_html}}<section class=\"episode__chapters\"><h2>Kapitel</h2>{{{chapters_html}}}</section>{{/chapters_html}}\n <section class=\"episode__notes\">{{{body}}}</section>\n <script>\n document.querySelectorAll('[data-jump-to]').forEach(b => {\n b.addEventListener('click', () => {\n const a = document.querySelector('audio');\n if (a) { a.currentTime = Number(b.dataset.jumpTo); a.play(); }\n });\n });\n </script>\n <style>\n .episode{max-width:42rem;margin:3rem auto;padding:0 1rem}\n .episode__hero{background:linear-gradient(135deg,var(--color-primary),var(--color-accent));color:#fff;padding:3rem 2rem;border-radius:1rem;margin-bottom:2rem}\n .episode__date{opacity:0.85;font-size:0.85rem}\n .episode__hero h1{font-family:var(--font-heading);font-size:2rem;margin:0.5rem 0}\n .episode__player audio{width:100%}\n .chapters{list-style:none;padding:0;margin:1.5rem 0}\n .chapters li{margin:0.25rem 0}\n .chapters button{display:flex;gap:1rem;width:100%;background:transparent;border:0;padding:0.5rem 0.75rem;cursor:pointer;text-align:left;border-radius:0.375rem;font:inherit;color:inherit}\n .chapters button:hover{background:var(--color-surface)}\n .chapters__time{font-variant-numeric:tabular-nums;color:var(--color-text-light);min-width:4ch}\n </style>\n</article>"
84
+ }
85
+ ```
86
+
87
+ **Create the item with both raw chapters AND the pre-rendered HTML:**
88
+
89
+ ```
90
+ create_collection_item collection="avsnitt" status="published" fields={
91
+ "title": "Avsnitt 17 — Designsystem på riktigt",
92
+ "slug": "17-designsystem-pa-riktigt",
93
+ "date": "2025-05-15",
94
+ "audio_url": "https://cdn.example.com/avsnitt-17.mp3",
95
+ "duration_min": 42,
96
+ "excerpt": "Vi pratar med...",
97
+ "body": "<p>...</p>",
98
+ "chapters_html": "<ol class=\"chapters\">...</ol>"
99
+ }
100
+ ```
101
+
102
+ The raw `chapters` array doesn't need to be stored unless you have a use for it (e.g. regenerating the HTML later from a structured source). If you do want it for round-trip editing, add a `chapters_json: textarea` field and stringify the array into it.
103
+
104
+ ## Pattern 2 — Guest card with nested fields
105
+
106
+ Each episode features a guest with a name, role, photo, and external links. Mustache can't reach into nested objects, so flatten OR pre-render.
107
+
108
+ **Option A: Flatten into prefixed fields (preferable when there's ≤1 guest):**
109
+
110
+ ```
111
+ fields: [
112
+ ...,
113
+ {"name":"guest_name", "type":"text"},
114
+ {"name":"guest_role", "type":"text"},
115
+ {"name":"guest_photo", "type":"image"},
116
+ {"name":"guest_bio", "type":"textarea"},
117
+ {"name":"guest_linkedin", "type":"text"},
118
+ {"name":"guest_website", "type":"text"}
119
+ ]
120
+ ```
121
+
122
+ In the template:
123
+
124
+ ```html
125
+ {{#guest_name}}
126
+ <aside class="guest">
127
+ {{#guest_photo}}<img src="{{guest_photo}}" alt="{{guest_name}}">{{/guest_photo}}
128
+ <div>
129
+ <h3>{{guest_name}}</h3>
130
+ <p class="guest__role">{{guest_role}}</p>
131
+ <p>{{guest_bio}}</p>
132
+ <p class="guest__links">
133
+ {{#guest_linkedin}}<a href="{{guest_linkedin}}">LinkedIn</a>{{/guest_linkedin}}
134
+ {{#guest_website}}<a href="{{guest_website}}">Webbplats</a>{{/guest_website}}
135
+ </p>
136
+ </div>
137
+ </aside>
138
+ {{/guest_name}}
139
+ ```
140
+
141
+ **Option B: Pre-render `guest_html` (when there are multiple guests or arbitrary depth):**
142
+
143
+ ```js
144
+ const guestHtml = item.guests.map(g => `
145
+ <article class="guest">
146
+ ${g.photo ? `<img src="${escapeHtml(g.photo)}" alt="${escapeHtml(g.name)}">` : ''}
147
+ <div>
148
+ <h3>${escapeHtml(g.name)}</h3>
149
+ <p class="guest__role">${escapeHtml(g.role)}</p>
150
+ ${g.links.map(l => `<a href="${escapeHtml(l.url)}">${escapeHtml(l.label)}</a>`).join(' · ')}
151
+ </div>
152
+ </article>
153
+ `).join('');
154
+ ```
155
+
156
+ Then `{{{guests_html}}}` in the template.
157
+
158
+ ## Pattern 3 — Gradient hero with computed colours
159
+
160
+ The hero needs a colour pair derived from a single brand colour the user picked per item. The template can't compute — pre-compute and pass as fields:
161
+
162
+ ```js
163
+ function shade(hex, amount) { /* lighten/darken */ }
164
+
165
+ const item = {
166
+ ...,
167
+ hero_from: rawColor,
168
+ hero_to: shade(rawColor, -0.2),
169
+ };
170
+ ```
171
+
172
+ Template:
173
+
174
+ ```html
175
+ <header class="hero" style="background:linear-gradient(135deg, {{hero_from}}, {{hero_to}})">
176
+ <h1>{{title}}</h1>
177
+ </header>
178
+ ```
179
+
180
+ Inline style with two substituted hex strings — works because the `{{}}` substitutions sit inside a CSS value, not as a CSS variable name. (Don't do this with user-supplied colours that haven't been validated — a malicious item could break out of the style attribute. For agent-curated colours this is fine.)
181
+
182
+ ## Pattern 4 — Image gallery with thumbnails
183
+
184
+ Same drill. The agent renders the gallery HTML when shaping the item:
185
+
186
+ ```js
187
+ const galleryHtml = `
188
+ <div class="gallery">
189
+ ${item.images.map((img, i) => `
190
+ <a href="${escapeHtml(img.full)}" class="gallery__item">
191
+ <img src="${escapeHtml(img.thumb)}" alt="${escapeHtml(img.alt || `Bild ${i+1}`)}" loading="lazy">
192
+ </a>
193
+ `).join('')}
194
+ </div>`;
195
+ ```
196
+
197
+ Schema gains `gallery_html: richtext`. Template renders `{{{gallery_html}}}`.
198
+
199
+ For a lightbox you can either inline a tiny vanilla JS handler in the item_template_html (works once per page load) or `tr-images` the gallery into a reusable partial.
200
+
201
+ ## Pattern 5 — Spec table (for products, services, etc.)
202
+
203
+ For a fixed set of spec fields (price, dimensions, in-stock, lead time), just add the fields explicitly:
204
+
205
+ ```
206
+ fields: [
207
+ ...,
208
+ {"name":"price_sek", "type":"number"},
209
+ {"name":"weight_g", "type":"number"},
210
+ {"name":"in_stock", "type":"boolean"},
211
+ {"name":"lead_days", "type":"number"}
212
+ ]
213
+ ```
214
+
215
+ ```html
216
+ <dl class="specs">
217
+ {{#price_sek}}<dt>Pris</dt><dd>{{price_sek}} kr</dd>{{/price_sek}}
218
+ {{#weight_g}}<dt>Vikt</dt><dd>{{weight_g}} g</dd>{{/weight_g}}
219
+ <dt>Lagerstatus</dt><dd>{{#in_stock}}I lager{{/in_stock}}{{^in_stock}}Slut{{/in_stock}}</dd>
220
+ {{#lead_days}}<dt>Leveranstid</dt><dd>{{lead_days}} dagar</dd>{{/lead_days}}
221
+ </dl>
222
+ ```
223
+
224
+ (`{{^field}}…{{/field}}` is the inverse of `{{#field}}` — render when falsy.)
225
+
226
+ For variable specs (different products have different attributes), fall back to a pre-rendered `specs_html` field.
227
+
228
+ ## Pre-rendering helpers — minimum viable
229
+
230
+ Every pre-render needs `escapeHtml`. Put this at the top of your data-prep script:
231
+
232
+ ```js
233
+ const escapeHtml = (s) =>
234
+ String(s ?? '')
235
+ .replace(/&/g, '&amp;')
236
+ .replace(/</g, '&lt;')
237
+ .replace(/>/g, '&gt;')
238
+ .replace(/"/g, '&quot;')
239
+ .replace(/'/g, '&#39;');
240
+ ```
241
+
242
+ Skip it only when you're certain the value can't carry user-supplied content (your own constants are fine; anything from a scrape, the user, or a model output goes through `escapeHtml`).
243
+
244
+ ## When to flatten vs pre-render
245
+
246
+ Rough guideline:
247
+
248
+ | Situation | Approach |
249
+ |---|---|
250
+ | 1–N optional related fields, fixed shape | Flatten into prefixed fields (`guest_name`, `guest_role`, …) and use `{{#field}}` conditionals |
251
+ | List of items with internal structure (chapters, gallery, related-links) | Pre-render to a single `*_html` field |
252
+ | Computed values (formatted dates, derived colours, totals) | Pre-compute as a sibling field, substitute with `{{}}` |
253
+ | Conditional sections based on multiple fields ("show this when status=published AND has_video") | Pre-compute a boolean field; conditional in the template |
254
+ | Genuinely dynamic content that changes per visitor | Doesn't fit — the static template renders once at build. Move to client-side JS in the template body, or rethink the page. |
255
+
256
+ ## Pitfalls
257
+
258
+ - **Forgetting `{{{ }}}` for pre-rendered HTML.** `{{chapters_html}}` (double braces) HTML-escapes the angle brackets and shows source code. Must be triple braces.
259
+ - **Mutating a published item's schema.** Removing or renaming a field that the template references silently produces empty sections. Keep templates in sync with schema changes.
260
+ - **Pre-rendered HTML drifts when you change the visual design.** The HTML for `chapters_html` was generated against the design as it was on import day. If you redo the look later, you need to re-prep + re-write every item's pre-rendered field, not just the template. Consider keeping the raw data (`chapters_json: textarea` with the original array stringified) so you can regenerate.
261
+ - **Inline `<script>` in `item_template_html` runs once per page.** That's fine for self-contained per-page widgets (the audio chapter-jumper above). If two collections need the same widget, factor it into a partial that both `item_template_html`s `<x-include>`.
262
+ - **Don't put credentials in pre-rendered HTML.** API keys, signed tokens — they go into the build output and end up on the public web. Run any prep step you wouldn't paste into a public Gist with that in mind.