@typeroll/mcp-server 0.7.8 → 0.7.12
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/AGENTS.md +7 -3
- package/dist/index.js +1 -1
- package/dist/server.js +1 -1
- package/dist/tools/block-types.js +80 -0
- package/dist/tools/page-blocks.js +84 -15
- package/dist/tools/pages.js +13 -5
- package/package.json +1 -1
- package/skills/tr-blog.md +1 -1
- package/skills/tr-brand.md +91 -0
- package/skills/tr-directory.md +53 -0
- package/skills/tr-images.md +19 -3
- package/skills/tr-new-site.md +57 -6
package/AGENTS.md
CHANGED
|
@@ -33,9 +33,13 @@ maps to one HTTP endpoint; the actual logic runs in the customer's portal
|
|
|
33
33
|
- `html` — body lives in `html_content` as a single HTML string.
|
|
34
34
|
Useful when you have hand-written markup to drop in directly.
|
|
35
35
|
|
|
36
|
-
Slug
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
Slug is a single path segment — no slashes. `about` → `/about`,
|
|
37
|
+
`kontakt` → `/kontakt`, empty string `""` → homepage. The v1 API
|
|
38
|
+
rejects `services/design` and other slash-containing slugs with
|
|
39
|
+
"Invalid slug … slugs must not contain slashes." For nested URLs
|
|
40
|
+
like `/blog/{slug}` or `/services/{slug}`, the right primitive is a
|
|
41
|
+
**collection with `route_template`** (see the `tr-blog` and
|
|
42
|
+
`tr-directory` skills) — not a flat page with a slashed slug.
|
|
39
43
|
|
|
40
44
|
- **Partials = global blocks.** Three kinds:
|
|
41
45
|
- `header` — auto-injected at the top of every page.
|
package/dist/index.js
CHANGED
|
@@ -16,7 +16,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
|
16
16
|
import { TyperollClient } from './client.js';
|
|
17
17
|
import { runInstallSkillsCli } from './install-skills.js';
|
|
18
18
|
import { buildServer } from './server.js';
|
|
19
|
-
const VERSION = '0.7.
|
|
19
|
+
const VERSION = '0.7.12';
|
|
20
20
|
async function resolveSiteId(client) {
|
|
21
21
|
const fromEnv = process.env.TYPEROLL_SITE_ID?.trim();
|
|
22
22
|
if (fromEnv)
|
package/dist/server.js
CHANGED
|
@@ -46,7 +46,7 @@ function effectFor(name) {
|
|
|
46
46
|
}
|
|
47
47
|
return 'write';
|
|
48
48
|
}
|
|
49
|
-
const DEFAULT_INFO = { name: 'typeroll', version: '0.7.
|
|
49
|
+
const DEFAULT_INFO = { name: 'typeroll', version: '0.7.12' };
|
|
50
50
|
export function buildServer(options) {
|
|
51
51
|
if (!options.fixedSiteId && !options.allowedSites) {
|
|
52
52
|
throw new Error('buildServer: either fixedSiteId or allowedSites must be provided');
|
|
@@ -83,4 +83,84 @@ export const blockTypeTools = [
|
|
|
83
83
|
return ok(res);
|
|
84
84
|
}),
|
|
85
85
|
},
|
|
86
|
+
// ─── BlockType authoring — CREATE / UPDATE / DELETE ──────────────────
|
|
87
|
+
// The chat AI surface intentionally omits the `script` field on these
|
|
88
|
+
// three tools. Scripts run with full DOM access in every visitor's
|
|
89
|
+
// browser; AI-authored blocks must not carry custom JS. A human can
|
|
90
|
+
// add a script later via the portal UI (consent gate) or via the
|
|
91
|
+
// cookie-auth API directly. Origin is stamped 'ai' so audit + UI
|
|
92
|
+
// distinguish these from human-authored types.
|
|
93
|
+
{
|
|
94
|
+
name: 'create_block_type',
|
|
95
|
+
description: "Create a new custom block type usable on this site. Origin is stamped 'ai' automatically. The block ships immediately to every page editor + the renderer's registry. **Custom JS is NOT exposed here** — if a script is genuinely needed, a human must add it later via the portal UI. Returns the created BlockType.",
|
|
96
|
+
inputSchema: {
|
|
97
|
+
name: z.string().describe('Machine name (lowercase kebab/underscore, 1-64 chars). Becomes the id.'),
|
|
98
|
+
label: z.string().optional().describe('Display label (defaults to name).'),
|
|
99
|
+
icon: z.string().optional().describe('Lucide icon name shown in the block picker.'),
|
|
100
|
+
category: z.enum(['layout', 'content', 'media', 'custom']).optional(),
|
|
101
|
+
container: z.union([
|
|
102
|
+
z.literal(true), z.literal(false),
|
|
103
|
+
z.enum(['slots', 'repeater', 'conditional']),
|
|
104
|
+
]).optional().describe('Container kind. Use slots for fixed named slots, repeater for looping over items, conditional for show-if blocks.'),
|
|
105
|
+
slot_count: z.number().optional().describe('Required when container="slots". 1-8.'),
|
|
106
|
+
slot_labels: z.array(z.string()).optional(),
|
|
107
|
+
item_compatible: z.boolean().optional().describe('Mark true if this block is designed to render as a repeater item (no outer padding, kernel layout).'),
|
|
108
|
+
expand_to: z.object({
|
|
109
|
+
target: z.string(),
|
|
110
|
+
defaults: z.record(z.unknown()),
|
|
111
|
+
}).optional().describe('Alias mechanism — render as another block type with these defaults merged under authored data.'),
|
|
112
|
+
schema: z.array(z.unknown()).optional().describe('FieldDefinition[]. Each field has name, type, label, optional required/default/options/responsive.'),
|
|
113
|
+
template: z.string().optional().describe('HTML with {{field}}, {{{field}}}, {{=tag}}, {{children}}, {{slot:NAME}} substitutions.'),
|
|
114
|
+
styles: z.string().optional().describe('Block-scoped CSS. Selectors should start with [data-block="<name>"].'),
|
|
115
|
+
version: versionParam,
|
|
116
|
+
},
|
|
117
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
118
|
+
const { version, ...body } = args;
|
|
119
|
+
// origin=ai is set via query so the API route can stamp it server-side
|
|
120
|
+
const query = { origin: 'ai' };
|
|
121
|
+
if (version)
|
|
122
|
+
query.version = version;
|
|
123
|
+
const res = await client.post(siteId, 'block-types', body, query);
|
|
124
|
+
return ok(res);
|
|
125
|
+
}),
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
name: 'update_block_type',
|
|
129
|
+
description: "Update fields of an existing custom block type. Core blocks (id starts with 'core/') are read-only — they're managed in platform code. Schema/template/styles replace wholesale (no deep merge); other fields shallow-merge. **Custom JS is NOT exposed here** — to add or change a script, edit via portal UI.",
|
|
130
|
+
inputSchema: {
|
|
131
|
+
type_id: z.string().describe('Block type id (custom or third_party). Cannot be a core/* block.'),
|
|
132
|
+
label: z.string().optional(),
|
|
133
|
+
icon: z.string().optional(),
|
|
134
|
+
category: z.enum(['layout', 'content', 'media', 'custom']).optional(),
|
|
135
|
+
container: z.union([
|
|
136
|
+
z.literal(true), z.literal(false),
|
|
137
|
+
z.enum(['slots', 'repeater', 'conditional']),
|
|
138
|
+
]).optional(),
|
|
139
|
+
slot_count: z.number().optional(),
|
|
140
|
+
slot_labels: z.array(z.string()).optional(),
|
|
141
|
+
item_compatible: z.boolean().optional(),
|
|
142
|
+
expand_to: z.object({ target: z.string(), defaults: z.record(z.unknown()) }).optional(),
|
|
143
|
+
schema: z.array(z.unknown()).optional(),
|
|
144
|
+
template: z.string().optional(),
|
|
145
|
+
styles: z.string().optional(),
|
|
146
|
+
version: versionParam,
|
|
147
|
+
},
|
|
148
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
149
|
+
const { type_id, version, ...body } = args;
|
|
150
|
+
const res = await client.patch(siteId, `block-types/${encodeURIComponent(type_id)}`, body, v(version));
|
|
151
|
+
return ok(res);
|
|
152
|
+
}),
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
name: 'delete_block_type',
|
|
156
|
+
description: "Remove a custom block type. Core blocks (core/*) cannot be deleted. **Warning**: pages currently using this block type will render '<!-- unknown block type -->' comments instead. Call find_pages_using_block_type FIRST to see the blast radius; the AI should prompt the user before deleting if usage > 0.",
|
|
157
|
+
inputSchema: {
|
|
158
|
+
type_id: z.string(),
|
|
159
|
+
version: versionParam,
|
|
160
|
+
},
|
|
161
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
162
|
+
const res = await client.del(siteId, `block-types/${encodeURIComponent(args.type_id)}`, v(args.version));
|
|
163
|
+
return ok(res);
|
|
164
|
+
}),
|
|
165
|
+
},
|
|
86
166
|
];
|
|
@@ -22,27 +22,55 @@ const blockSchema = z.object({
|
|
|
22
22
|
html_id: z.string().optional(),
|
|
23
23
|
}).optional(),
|
|
24
24
|
}).passthrough();
|
|
25
|
+
/**
|
|
26
|
+
* Generic container target. kind=page is the back-compat default — when
|
|
27
|
+
* `page_id` is provided without `target`, the dispatcher synthesises
|
|
28
|
+
* `{ kind: 'page', id: page_id }`.
|
|
29
|
+
*/
|
|
30
|
+
const targetSchema = z.object({
|
|
31
|
+
kind: z.enum(['page', 'partial', 'template', 'item_template']),
|
|
32
|
+
id: z.string(),
|
|
33
|
+
}).describe('Address a container. kind=page|partial|template|item_template. Pages: id is the page id. Partials: id is "header"|"footer"|<free-block id>. Templates: id is the PageTemplate id. item_template: id is the COLLECTION NAME — the block tree is the collection\'s per-item layout (e.g. blog post body bound to {{item.title}}, {{item.body}}, etc.).');
|
|
25
34
|
function v(version) {
|
|
26
35
|
return version ? { version } : undefined;
|
|
27
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* Resolve a (target | page_id) pair to a REST path prefix. Pages keep
|
|
39
|
+
* routing through /pages/{id}/blocks for back-compat with older callers;
|
|
40
|
+
* partials and templates route through /containers/{kind}/{id}/blocks
|
|
41
|
+
* (the unified handler).
|
|
42
|
+
*/
|
|
43
|
+
function pathFor(target, pageId) {
|
|
44
|
+
const kind = target?.kind ?? 'page';
|
|
45
|
+
const id = target?.id ?? pageId;
|
|
46
|
+
if (!id)
|
|
47
|
+
throw new Error('Either target or page_id is required');
|
|
48
|
+
if (kind === 'page') {
|
|
49
|
+
return `pages/${encodeURIComponent(id)}/blocks`;
|
|
50
|
+
}
|
|
51
|
+
return `containers/${kind}/${encodeURIComponent(id)}/blocks`;
|
|
52
|
+
}
|
|
28
53
|
export const pageBlockTools = [
|
|
29
54
|
{
|
|
30
55
|
name: 'get_page_blocks',
|
|
31
|
-
description: 'Read
|
|
56
|
+
description: 'Read the block tree of a container (page, partial, or page template). Use `target: { kind, id }` for partials/templates. For pages, `page_id` is the back-compat shorthand. Returns content_mode + blocks. Empty array for HTML-mode pages — those store body content in html_content instead. Always call this before any block mutation so you know what you\'re modifying.',
|
|
32
57
|
inputSchema: {
|
|
33
|
-
|
|
58
|
+
target: targetSchema.optional(),
|
|
59
|
+
page_id: z.string().optional().describe('Shorthand for target={kind:"page", id:page_id}.'),
|
|
34
60
|
version: versionParam,
|
|
35
61
|
},
|
|
36
62
|
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
37
|
-
const
|
|
63
|
+
const path = pathFor(args.target, args.page_id);
|
|
64
|
+
const res = await client.get(siteId, path, v(args.version));
|
|
38
65
|
return ok(res);
|
|
39
66
|
}),
|
|
40
67
|
},
|
|
41
68
|
{
|
|
42
69
|
name: 'add_block',
|
|
43
|
-
description: 'Insert a block into a page. With no parent_id, inserts at top level. Otherwise inserts as a child (or into the named slot for slot-containers). Position defaults to "end". For slot containers, slot_index picks the slot
|
|
70
|
+
description: 'Insert a block into a container (page, partial, or page template). With no parent_id, inserts at the top level of the container. Otherwise inserts as a child (or into the named slot for slot-containers). Position defaults to "end". For slot containers, slot_index picks the slot. Pass `target: { kind: "partial", id: "header" }` to drop a block into the header on every page, etc. `page_id` is the legacy shorthand.',
|
|
44
71
|
inputSchema: {
|
|
45
|
-
|
|
72
|
+
target: targetSchema.optional(),
|
|
73
|
+
page_id: z.string().optional(),
|
|
46
74
|
block: blockSchema,
|
|
47
75
|
parent_id: z.string().optional().nullable(),
|
|
48
76
|
slot_index: z.number().int().min(0).optional(),
|
|
@@ -50,7 +78,8 @@ export const pageBlockTools = [
|
|
|
50
78
|
version: versionParam,
|
|
51
79
|
},
|
|
52
80
|
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
53
|
-
const
|
|
81
|
+
const path = pathFor(args.target, args.page_id);
|
|
82
|
+
const res = await client.post(siteId, path, {
|
|
54
83
|
block: args.block,
|
|
55
84
|
parent_id: args.parent_id,
|
|
56
85
|
slot_index: args.slot_index,
|
|
@@ -61,9 +90,10 @@ export const pageBlockTools = [
|
|
|
61
90
|
},
|
|
62
91
|
{
|
|
63
92
|
name: 'update_block',
|
|
64
|
-
description: 'Update a block\'s data fields (shallow merge), style overrides, or responsive settings. Does not modify children or slots
|
|
93
|
+
description: 'Update a block\'s data fields (shallow merge), style overrides, or responsive settings. Works on pages, partials, and page templates — use `target` to address non-page containers. Does not modify children or slots; use move/add/remove for structural changes.',
|
|
65
94
|
inputSchema: {
|
|
66
|
-
|
|
95
|
+
target: targetSchema.optional(),
|
|
96
|
+
page_id: z.string().optional(),
|
|
67
97
|
block_id: z.string(),
|
|
68
98
|
data: z.record(z.unknown()).optional(),
|
|
69
99
|
style_overrides: z.object({
|
|
@@ -76,7 +106,8 @@ export const pageBlockTools = [
|
|
|
76
106
|
version: versionParam,
|
|
77
107
|
},
|
|
78
108
|
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
79
|
-
const
|
|
109
|
+
const path = pathFor(args.target, args.page_id);
|
|
110
|
+
const res = await client.patch(siteId, path, {
|
|
80
111
|
block_id: args.block_id,
|
|
81
112
|
data: args.data,
|
|
82
113
|
style_overrides: args.style_overrides,
|
|
@@ -86,9 +117,10 @@ export const pageBlockTools = [
|
|
|
86
117
|
},
|
|
87
118
|
{
|
|
88
119
|
name: 'move_block',
|
|
89
|
-
description: 'Reparent or reorder a block. target_parent_id=null moves to top level. For slot containers, target_slot_index picks the slot. Cycles (moving a block into its own descendant) are rejected.',
|
|
120
|
+
description: 'Reparent or reorder a block inside its container. target_parent_id=null moves to top level. For slot containers, target_slot_index picks the slot. Cycles (moving a block into its own descendant) are rejected. Works for pages, partials, and templates.',
|
|
90
121
|
inputSchema: {
|
|
91
|
-
|
|
122
|
+
target: targetSchema.optional(),
|
|
123
|
+
page_id: z.string().optional(),
|
|
92
124
|
block_id: z.string(),
|
|
93
125
|
target_parent_id: z.string().optional().nullable(),
|
|
94
126
|
target_slot_index: z.number().int().min(0).optional(),
|
|
@@ -96,7 +128,8 @@ export const pageBlockTools = [
|
|
|
96
128
|
version: versionParam,
|
|
97
129
|
},
|
|
98
130
|
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
99
|
-
const
|
|
131
|
+
const path = pathFor(args.target, args.page_id);
|
|
132
|
+
const res = await client.put(siteId, path, {
|
|
100
133
|
block_id: args.block_id,
|
|
101
134
|
target_parent_id: args.target_parent_id,
|
|
102
135
|
target_slot_index: args.target_slot_index,
|
|
@@ -107,17 +140,53 @@ export const pageBlockTools = [
|
|
|
107
140
|
},
|
|
108
141
|
{
|
|
109
142
|
name: 'remove_block',
|
|
110
|
-
description: 'Remove a block and everything inside it from
|
|
143
|
+
description: 'Remove a block and everything inside it from its container. Works for pages, partials, and templates.',
|
|
111
144
|
inputSchema: {
|
|
112
|
-
|
|
145
|
+
target: targetSchema.optional(),
|
|
146
|
+
page_id: z.string().optional(),
|
|
113
147
|
block_id: z.string(),
|
|
114
148
|
version: versionParam,
|
|
115
149
|
},
|
|
116
150
|
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
151
|
+
const path = pathFor(args.target, args.page_id);
|
|
117
152
|
const query = { block_id: args.block_id };
|
|
118
153
|
if (args.version)
|
|
119
154
|
query.version = args.version;
|
|
120
|
-
const res = await client.del(siteId,
|
|
155
|
+
const res = await client.del(siteId, path, query);
|
|
156
|
+
return ok(res);
|
|
157
|
+
}),
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
name: 'duplicate_block',
|
|
161
|
+
description: 'Clone a block (subtree included) and insert the copy directly after the original within the same container. Returns the duplicate\'s new id. Useful for "add another card to this feature grid" — duplicate the existing one and then `update_block` on the new id to change its data. Works for pages, partials, and templates.',
|
|
162
|
+
inputSchema: {
|
|
163
|
+
target: targetSchema.optional(),
|
|
164
|
+
page_id: z.string().optional(),
|
|
165
|
+
block_id: z.string(),
|
|
166
|
+
version: versionParam,
|
|
167
|
+
},
|
|
168
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
169
|
+
const path = pathFor(args.target, args.page_id);
|
|
170
|
+
const res = await client.post(siteId, `${path}/duplicate`, { block_id: args.block_id }, v(args.version));
|
|
171
|
+
return ok(res);
|
|
172
|
+
}),
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
name: 'set_block_responsive',
|
|
176
|
+
description: 'Set a per-breakpoint value on one responsive field of a block (in any container — page, partial, or template). The renderer reads { mobile, tablet, laptop, desktop, wide } objects on any field marked responsive in the BlockType schema (use read_block_type to see which). Mobile-first inheritance: missing breakpoints inherit upward. Pass a scalar value to clear responsive back to a single value. Example: setting `cols` on a feature_grid to { mobile: 1, tablet: 2, desktop: 3 } renders 1-col on phones, 2-col on tablets, 3-col on desktop+.',
|
|
177
|
+
inputSchema: {
|
|
178
|
+
target: targetSchema.optional(),
|
|
179
|
+
page_id: z.string().optional(),
|
|
180
|
+
block_id: z.string(),
|
|
181
|
+
field: z.string().describe('Field name (must be marked responsive in the BlockType schema).'),
|
|
182
|
+
value: z.any().describe('Scalar (applies to all BPs) or { mobile?, tablet?, laptop?, desktop?, wide? }.'),
|
|
183
|
+
version: versionParam,
|
|
184
|
+
},
|
|
185
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
186
|
+
const path = pathFor(args.target, args.page_id);
|
|
187
|
+
const res = await client.post(siteId, `${path}/responsive`, {
|
|
188
|
+
block_id: args.block_id, field: args.field, value: args.value,
|
|
189
|
+
}, v(args.version));
|
|
121
190
|
return ok(res);
|
|
122
191
|
}),
|
|
123
192
|
},
|
package/dist/tools/pages.js
CHANGED
|
@@ -61,16 +61,24 @@ export const pageTools = [
|
|
|
61
61
|
},
|
|
62
62
|
{
|
|
63
63
|
name: 'create_page',
|
|
64
|
-
description: 'Create a new page. Defaults to
|
|
64
|
+
description: 'Create a new page. Defaults to blocks mode (same as the portal UI\'s "New page" button) ' +
|
|
65
|
+
'and seeds the tree with a heading + prose block so the editor is never blank. ' +
|
|
66
|
+
'Pass html_content (or content_mode="html") to opt into raw-HTML mode instead. ' +
|
|
65
67
|
'Slug is derived from the title when omitted. Default status is "draft". ' +
|
|
66
68
|
'Homepage convention: pass slug="" (empty string) or omit slug and set title to "Home"; ' +
|
|
67
69
|
'the server stores it under id "home" with slug "". ' +
|
|
68
|
-
'
|
|
69
|
-
'
|
|
70
|
+
'Slug must be a single path segment (no slashes). For nested URLs like ' +
|
|
71
|
+
'/erbjudanden/sommar or /tjanster/design, set the optional `path` field explicitly — ' +
|
|
72
|
+
'e.g. path="/erbjudanden/sommar". For many similar items use a collection with route_template ' +
|
|
73
|
+
'(see tr-blog / tr-directory), but for a small group of bespoke pages sharing a URL prefix, ' +
|
|
74
|
+
'`path` is the right primitive. ' +
|
|
75
|
+
'Returns the created page (blocks for blocks-mode, html_content for html-mode) including ' +
|
|
76
|
+
'`url` (the resolved live URL).',
|
|
70
77
|
inputSchema: {
|
|
71
78
|
title: z.string().min(1),
|
|
72
|
-
slug: z.string().optional().describe('URL slug
|
|
73
|
-
|
|
79
|
+
slug: z.string().optional().describe('URL slug — single path segment, no slashes (e.g. "about", "kontakt"). Empty string "" = homepage. For nested URLs set the `path` field explicitly — `slug` stays a leaf id.'),
|
|
80
|
+
path: z.string().optional().describe('Optional explicit URL path for nested pages (e.g. "/erbjudanden/sommar-2026"). When set, takes precedence over slug for routing. Must start with "/", lowercase a-z/0-9/-/_/ only, no "..", no "//", no trailing slash. Slug is still required as the leaf id but two pages CAN share a slug under different paths.'),
|
|
81
|
+
content_mode: z.enum(['blocks', 'html']).optional().describe('Default "blocks". "blocks" stores a Block[] tree (the modern default — supports the full ~40-block library, page templates, and the responsive system). "html" stores raw markup in html_content (legacy path, still fully supported for imported content or hand-written HTML). Passing html_content without content_mode also opts into "html".'),
|
|
74
82
|
html_content: z.string().optional().describe('Body HTML — only used when content_mode="html".'),
|
|
75
83
|
blocks: z.array(z.any()).optional().describe('Block tree — only used when content_mode="blocks". Omit to get the default heading+prose seed.'),
|
|
76
84
|
status: z.enum(['draft', 'review', 'unlisted', 'published']).optional(),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@typeroll/mcp-server",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.12",
|
|
4
4
|
"description": "Model Context Protocol server for the Typeroll public API. Use with Claude Code or any MCP-compatible client to manage a Typeroll site.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
package/skills/tr-blog.md
CHANGED
|
@@ -154,7 +154,7 @@ Three calls. No per-article `create_page`. No HTML diffing by hand.
|
|
|
154
154
|
|
|
155
155
|
## Pitfalls
|
|
156
156
|
|
|
157
|
-
- **Don't fall back to "one page per article".** That was the pre-`item_template_html` pattern. It
|
|
157
|
+
- **Don't fall back to "one page per article".** That was the pre-`item_template_html` pattern. It's strictly worse now: design changes mean editing N pages, you lose `{{url}}` resolution in listings, sitemap doesn't include items, previews can't surface a per-item URL — and the API will reject your attempt anyway. `create_page` rejects slugs containing slashes ("Invalid slug … slugs must not contain slashes"), so `slug: "blog/foo"` doesn't even get through. The collection's `route_template` is the only path to nested URLs.
|
|
158
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
159
|
- **Don't use non-ASCII field names.** `datum` not `Datum`; `forfattare` not `författare` in the `name`. The `label` is free-form.
|
|
160
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`.
|
package/skills/tr-brand.md
CHANGED
|
@@ -141,6 +141,87 @@ a special border radius, or a branded highlight color — add them via
|
|
|
141
141
|
|
|
142
142
|
Then reference `var(--brand-gradient)` etc. in page HTML and partials.
|
|
143
143
|
|
|
144
|
+
## Step 4b — Section + layout design defaults
|
|
145
|
+
|
|
146
|
+
These are non-negotiable defaults the rest of the platform skills inherit (`tr-new-site`, `tr-directory`, `tr-collection-template`). Apply them on every page that has visible sections — they're battle-tested across real customer migrations.
|
|
147
|
+
|
|
148
|
+
### One signal per section boundary
|
|
149
|
+
|
|
150
|
+
Use **either** a background-color shift **or** a horizontal divider line at a section transition — never both stacked. They serve the same purpose; stacking them looks busy.
|
|
151
|
+
|
|
152
|
+
- Default: alternating `.section` / `.section.alt` with a bg shift is enough.
|
|
153
|
+
- A standalone divider line (gradient/keyline) is reserved for the hero → body boundary, where the bg already shifts.
|
|
154
|
+
|
|
155
|
+
### Sections are full-bleed; content is container-width
|
|
156
|
+
|
|
157
|
+
The section element ALWAYS spans the full viewport (its bg, border, decorative line). Content inside is constrained by `.container` / `.container.narrow`.
|
|
158
|
+
|
|
159
|
+
The renderer wraps `html_content` in `<main class="page-content">` with `max-width: var(--container-medium)` — so a section's bg-color rule alone gives a "1080px-wide stripe in the middle of the page", which is wrong. Every section that has a bg/border must apply the negative-margin escape:
|
|
160
|
+
|
|
161
|
+
```css
|
|
162
|
+
.my-page .section {
|
|
163
|
+
position: relative;
|
|
164
|
+
margin-left: calc(50% - 50vw);
|
|
165
|
+
margin-right: calc(50% - 50vw);
|
|
166
|
+
width: 100vw;
|
|
167
|
+
padding: 74px 0;
|
|
168
|
+
}
|
|
169
|
+
.my-page .section.alt { background: #fff }
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
The first section (hero) also wants `margin-top: -32px` to cancel `.page-content`'s top padding. Do NOT wrap the page in `overflow-x: clip` — it cancels the bleed.
|
|
173
|
+
|
|
174
|
+
### Cards sit directly on the section bg — never on a matching bg
|
|
175
|
+
|
|
176
|
+
A card with a white bg inside a white-bg section creates a redundant "white plate on white" effect. Two enforcement rules:
|
|
177
|
+
|
|
178
|
+
1. Cards on the default page bg (`var(--color-background)`) can use `background: #fff` + border. ✓
|
|
179
|
+
2. Cards on `.section.alt` (which has `background: #fff`) must drop their own bg:
|
|
180
|
+
- **Single-card section** (one card in a section): drop all chrome (bg, border, accent line). Just content; the section's bg is the only context.
|
|
181
|
+
- **Grid cards** (multiple side-by-side): keep border for grid separation, drop bg. The card becomes a transparent container with a hairline outline.
|
|
182
|
+
|
|
183
|
+
```css
|
|
184
|
+
.my-page .section.alt .my-expert,
|
|
185
|
+
.my-page .section.alt .my-expert::before { background: transparent; border: 0; padding: 0; display: none }
|
|
186
|
+
.my-page .section.alt .my-grid-card { background: transparent } /* grid cards keep border */
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Mental model: bg shifts twice before you have a problem — body → section → card. Three shifts feels muddled.
|
|
190
|
+
|
|
191
|
+
### Gradient-clipped headings need extra line-height for descenders
|
|
192
|
+
|
|
193
|
+
When using `-webkit-background-clip: text` + `display: inline-block` to render a gradient-filled heading, the inline-block box is sized by `line-height`. With `line-height: 1` (a common "tight" value for display headings) the descenders of g/j/y/p get clipped.
|
|
194
|
+
|
|
195
|
+
**Rule:** gradient-clipped headings use `line-height: 1.1` or higher, plus `padding-bottom: 0.05em` for belt-and-braces:
|
|
196
|
+
|
|
197
|
+
```css
|
|
198
|
+
.gradient-h1 {
|
|
199
|
+
display: inline-block;
|
|
200
|
+
background: var(--gradient-brand);
|
|
201
|
+
-webkit-background-clip: text;
|
|
202
|
+
background-clip: text;
|
|
203
|
+
color: transparent;
|
|
204
|
+
line-height: 1.12;
|
|
205
|
+
padding-bottom: 0.05em;
|
|
206
|
+
}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
### Hero copy is not body copy
|
|
210
|
+
|
|
211
|
+
When porting a page, identify the H1 + tagline pair and leave the body intro inside the body. Don't lift a body sentence up into the hero unless the source has it twice.
|
|
212
|
+
|
|
213
|
+
Rule: hero gets at most **H1 + one tagline**. Body intro stays in the body. Repeating the same sentence in both places looks accidental.
|
|
214
|
+
|
|
215
|
+
### Footer architecture: navigate by domain, not by content type
|
|
216
|
+
|
|
217
|
+
Default footer columns should mirror the user's mental model of the BUSINESS, not the technical content shapes. Anti-pattern: separate "Podcast / Articles / Events / Offers" columns that just list content categories.
|
|
218
|
+
|
|
219
|
+
Better default:
|
|
220
|
+
- **Områden / Domains** — subject domains, what the user wants help with
|
|
221
|
+
- **Företaget / Company** — about / services / legal, meta-information about the org
|
|
222
|
+
|
|
223
|
+
Reserve a third column only when there's a genuinely different surface (locations, languages, partner pages). Don't pad the footer with content-type columns — navigation to those happens via top nav + topic pages.
|
|
224
|
+
|
|
144
225
|
## Step 7 — Preview
|
|
145
226
|
|
|
146
227
|
```
|
|
@@ -167,3 +248,13 @@ Open in browser. Check:
|
|
|
167
248
|
- **Dark themes need dark surface too.** Setting `background: #0f0f0f`
|
|
168
249
|
but leaving `surface: #f8fafc` (white) breaks every card/input. Always
|
|
169
250
|
update all 7 tokens as a set.
|
|
251
|
+
- **The renderer's `.page-content` layout shell.** The renderer wraps
|
|
252
|
+
`html_content` in `<main class="page-content">` with constrained
|
|
253
|
+
`max-width` and default typography. The typography defaults now sit
|
|
254
|
+
inside `:where()` so they have specificity 0 — a customer's class
|
|
255
|
+
rules trivially win. The layout shell (width + padding) is still at
|
|
256
|
+
normal specificity by design: it's what gives a brand-new page
|
|
257
|
+
reasonable margins out of the box. If a section needs to escape the
|
|
258
|
+
shell (full-bleed bg, full-width hero), apply the negative-margin
|
|
259
|
+
pattern shown in Step 4b. Don't fight the shell with `overflow-x`
|
|
260
|
+
hacks.
|
package/skills/tr-directory.md
CHANGED
|
@@ -178,6 +178,38 @@ get_deploy_status job_id=<id>
|
|
|
178
178
|
Each published item gets its own URL in the static build, with
|
|
179
179
|
`sitemap.xml` automatically including them all.
|
|
180
180
|
|
|
181
|
+
## Patterns worth knowing
|
|
182
|
+
|
|
183
|
+
### Conditional rendering without Mustache conditionals
|
|
184
|
+
|
|
185
|
+
`item_template_html` substitution is plain `{{field}}` / `{{{field}}}` — no loops, no `{{#if}}` blocks. To hide a section/element when a field is empty, use a data-attribute that resolves to either the empty string or a non-empty value, plus a CSS selector:
|
|
186
|
+
|
|
187
|
+
```html
|
|
188
|
+
<aside class="podcast-guest" data-empty-if-blank="{{guest_name}}">
|
|
189
|
+
<h2>Om gästen</h2>
|
|
190
|
+
<p>{{guest_bio}}</p>
|
|
191
|
+
</aside>
|
|
192
|
+
```
|
|
193
|
+
```css
|
|
194
|
+
.podcast-detail [data-empty-if-blank=""] { display: none !important; }
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
When `guest_name` is empty, the attribute becomes `data-empty-if-blank=""` and the CSS matches and hides the block. When non-empty, the rule misses and the block renders. Works for optional images, optional audio, optional sub-sections — any "show only if this field has a value" need.
|
|
198
|
+
|
|
199
|
+
### Pre-render list-typed source data into a richtext field
|
|
200
|
+
|
|
201
|
+
For nested arrays in the source (e.g. `chapters: [{time, title}, …]`), pre-render to HTML during import and store in a dedicated `*_html` richtext field. The template just splats `{{{chapters_html}}}`. Three concrete recipes worth applying:
|
|
202
|
+
|
|
203
|
+
1. **Podcast detail page:** `chapters_html`, `guest_links_html`.
|
|
204
|
+
2. **Restaurant directory:** `hours_html` table.
|
|
205
|
+
3. **Product directory:** `variants_html` grid.
|
|
206
|
+
|
|
207
|
+
See `tr-collection-template` for full code examples.
|
|
208
|
+
|
|
209
|
+
### Batch-import via subagent for large datasets
|
|
210
|
+
|
|
211
|
+
22+ `create_collection_item` calls bloat the main agent's context with response payloads. Spawn a `general-purpose` subagent with the manifest path and a tight contract ("report ok/fail per item, end with a one-line summary"). The sub returns one line per item instead of a JSON blob per item back into the main turn.
|
|
212
|
+
|
|
181
213
|
## Pitfalls
|
|
182
214
|
|
|
183
215
|
- **Slugs must be unique within the collection** — duplicates cause
|
|
@@ -195,9 +227,30 @@ Each published item gets its own URL in the static build, with
|
|
|
195
227
|
- **Template too clever.** Substitution is plain `{{field}}` — no
|
|
196
228
|
loops, no conditionals. If your design needs more, prefer flat
|
|
197
229
|
fields (`star_html`, `rating_label`) prebuilt in the data step.
|
|
230
|
+
See the "Patterns worth knowing" section above.
|
|
198
231
|
- **Field type changes drift data.** Adding a new field after import
|
|
199
232
|
is fine; renaming one orphans the old data on every item. Plan the
|
|
200
233
|
schema before import.
|
|
234
|
+
- **Never derive display labels from slugs.** Slugs are ASCII-folded
|
|
235
|
+
for URL-safety. Computing the visible label as
|
|
236
|
+
`slug.replace("-", " ").capitalize()` produces **wrong words** in
|
|
237
|
+
languages with diacritics: `innehall` → `Innehall` (should be
|
|
238
|
+
`Innehåll`), `affarssystem` → `Affärssystem`. Always carry the
|
|
239
|
+
real title from the source and look it up by slug:
|
|
240
|
+
|
|
241
|
+
```python
|
|
242
|
+
slug_to_title = {t["slug"]: t["title"] for t in topic_manifest}
|
|
243
|
+
label = slug_to_title.get(slug, slug.replace("-", " ").title()) # fallback only
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
Caught during a real migration where 20 of 22 podcast detail pages
|
|
247
|
+
ended up with `Innehall` / `Prissattning` / `Affarssystem` in their
|
|
248
|
+
topic chips.
|
|
249
|
+
- **Numeric `sort_field` sorts numerically** as of the 2026-05 fix —
|
|
250
|
+
episode 9 ranks below episode 23 under desc sort. Earlier versions
|
|
251
|
+
compared as strings (9 > 23), so if you're working against an older
|
|
252
|
+
portal deploy add a `sort_key` text field with zero-padded values
|
|
253
|
+
(`f"{episode:03d}"`) and set `sort_field: "sort_key"` instead.
|
|
201
254
|
|
|
202
255
|
## Mixing scraped + generated content
|
|
203
256
|
|
package/skills/tr-images.md
CHANGED
|
@@ -65,11 +65,18 @@ Body: <file bytes>
|
|
|
65
65
|
In a shell:
|
|
66
66
|
|
|
67
67
|
```bash
|
|
68
|
-
curl -X PUT "
|
|
69
|
-
-H "Content-Type: image/png" \
|
|
70
|
-
--data-binary @hero-services.png
|
|
68
|
+
curl -sS -X PUT --data-binary @hero-services.png "$UPLOAD_URL"
|
|
71
69
|
```
|
|
72
70
|
|
|
71
|
+
Optionally with `-H "Content-Type: image/png"` if you need to override what R2 will infer. **Nothing else.**
|
|
72
|
+
|
|
73
|
+
> The signed URL embeds checksum-related query parameters
|
|
74
|
+
> (`x-amz-checksum-crc32`, `x-amz-sdk-checksum-algorithm`) for legacy
|
|
75
|
+
> SDK compatibility, but `X-Amz-SignedHeaders=host` — only the host
|
|
76
|
+
> header is part of the signature. Sending the `x-amz-*` values as
|
|
77
|
+
> request headers yields `403 SignatureDoesNotMatch`. Don't. Just
|
|
78
|
+
> `--data-binary` the file at the URL.
|
|
79
|
+
|
|
73
80
|
Or from JS (Claude Code can run a one-line script):
|
|
74
81
|
|
|
75
82
|
```js
|
|
@@ -80,6 +87,15 @@ await fetch(uploadUrl, {
|
|
|
80
87
|
});
|
|
81
88
|
```
|
|
82
89
|
|
|
90
|
+
Parallelise N uploads via shell `&` + `wait`:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
while IFS=$'\t' read -r filename signed_url; do
|
|
94
|
+
curl -sS -X PUT --data-binary @"$filename" "$signed_url" &
|
|
95
|
+
done < manifest.tsv
|
|
96
|
+
wait
|
|
97
|
+
```
|
|
98
|
+
|
|
83
99
|
A 200 OK from R2 means the image is now live at `cdn_url`.
|
|
84
100
|
|
|
85
101
|
### 4. Patch metadata (alt text, etc.)
|
package/skills/tr-new-site.md
CHANGED
|
@@ -128,29 +128,62 @@ Use `replace_partial partial_id="footer" html_content="..."`.
|
|
|
128
128
|
|
|
129
129
|
```
|
|
130
130
|
create_page title="Start" slug="" content_mode="html"
|
|
131
|
-
html_content="<full hero + intro HTML>"
|
|
131
|
+
html_content="<article class=\"home-page\">...full hero + intro HTML...</article>"
|
|
132
132
|
status="published"
|
|
133
133
|
```
|
|
134
134
|
|
|
135
|
-
`slug=""` creates the homepage under `page_id: "home"`. If it already
|
|
136
|
-
|
|
135
|
+
`slug=""` creates the homepage under `page_id: "home"`. If it already exists, use `update_page page_id="home" patch={html_content: "..."}`.
|
|
136
|
+
|
|
137
|
+
**Always wrap the body in a page-scope `<article class="...-page">`.** The renderer's `.page-content` layout shell sets default max-width and padding; without a scope class, any selectors you write live in the same tree as the renderer defaults and you'll wonder why your `.hero h1` doesn't get the size you set. With `.home-page .hero h1` you're at specificity 0,2,1 and trivially win. Use `<article class="home-page">` for the homepage and `<article class="static-page">` (or page-specific names like `<article class="about-page">`) for inner pages.
|
|
137
138
|
|
|
138
139
|
A minimal homepage structure:
|
|
139
|
-
- Hero: full-
|
|
140
|
+
- Hero: **full-bleed** section (negative-margin escape) with `<h1>`, tagline, one CTA button
|
|
140
141
|
- Intro: 2–3 sentences about what the company does
|
|
141
142
|
- Services/features grid: 3 cards max
|
|
142
143
|
- CTA band: "Kontakta oss" or similar
|
|
143
144
|
|
|
145
|
+
Full-bleed hero recipe (matches the section conventions from `tr-brand`'s Step 4b):
|
|
146
|
+
|
|
147
|
+
```css
|
|
148
|
+
.home-page .home-hero {
|
|
149
|
+
position: relative;
|
|
150
|
+
margin-left: calc(50% - 50vw);
|
|
151
|
+
margin-right: calc(50% - 50vw);
|
|
152
|
+
margin-top: -32px; /* cancels .page-content top padding */
|
|
153
|
+
width: 100vw;
|
|
154
|
+
padding: 80px 0 64px;
|
|
155
|
+
background: var(--gradient-soft), #fff;
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
**Caveat:** never combine with `overflow-x: clip` on the page wrapper — the clip cancels the bleed.
|
|
160
|
+
|
|
144
161
|
### 6. Inner pages
|
|
145
162
|
|
|
146
|
-
Create each page:
|
|
163
|
+
Create each page (always with a page-scope class):
|
|
164
|
+
|
|
147
165
|
```
|
|
148
166
|
create_page title="Om oss" slug="om-oss" content_mode="html"
|
|
149
|
-
html_content="
|
|
167
|
+
html_content="<article class=\"static-page\">...</article>"
|
|
168
|
+
status="published"
|
|
150
169
|
```
|
|
151
170
|
|
|
152
171
|
Standard set: Om oss, Tjänster, Kontakt. Add more if the brief says so.
|
|
153
172
|
|
|
173
|
+
### 6b. If the legacy site is still live, scrape canonical content
|
|
174
|
+
|
|
175
|
+
When a page (privacy policy, terms, about) exists on the still-live previous site, use `WebFetch` to pull the canonical text directly rather than rewriting from memory or relying on placeholder copy.
|
|
176
|
+
|
|
177
|
+
Pattern:
|
|
178
|
+
|
|
179
|
+
```
|
|
180
|
+
WebFetch url="https://www.olddomain.example/integritetspolicy"
|
|
181
|
+
prompt="Return full content as markdown, preserve Swedish characters,
|
|
182
|
+
preserve hierarchy, don't summarize"
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Then convert markdown → HTML for `html_content`. This keeps legal text exact and stops "polished" rewrites from drifting from the canonical version.
|
|
186
|
+
|
|
154
187
|
### 7. Preview + iterate
|
|
155
188
|
|
|
156
189
|
```
|
|
@@ -178,6 +211,24 @@ get_deploy_status job_id=<id> # poll until status=succeeded
|
|
|
178
211
|
- **Scoped styles.** Put `<style>` at the bottom of partial HTML.
|
|
179
212
|
Styles apply to the whole page; prefix class names with the partial ID
|
|
180
213
|
to avoid collisions (`site-header__*`, `site-footer__*`).
|
|
214
|
+
- **Page-scope class on every page body.** Wrap `html_content` in
|
|
215
|
+
`<article class="my-page">` and prefix all your page-specific rules
|
|
216
|
+
with `.my-page`. The renderer's `.page-content` layout shell sets
|
|
217
|
+
width + padding at normal specificity, so a scope class keeps your
|
|
218
|
+
rules cleanly above the defaults and the next agent can refactor a
|
|
219
|
+
single page without bleed-through.
|
|
220
|
+
- **Inherit `tr-brand`'s Step 4b section/layout defaults.** Full-bleed
|
|
221
|
+
sections via negative-margin escape, one signal per section boundary
|
|
222
|
+
(bg shift OR divider, not both), cards drop bg on `.section.alt`,
|
|
223
|
+
gradient-clipped headings use `line-height: 1.1+` to keep descenders
|
|
224
|
+
inside the box. See the brand skill for the full list — don't
|
|
225
|
+
re-derive.
|
|
226
|
+
- **Don't simplify data during import.** When porting content from an
|
|
227
|
+
existing site, preserve full fidelity at the data layer. Don't
|
|
228
|
+
ASCII-fold slugs into display labels (`affärsutveckling` ≠
|
|
229
|
+
`affarsutveckling`), don't truncate descriptions, don't drop
|
|
230
|
+
metadata fields you "won't use yet". Carry titles verbatim from
|
|
231
|
+
source; derive slug-from-title only for the URL, not the label.
|
|
181
232
|
- **Minimal JS.** The platform doesn't bundle JS. If you need interactions,
|
|
182
233
|
write a small `<script>` inside the partial or page; keep it under 50
|
|
183
234
|
lines. Avoid external CDN dependencies in page bodies.
|