@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/README.md +39 -13
- package/dist/index.js +15 -65
- package/dist/server.js +129 -0
- package/package.json +7 -1
- package/skills/README.md +9 -6
- package/skills/tr-blog.md +91 -97
- package/skills/tr-collection-template.md +262 -0
- package/skills/tr-migrate-astro.md +278 -0
- package/skills/tr-page-template.md +168 -0
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
|
|
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
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
-
|
|
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",
|
|
33
|
-
{"name": "slug",
|
|
34
|
-
{"name": "date",
|
|
35
|
-
{"name": "author",
|
|
36
|
-
{"name": "excerpt",
|
|
37
|
-
{"name": "body",
|
|
38
|
-
{"name": "image",
|
|
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
|
-
**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
###
|
|
71
|
+
### 3. Build the listing page (once)
|
|
75
72
|
|
|
76
|
-
|
|
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
|
-
```
|
|
80
|
-
|
|
81
|
-
<
|
|
82
|
-
|
|
83
|
-
<
|
|
84
|
-
|
|
85
|
-
|
|
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-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
- **
|
|
169
|
-
|
|
170
|
-
- **Don't use non-ASCII field names.** `datum` not `
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
- **
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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, '&')
|
|
236
|
+
.replace(/</g, '<')
|
|
237
|
+
.replace(/>/g, '>')
|
|
238
|
+
.replace(/"/g, '"')
|
|
239
|
+
.replace(/'/g, ''');
|
|
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.
|