@typeroll/mcp-server 0.7.9 → 0.9.0

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 CHANGED
@@ -287,22 +287,22 @@ first if you're unsure what's writable.
287
287
  ```
288
288
  # Image lives on a URL somewhere (Unsplash, customer's existing CDN):
289
289
  upload_media_from_url source_url="https://..." alt_text="Hero photo of …"
290
- → returns { media_id, cdn_url, … }
290
+ → returns { media_id, cdn_url, finalize: {}, finalize_error: null }
291
291
 
292
292
  # OR image lives in your memory (image-gen output):
293
293
  upload_media_inline filename="hero.png" content_type="image/png"
294
294
  data_base64="iVBORw0KGgo…"
295
295
  → returns the same shape
296
296
 
297
- # OPTIONAL but recommended: produce srcset variants for responsive <img>:
298
- generate_image_variants media_id=<id>
299
- returns webp + avif variants at 320/640/1024/1920 widths
300
- (skips upscales; sub-5s per image)
297
+ # Both tools auto-finalize after PUT: immutable Cache-Control on the
298
+ # original PLUS AVIF/WebP variants at 320/640/1024/1920. No manual
299
+ # generate_image_variants call needed. The site-template renderer reads
300
+ # the variants array off the Media doc and emits <picture> automatically
301
+ # — you can keep the <img src="{cdn_url}"> markup simple.
301
302
 
302
303
  # Then embed in a page:
303
304
  read_page page_id=...
304
- # Build <picture> or <img srcset="..."> using variants[*].cdn_url
305
- update_page page_id=... patch={ html_content: "<...><img src='{cdn_url}' .../>" }
305
+ update_page page_id=... patch={ html_content: "<...><img src='{cdn_url}' alt='…' /></...>" }
306
306
  ```
307
307
 
308
308
  ### "Fill missing alt-text across the media library"
@@ -438,7 +438,7 @@ stakeholder review.
438
438
  | **Global blocks (partials)** | `list_partials` (summary by default), `read_partial`, `create_free_block`, `update_partial`, `replace_partial`, `delete_partial`, `find_pages_using_block`, `list_blocks_with_usage` |
439
439
  | **Block types** | `list_block_types`, `read_block_type`, `find_pages_using_block_type`, `export_block_types`, `import_block_types` |
440
440
  | **Collections** | `create_collection`, `update_collection_schema`, `delete_collection`, `list_collections`, `read_collection`, `list_collection_items` (richtext hidden by default), `read_collection_item`, `batch_read_collection_items`, `create_collection_item`, `update_collection_item`, `delete_collection_item`, `regenerate_collection_listing` |
441
- | **Media** | `list_media`, `read_media`, `create_upload_url`, `upload_media_from_url`, `upload_media_inline`, `update_media`, `delete_media`, `generate_image_variants`, `suggest_alt_text_context` |
441
+ | **Media** | `list_media`, `read_media`, `create_upload_url`, `upload_media_from_url`, `upload_media_inline`, `update_media`, `delete_media`, `finalize_media`, `finalize_all_media`, `generate_image_variants`, `suggest_alt_text_context` |
442
442
  | **Redirects** | `list_redirects`, `create_redirect`, `delete_redirect` |
443
443
  | **Forms** | `list_forms`, `read_form`, `create_form`, `update_form`, `delete_form`, `list_form_submissions` |
444
444
  | **Settings** | `update_site_settings` (whitelist) |
package/README.md CHANGED
@@ -105,9 +105,14 @@ the full reference + concrete operation recipes.
105
105
  itself (incl. `route_template` for per-item URLs); list/read/batch-
106
106
  read/create/update/delete items.
107
107
  - **Media** — list, read, signed upload URLs, `upload_media_from_url`,
