@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.
- package/README.md +39 -13
- package/dist/index.js +28 -63
- package/dist/install-skills.js +130 -0
- package/dist/server.js +129 -0
- package/dist/tools/pages.js +11 -2
- package/dist/tools/partials.js +9 -4
- package/dist/tools/search.js +6 -3
- package/package.json +7 -1
- package/skills/README.md +26 -5
- package/skills/tr-blog.md +171 -0
- package/skills/tr-brand.md +169 -0
- package/skills/tr-collection-template.md +262 -0
- package/skills/tr-forms.md +243 -0
- package/skills/tr-import-url.md +173 -0
- package/skills/tr-migrate-astro.md +278 -0
- package/skills/tr-new-site.md +198 -0
- package/skills/tr-page-template.md +168 -0
- package/skills/tr-seo.md +179 -0
|
@@ -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, '&')
|
|
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.
|