@typeroll/mcp-server 0.7.5 → 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.
@@ -0,0 +1,171 @@
1
+ ---
2
+ name: tr-blog
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
+ ---
5
+
6
+ # Set up a blog / news section
7
+
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.
11
+
12
+ ## Preconditions
13
+
14
+ - Site exists with working header/footer.
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
+
18
+ ## Recipe
19
+
20
+ ### 1. Create the collection with detail template baked in
21
+
22
+ ```
23
+ create_collection {
24
+ "name": "blog",
25
+ "label_singular": "Artikel",
26
+ "label_plural": "Artiklar",
27
+ "icon": "📝",
28
+ "slug_field": "slug",
29
+ "sort_field": "date",
30
+ "sort_dir": "desc",
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>",
33
+ "fields": [
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"}
41
+ ]
42
+ }
43
+ ```
44
+
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).
50
+
51
+ **Field name rule:** ASCII only, lowercase, `[a-z][a-z0-9_-]*`. `ä→a`, `ö→o`, `å→a` for the `name`; the `label` can be anything.
52
+
53
+ ### 2. Seed with real content
54
+
55
+ ```
56
+ create_collection_item collection="blog" status="published" fields={
57
+ "title": "Vår designfilosofi",
58
+ "slug": "var-designfilosofi",
59
+ "date": "2025-05-15",
60
+ "author": "Anna Lindström",
61
+ "excerpt": "Vi tror på enkelhet med syfte — varje beslut ska kunna motiveras.",
62
+ "body": "<p>Lång brödtext här...</p><h2>En underrubrik</h2><p>Mer text...</p>",
63
+ "image": "https://cdn.typeroll.com/..."
64
+ }
65
+ ```
66
+
67
+ If `image` is a URL from elsewhere, upload it first via `upload_media_from_url` and use the returned CDN URL.
68
+
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`.
70
+
71
+ ### 3. Build the listing page (once)
72
+
73
+ Create a single page that hosts the listing. The HTML between the `typeroll:listing` markers gets regenerated whenever the collection changes:
74
+
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 -->
82
+ </div>
83
+ </section>
84
+ <style>
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}
87
+ .blog-card{border:1px solid var(--color-surface);border-radius:0.5rem;overflow:hidden}
88
+ .blog-card a{text-decoration:none;display:block;color:var(--color-text)}
89
+ .blog-card img{width:100%;aspect-ratio:16/9;object-fit:cover}
90
+ .blog-card__body{padding:1.5rem}
91
+ .blog-card__date{font-size:0.8rem;color:var(--color-text-light);display:block;margin-bottom:0.5rem}
92
+ .blog-card__title{font-family:var(--font-heading);font-size:1.25rem;margin-bottom:0.5rem}
93
+ .blog-card__excerpt{color:var(--color-text-light);font-size:0.9rem;margin-bottom:1rem}
94
+ .blog-card__cta{color:var(--color-accent);font-size:0.85rem;font-weight:600}
95
+ </style>"
96
+ ```
97
+
98
+ ### 4. Populate the listing (and re-run after every change)
99
+
100
+ ```
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>"
117
+ ```
118
+
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.
120
+
121
+ ### 5. Update the header partial to link to the listing
122
+
123
+ ```
124
+ read_partial partial_id="header"
125
+ replace_partial partial_id="header" html_content="<updated with /blog link>"
126
+ ```
127
+
128
+ ### 6. Preview a single article
129
+
130
+ ```
131
+ get_preview_link collection_name="blog" item_id="<id>"
132
+ ```
133
+
134
+ The returned URL renders the item through `item_template_html` exactly as it'll appear in production.
135
+
136
+ ### 7. Deploy
137
+
138
+ ```
139
+ trigger_deploy
140
+ get_deploy_status job_id=<id>
141
+ ```
142
+
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`.
144
+
145
+ ## Adding a new article later
146
+
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="..."
150
+ trigger_deploy
151
+ ```
152
+
153
+ Three calls. No per-article `create_page`. No HTML diffing by hand.
154
+
155
+ ## Pitfalls
156
+
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,169 @@
1
+ ---
2
+ name: tr-brand
3
+ description: Use when the user asks to create a brand identity, design system, or visual style for a site. Triggers on "create a brand", "design the look", "choose colors", "pick fonts", "make it look like [reference]", or "rebrand the site". Produces a cohesive palette, typography scale, and CSS custom properties applied to an existing site.
4
+ ---
5
+
6
+ # Design a brand identity for a Typeroll site
7
+
8
+ This skill turns a brief (or a reference URL/screenshot) into a complete
9
+ visual design system applied to the site's settings and partials.
10
+
11
+ ## Preconditions
12
+
13
+ - Site exists and MCP is configured.
14
+ - You have at least one of: industry, mood words, reference URL, existing
15
+ logo colors, or competitor sites to contrast with.
16
+
17
+ ## Step 1 — Gather context
18
+
19
+ Ask (or infer from the brief):
20
+
21
+ 1. **Industry + audience.** Law firm → formal, trust. Café → warm, approachable.
22
+ Tech startup → clean, modern. Interior design → refined, editorial.
23
+ 2. **Mood words.** 3–5 adjectives the brand should feel: "minimal, Nordic,
24
+ calm" or "bold, energetic, playful".
25
+ 3. **Reference.** A URL, a screenshot, or a competitor they like (and what
26
+ they want to be different from it).
27
+ 4. **Must-keep.** Existing logo color? Legal industry color conventions?
28
+
29
+ If the user provided a URL, fetch it and note the dominant colors,
30
+ typeface categories, and layout density.
31
+
32
+ ## Step 2 — Build the palette
33
+
34
+ A Typeroll site uses 7 color tokens:
35
+
36
+ | Token | Role | Design rule |
37
+ |---|---|---|
38
+ | `primary` | Brand identity. CTA buttons, active nav, links. | High contrast on `background`. |
39
+ | `secondary` | Header, footer, darker sections. | Darker or more neutral than primary. |
40
+ | `accent` | Highlights, price tags, badges, hover states. | High-energy complement. |
41
+ | `background` | Page background. | Near-white for light themes, near-black for dark. |
42
+ | `surface` | Cards, input boxes, code blocks. | Slightly off from `background`. |
43
+ | `text` | Body copy. | ≥4.5:1 contrast ratio on `background`. |
44
+ | `text_light` | Secondary labels, captions, placeholders. | ≥3:1 on `background`. |
45
+
46
+ **Palette recipes by mood:**
47
+
48
+ *Nordic / minimal:*
49
+ ```
50
+ primary: #1f2a30 secondary: #142027 accent: #c9b89a
51
+ background: #faf8f4 surface: #f2ede5 text: #1f2a30 text_light: #7a7265
52
+ ```
53
+
54
+ *Warm / artisan:*
55
+ ```
56
+ primary: #3d2b1f secondary: #2a1d14 accent: #c8860a
57
+ background: #fdf6ee surface: #f7ede0 text: #1a1008 text_light: #8a7060
58
+ ```
59
+
60
+ *Modern / tech:*
61
+ ```
62
+ primary: #2563eb secondary: #1e293b accent: #f59e0b
63
+ background: #ffffff surface: #f8fafc text: #0f172a text_light: #64748b
64
+ ```
65
+
66
+ *Editorial / dark:*
67
+ ```
68
+ primary: #e2c08d secondary: #0f0f0f accent: #e2c08d
69
+ background: #0f0f0f surface: #1a1a1a text: #f5f5f0 text_light: #a0a090
70
+ ```
71
+
72
+ Check WCAG contrast ratios mentally: text on background must be ≥4.5:1.
73
+ The online tool `https://webaim.org/resources/contrastchecker/` is useful
74
+ but not accessible during a tool call — reason about perceived contrast
75
+ instead (light grey on white = bad; dark grey on white = fine).
76
+
77
+ ## Step 3 — Choose typefaces
78
+
79
+ Pick from high-quality Google Fonts pairings:
80
+
81
+ | Heading | Body | Mood |
82
+ |---|---|---|
83
+ | Cormorant Garamond | Raleway | Luxury, editorial |
84
+ | Playfair Display | Source Sans 3 | Classic, readable |
85
+ | DM Serif Display | DM Sans | Contemporary, clean |
86
+ | Fraunces | Mulish | Artisan, craft |
87
+ | Syne | Inter | Bold, modern |
88
+ | Plus Jakarta Sans | Plus Jakarta Sans | Clean, versatile |
89
+ | Libre Baskerville | Libre Franklin | Traditional, trustworthy |
90
+
91
+ Same font for heading and body is fine if it has enough weight variation
92
+ (Inter at 700 + 400 works well).
93
+
94
+ `size_base` should be 16 for most sites; 17–18 for text-heavy editorial
95
+ sites; 15 for dense dashboards.
96
+
97
+ ## Step 4 — Apply to the site
98
+
99
+ One call sets everything:
100
+
101
+ ```
102
+ update_site_settings {
103
+ "colors": { ...all 7 tokens },
104
+ "fonts": { "heading": "...", "body": "...", "size_base": 16 },
105
+ "custom_css": "/* optional: utility classes or @keyframes */"
106
+ }
107
+ ```
108
+
109
+ Read back to confirm: `read_site_settings`.
110
+
111
+ ## Step 5 — Update partials to use the new palette
112
+
113
+ Partials that hardcoded hex colors need updating. Fetch the header:
114
+
115
+ ```
116
+ read_partial partial_id="header"
117
+ ```
118
+
119
+ If it has hardcoded colors, replace them with CSS variable references
120
+ (`var(--color-primary)`) and call `replace_partial`:
121
+
122
+ ```
123
+ replace_partial partial_id="header" html_content="<updated HTML>"
124
+ ```
125
+
126
+ Same for footer.
127
+
128
+ ## Step 6 — Custom CSS for advanced tokens (optional)
129
+
130
+ If the brand needs things beyond the 7 base tokens — e.g. a gradient,
131
+ a special border radius, or a branded highlight color — add them via
132
+ `custom_css`:
133
+
134
+ ```css
135
+ :root {
136
+ --brand-gradient: linear-gradient(135deg, var(--color-primary), var(--color-accent));
137
+ --radius-brand: 2px; /* sharp corners for formal brands */
138
+ --letter-spacing-display: -0.03em; /* tight tracking for display headings */
139
+ }
140
+ ```
141
+
142
+ Then reference `var(--brand-gradient)` etc. in page HTML and partials.
143
+
144
+ ## Step 7 — Preview
145
+
146
+ ```
147
+ get_preview_link
148
+ ```
149
+
150
+ Open in browser. Check:
151
+ - Colors render as intended (not "undefined" or missing)
152
+ - Fonts load (Google Fonts link is in `<head>`)
153
+ - Nav text is readable against header background
154
+ - Body text has sufficient contrast
155
+
156
+ ## Pitfalls
157
+
158
+ - **Don't set colors without checking the header contrast.** If `primary`
159
+ is light, white nav text becomes unreadable. Either darken `primary` or
160
+ make the header use `secondary`.
161
+ - **Custom_css is global.** Rules here apply to every page. Keep it to
162
+ `:root {}` token additions and truly global utilities. Page-specific
163
+ styles go in the page's HTML `<style>` block.
164
+ - **Google Fonts load time.** Two different font families is fine; three
165
+ adds measurable LCP impact. Stick to two families with variable-font
166
+ versions when possible.
167
+ - **Dark themes need dark surface too.** Setting `background: #0f0f0f`
168
+ but leaving `surface: #f8fafc` (white) breaks every card/input. Always
169
+ update all 7 tokens as a set.
@@ -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.