108
- `upload_media_inline`, patch metadata, delete, `generate_image_variants`
109
- (build-time srcset pipeline), `suggest_alt_text_context` (returns a
110
- tuned prompt for your own vision model).
108
+ `upload_media_inline` (both auto-finalize after PUT — see below),
109
+ patch metadata, delete, `finalize_media` (per-item: applies immutable
110
+ Cache-Control + generates AVIF/WebP srcset variants — call after
111
+ `create_upload_url`'s raw PUT path), `finalize_all_media` (bulk
112
+ backfill for legacy libraries), `generate_image_variants` (the
113
+ variant half of finalize, kept for surgical reruns),
114
+ `suggest_alt_text_context` (returns a tuned prompt for your own
115
+ vision model).
111
116
  - **Redirects** — list, create, delete. Plus automatic 301 on slug change.
112
117
  - **Forms** — list, read, create, update, delete, list submissions.
113
118
  - **Settings** — read + patch (scripts and custom CSS not exposed).
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.9';
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
@@ -24,6 +24,7 @@ import { blockTypeTools } from './tools/block-types.js';
24
24
  import { pageBlockTools } from './tools/page-blocks.js';
25
25
  import { settingsTools } from './tools/settings.js';
26
26
  import { siteTools } from './tools/sites.js';
27
+ import { domainTools } from './tools/domain.js';
27
28
  import { fail } from './tools/helpers.js';
28
29
  const PERM_RANK = { read: 0, write: 1, admin: 2 };
29
30
  /**
@@ -46,7 +47,7 @@ function effectFor(name) {
46
47
  }
47
48
  return 'write';
48
49
  }
49
- const DEFAULT_INFO = { name: 'typeroll', version: '0.7.9' };
50
+ const DEFAULT_INFO = { name: 'typeroll', version: '0.7.12' };
50
51
  export function buildServer(options) {
51
52
  if (!options.fixedSiteId && !options.allowedSites) {
52
53
  throw new Error('buildServer: either fixedSiteId or allowedSites must be provided');
@@ -73,6 +74,7 @@ export function buildServer(options) {
73
74
  ...versionTools,
74
75
  ...deployTools,
75
76
  ...previewTools,
77
+ ...domainTools,
76
78
  ];
77
79
  // SDK's registerTool has deeply-nested generics we can't unify across a
78
80
  // heterogeneous tool list at compile time — same shrug as stdio's index.ts.
@@ -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
  ];
@@ -52,6 +52,14 @@ export const collectionTools = [
52
52
  sort_dir: z.enum(['asc', 'desc']).optional(),
53
53
  route_template: z.string().optional(),
54
54
  item_template_html: z.string().optional(),
55
+ schema_type: z
56
+ .string()
57
+ .optional()
58
+ .describe('Free-form Schema.org type for auto JSON-LD on every item route ("BlogPosting", "PodcastEpisode", "Product", "Event", "Recipe", "Course", …). Pair with schema_field_map when item field names differ from the schema property names.'),
59
+ schema_field_map: z
60
+ .record(z.string())
61
+ .optional()
62
+ .describe('Override the default field→schema-property mapping. Example: { "audio_url": "contentUrl", "show_notes": "description" }. Values use camelCase (Schema.org convention).'),
55
63
  })
56
64
  .passthrough(),
57
65
  version: versionParam,
@@ -0,0 +1,98 @@
1
+ // Custom-domain lifecycle tools. Wrap the four /api/v1/sites/{id}/domain/*
2
+ // routes so an agent can read, attach, poll, activate, or remove a
3
+ // custom domain through MCP. Admin-only on the server side; site-scoped
4
+ // keys are implicitly admin, org-scoped keys forward the share's
5
+ // permission.
6
+ //
7
+ // State machine recap (see docs/domain-lifecycle-plan.md):
8
+ //
9
+ // no domain ──add_domain──▶ pending
10
+ // pending ──poll_domain──▶ pending | verified | failed
11
+ // verified ──activate_domain──▶ live
12
+ // any state ──remove_domain──▶ no domain
13
+ //
14
+ // `production_url` on the site response (list_sites / read_site) only
15
+ // reflects the custom domain when status === 'live'. Until then,
16
+ // agents should send users to the fallback URL.
17
+ import { z } from 'zod';
18
+ import { ok, withErrorBoundary } from './helpers.js';
19
+ export const domainTools = [
20
+ {
21
+ name: 'read_domain',
22
+ description: "Read the current state of the site's custom domain. Returns " +
23
+ '`{ hostname, status, dns_target, verified_at, failure_reason }` ' +
24
+ "or `null` when no domain is attached. Status is one of 'pending' " +
25
+ "(DNS not yet verified), 'verified' (DNS + cert OK, awaiting customer " +
26
+ "activation), 'live' (production URL is now the custom domain), " +
27
+ "'failed' (Cloudflare returned an error — see failure_reason).",
28
+ inputSchema: {},
29
+ handler: withErrorBoundary(async (_args, { client, siteId }) => {
30
+ const res = await client.get(siteId, 'domain');
31
+ return ok(res);
32
+ }),
33
+ },
34
+ {
35
+ name: 'add_domain',
36
+ description: "Attach a custom domain to this site. The site MUST NOT already have " +
37
+ "a domain set — remove the existing one first. Returns the new state, " +
38
+ "including the CNAME target the customer must point DNS at. Status " +
39
+ "starts as 'pending'; once DNS is correct + cert is issued, " +
40
+ "`poll_domain` will flip it to 'verified', then `activate_domain` " +
41
+ "makes it production.",
42
+ inputSchema: {
43
+ hostname: z
44
+ .string()
45
+ .describe('Hostname to attach, e.g. "www.example.com". Leading https:// and trailing slashes are stripped.'),
46
+ },
47
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
48
+ const res = await client.post(siteId, 'domain', { hostname: args.hostname });
49
+ return ok(res);
50
+ }),
51
+ },
52
+ {
53
+ name: 'poll_domain',
54
+ description: "Ask Cloudflare for the current state of the attached domain and " +
55
+ "update the site doc accordingly. Idempotent — call after DNS " +
56
+ "changes propagate. Returns the new state; status may have advanced " +
57
+ "from 'pending' to 'verified' or 'failed'.",
58
+ inputSchema: {},
59
+ handler: withErrorBoundary(async (_args, { client, siteId }) => {
60
+ const res = await client.post(siteId, 'domain/poll');
61
+ return ok(res);
62
+ }),
63
+ },
64
+ {
65
+ name: 'activate_domain',
66
+ description: "Make the verified domain the production URL. Precondition: status " +
67
+ "must be 'verified' (or already 'live' — idempotent). After this " +
68
+ "call, the site's `production_url` switches from the fallback URL " +
69
+ "to https://<hostname>. " +
70
+ "By default also enqueues a production deploy so the canonical URL, " +
71
+ "sitemap, and JSON-LD update to reflect the new hostname — the " +
72
+ "response includes `deploy.job_id` you can poll. Pass " +
73
+ "`auto_deploy: false` to skip the deploy (the canonical stays at " +
74
+ "the previous URL until the customer deploys manually).",
75
+ inputSchema: {
76
+ auto_deploy: z
77
+ .boolean()
78
+ .optional()
79
+ .describe('Default true. Enqueue a production deploy after activation so canonical URLs update.'),
80
+ },
81
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
82
+ const res = await client.post(siteId, 'domain/activate', args);
83
+ return ok(res);
84
+ }),
85
+ },
86
+ {
87
+ name: 'remove_domain',
88
+ description: "Detach the custom domain from this site. Removes it from Cloudflare " +
89
+ "Pages and clears the site doc. The fallback subdomain remains. " +
90
+ "Use this when the customer wants to start over with a different " +
91
+ "hostname or move the site to a different domain provider.",
92
+ inputSchema: {},
93
+ handler: withErrorBoundary(async (_args, { client, siteId }) => {
94
+ const res = await client.del(siteId, 'domain');
95
+ return ok(res);
96
+ }),
97
+ },
98
+ ];
@@ -59,7 +59,7 @@ export const mediaTools = [
59
59
  },
60
60
  {
61
61
  name: 'create_upload_url',
62
- description: 'Mint a 5-min signed PUT URL for direct-to-R2 upload. After the upload completes, the CDN url is what you reference in <img src="…">. The media doc is pre-created so a follow-up update_media can attach alt_text.',
62
+ description: 'Mint a 5-min signed PUT URL for direct-to-R2 upload. After the upload completes, the CDN url is what you reference in <img src="…">. REQUIRED follow-up: call `finalize_media` after the PUT succeeds — without it the original ships with no Cache-Control (Lighthouse will flag it) and no responsive variants get generated. The response carries `finalize_url` as a reminder. Prefer `upload_media_from_url` or `upload_media_inline` if you have the bytes in hand — they do the PUT + finalize for you in one call.',
63
63
  inputSchema: {
64
64
  filename: z.string().min(1),
65
65
  content_type: z.string().min(1).describe('e.g. "image/png", "image/jpeg", "application/pdf"'),
@@ -108,12 +108,27 @@ export const mediaTools = [
108
108
  if (!putRes.ok) {
109
109
  throw new Error(`R2 upload failed: ${putRes.status} ${putRes.statusText}`);
110
110
  }
111
+ // 4. Auto-finalize: set immutable Cache-Control on the original and
112
+ // generate AVIF/WebP responsive variants. Best-effort — if it
113
+ // fails (variant pipeline 500, missing env var on the server),
114
+ // the upload itself still succeeded so we return ok with a
115
+ // `finalize_error` flag instead of throwing.
116
+ let finalizeResult = null;
117
+ let finalizeError = null;
118
+ try {
119
+ finalizeResult = await client.post(siteId, `media/${encodeURIComponent(mint.media_id)}/finalize`);
120
+ }
121
+ catch (e) {
122
+ finalizeError = e instanceof Error ? e.message : String(e);
123
+ }
111
124
  return ok({
112
125
  media_id: mint.media_id,
113
126
  cdn_url: mint.cdn_url,
114
127
  filename,
115
128
  content_type: contentType,
116
129
  size_bytes: buf.byteLength,
130
+ finalize: finalizeResult,
131
+ finalize_error: finalizeError,
117
132
  });
118
133
  }),
119
134
  },
@@ -149,12 +164,24 @@ export const mediaTools = [
149
164
  if (!putRes.ok) {
150
165
  throw new Error(`R2 upload failed: ${putRes.status} ${putRes.statusText}`);
151
166
  }
167
+ // Auto-finalize (cache headers + AVIF/WebP variants). See
168
+ // upload_media_from_url for the same pattern + rationale.
169
+ let finalizeResult = null;
170
+ let finalizeError = null;
171
+ try {
172
+ finalizeResult = await client.post(siteId, `media/${encodeURIComponent(mint.media_id)}/finalize`);
173
+ }
174
+ catch (e) {
175
+ finalizeError = e instanceof Error ? e.message : String(e);
176
+ }
152
177
  return ok({
153
178
  media_id: mint.media_id,
154
179
  cdn_url: mint.cdn_url,
155
180
  filename: args.filename,
156
181
  content_type: args.content_type,
157
182
  size_bytes: buf.byteLength,
183
+ finalize: finalizeResult,
184
+ finalize_error: finalizeError,
158
185
  });
159
186
  }),
160
187
  },
@@ -174,13 +201,31 @@ export const mediaTools = [
174
201
  },
175
202
  {
176
203
  name: 'generate_image_variants',
177
- description: 'Run the build-time srcset pipeline for one image: produces webp + avif variants at 320 / 640 / 1024 / 1920 (skipping upscales), uploads them to R2 alongside the original, and patches the media doc with a variants[] array. Synchronous; takes ~1-5s per image. Skips PDFs and non-image media without erroring. Loop this over list_media to rebuild an existing library; call once per image right after upload to keep new media current.',
204
+ description: 'Run the build-time srcset pipeline for one image: produces webp + avif variants at 320 / 640 / 1024 / 1920 (skipping upscales), uploads them to R2 alongside the original, and patches the media doc with a variants[] array. Synchronous; takes ~1-5s per image. Skips PDFs and non-image media without erroring. NOTE: prefer `finalize_media` for new uploads it does this PLUS sets immutable Cache-Control on the original. Use this tool only for surgical variant reruns.',
178
205
  inputSchema: { media_id: z.string() },
179
206
  handler: withErrorBoundary(async (args, { client, siteId }) => {
180
207
  const res = await client.post(siteId, `media/${encodeURIComponent(args.media_id)}/generate-variants`);
181
208
  return ok(res);
182
209
  }),
183
210
  },
211
+ {
212
+ name: 'finalize_media',
213
+ description: 'Post-upload finalize for a single image: (1) applies immutable Cache-Control (public, max-age=31536000) to the R2 original via in-place CopyObject so the CDN caches it for a year, and (2) generates AVIF/WebP responsive variants if not already present. Call this immediately after a successful upload (the presigned PUT does NOT set cache headers — without finalize, Cloudflare defaults to ~4h and every visit re-fetches the original). Idempotent: safe to call twice. Skips variant work when variants already exist on the doc.',
214
+ inputSchema: { media_id: z.string() },
215
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
216
+ const res = await client.post(siteId, `media/${encodeURIComponent(args.media_id)}/finalize`);
217
+ return ok(res);
218
+ }),
219
+ },
220
+ {
221
+ name: 'finalize_all_media',
222
+ description: 'Backfill version of finalize_media: walks the entire media library, applies cache headers + variant generation to every item missing them. Use this once on legacy sites to fix Lighthouse "cache lifetimes" + "image delivery" warnings in one go. Synchronous, can take minutes on large libraries (~1-5s per image needing variants, ~50ms for cache-only). Idempotent.',
223
+ inputSchema: {},
224
+ handler: withErrorBoundary(async (_args, { client, siteId }) => {
225
+ const res = await client.post(siteId, 'media/finalize-all');
226
+ return ok(res);
227
+ }),
228
+ },
184
229
  {
185
230
  name: 'suggest_alt_text_context',
186
231
  description: 'Get everything you need to generate good alt-text for a media item: the image URL, a tuned SEO/accessibility prompt, the site\'s content language, and the list of pages where the image is used (with the nearest heading per use site). YOU run the actual vision call with your own model — this endpoint does NOT generate text. After you decide on alt-text, save it with update_media.',
@@ -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 a page\'s block tree. Returns content_mode (html or blocks) and the blocks array. Empty array for HTML-mode pages — those store body content in html_content instead. Use this before any block mutation so you know what you\'re modifying.',
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
- page_id: z.string(),
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 res = await client.get(siteId, `pages/${encodeURIComponent(args.page_id)}/blocks`, v(args.version));
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; for regular containers it\'s ignored.',
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
- page_id: z.string(),
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 res = await client.post(siteId, `pages/${encodeURIComponent(args.page_id)}/blocks`, {
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 use move/add/remove for structural changes.',
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
- page_id: z.string(),
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 res = await client.patch(siteId, `pages/${encodeURIComponent(args.page_id)}/blocks`, {
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
- page_id: z.string(),
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 res = await client.put(siteId, `pages/${encodeURIComponent(args.page_id)}/blocks`, {
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 a page.',
143
+ description: 'Remove a block and everything inside it from its container. Works for pages, partials, and templates.',
111
144
  inputSchema: {
112
- page_id: z.string(),
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, `pages/${encodeURIComponent(args.page_id)}/blocks`, query);
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
  },
@@ -61,23 +61,31 @@ export const pageTools = [
61
61
  },
62
62
  {
63
63
  name: 'create_page',
64
- description: 'Create a new page. Defaults to html mode pass html_content to set the body. ' +
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
- 'Slug must be a single path segment no leading/trailing slashes, no internal slashes. ' +
69
- 'For nested URLs like /blog/{slug} or /services/{slug}, use a collection with a ' +
70
- 'route_template (see the tr-blog and tr-directory skills) — NOT a page slug with a slash. ' +
71
- 'Returns the created page including html_content.',
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).',
72
77
  inputSchema: {
73
78
  title: z.string().min(1),
74
- slug: z.string().optional().describe('URL slug — single path segment, no slashes (e.g. "about", "kontakt"). Empty string "" = homepage. For nested URLs use a collection with route_template, not a slug like "services/design" the API rejects internal slashes.'),
75
- content_mode: z.enum(['blocks', 'html']).optional().describe('Default "html". "html" stores body in html_content; "blocks" stores a Block[] tree (note: blocks mode pages render as empty until the block editor ships use html mode for all real content).'),
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".'),
76
82
  html_content: z.string().optional().describe('Body HTML — only used when content_mode="html".'),
77
83
  blocks: z.array(z.any()).optional().describe('Block tree — only used when content_mode="blocks". Omit to get the default heading+prose seed.'),
78
84
  status: z.enum(['draft', 'review', 'unlisted', 'published']).optional(),
79
85
  seo_title: z.string().optional(),
80
86
  seo_description: z.string().optional(),
87
+ seo_image_alt: z.string().optional(),
88
+ schema_type: z.string().optional().describe('Free-form Schema.org type ("Service", "Course", "Product", …) for auto JSON-LD.'),
81
89
  kind: z.enum(['page', 'article']).optional(),
82
90
  author: z.string().optional(),
83
91
  language: z.string().optional().describe('BCP-47 tag overriding the site default (e.g. "en" on an otherwise Swedish site).'),
@@ -109,9 +117,21 @@ export const pageTools = [
109
117
  seo_title: z.string().optional(),
110
118
  seo_description: z.string().optional(),
111
119
  og_image: z.string().optional(),
120
+ seo_image_alt: z.string().optional().describe('Alt text for og:image/twitter:image. Falls back to first <img alt> on the page when unset.'),
112
121
  canonical_url: z.string().optional(),
113
122
  noindex: z.boolean().optional(),
123
+ lastmod_override: z.string().optional().describe('Override the sitemap <lastmod>. Empty string suppresses lastmod for this page entirely.'),
114
124
  json_ld: z.string().optional(),
125
+ schema_type: z.string().optional().describe('Free-form Schema.org type ("Service", "Course", "Product", …) used for auto JSON-LD emission. Use service.* for Service-specific fields.'),
126
+ service: z
127
+ .object({
128
+ price: z.union([z.number(), z.string()]).optional(),
129
+ price_currency: z.string().optional().describe('ISO 4217 (SEK, EUR, USD).'),
130
+ duration: z.string().optional(),
131
+ description: z.string().optional(),
132
+ url: z.string().optional(),
133
+ })
134
+ .optional(),
115
135
  template: z.string().optional(),
116
136
  })
117
137
  .passthrough(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@typeroll/mcp-server",
3
- "version": "0.7.9",
3
+ "version": "0.9.0",
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": {
@@ -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.
@@ -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
 
@@ -65,11 +65,18 @@ Body: <file bytes>
65
65
  In a shell:
66
66
 
67
67
  ```bash
68
- curl -X PUT "<upload_url>" \
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.)
@@ -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
- exists, use `update_page page_id="home" patch={html_content: "..."}`.
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-width section with `<h1>`, tagline, one CTA button
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="..." status="published"
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.