@typeroll/mcp-server 0.44.73 → 0.45.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 +63 -74
- package/README.md +32 -18
- package/dist/bundled-content.js +15 -16
- package/dist/init.js +6 -0
- package/dist/server.js +15 -7
- package/dist/tools/bulk.js +4 -5
- package/dist/tools/content-types.js +28 -0
- package/dist/tools/deploy.js +1 -1
- package/dist/tools/migration.js +3 -4
- package/dist/tools/page-blocks.js +2 -2
- package/dist/tools/page-templates.js +18 -0
- package/dist/tools/pages.js +16 -3
- package/dist/tools/preview.js +1 -3
- package/dist/tools/sites.js +1 -1
- package/dist/tools/working-copy.js +2 -8
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/skills/README.md +6 -7
- package/skills/tr-blog.md +46 -130
- package/skills/tr-brand.md +2 -2
- package/skills/tr-content-write.md +1 -1
- package/skills/tr-directory.md +46 -271
- package/skills/tr-import-url.md +1 -1
- package/skills/tr-migrate-astro.md +65 -293
- package/skills/tr-migrate-multisite.md +1 -1
- package/skills/tr-migrate-wp.md +2 -2
- package/skills/tr-new-site.md +9 -9
- package/skills/tr-page-template.md +53 -164
- package/skills/tr-redesign-branch.md +2 -2
- package/skills/tr-responsive.md +2 -2
- package/skills/tr-seo.md +1 -1
- package/dist/tools/collections.js +0 -242
- package/skills/tr-collection-template.md +0 -305
package/dist/init.js
CHANGED
|
@@ -104,6 +104,12 @@ This project is wired to a Typeroll site through \`@typeroll/mcp-server\`.
|
|
|
104
104
|
- **Connection** is configured in \`.mcp.json\`. Fill in \`TYPEROLL_API_KEY\`
|
|
105
105
|
(create one in the portal under Settings → API keys) and
|
|
106
106
|
\`TYPEROLL_SITE_ID\`.
|
|
107
|
+
- **One Page model (Core 0.2.0 / MCP 0.45.0).** Every article, checklist,
|
|
108
|
+
directory entry and ordinary page is a Page. A \`content_type\` supplies its
|
|
109
|
+
schema, URL pattern and default Page template; custom values live in \`fields\`.
|
|
110
|
+
Use \`create_page\`, \`list_pages content_type=...\` and Page IDs everywhere.
|
|
111
|
+
Use the same \`version\` for Pages, types, templates and previews. Existing
|
|
112
|
+
installations require the schema 2 migration before using this model.
|
|
107
113
|
- **Discover before you write.** Call \`get_site\`, \`read_site_settings\`,
|
|
108
114
|
\`list_pages\`, and \`list_block_types\` before proposing changes so you
|
|
109
115
|
mirror the site's conventions.
|
package/dist/server.js
CHANGED
|
@@ -11,7 +11,8 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
11
11
|
import { z } from 'zod';
|
|
12
12
|
import { pageTools } from './tools/pages.js';
|
|
13
13
|
import { partialTools } from './tools/partials.js';
|
|
14
|
-
import {
|
|
14
|
+
import { contentTypeTools } from './tools/content-types.js';
|
|
15
|
+
import { pageTemplateTools } from './tools/page-templates.js';
|
|
15
16
|
import { mediaTools } from './tools/media.js';
|
|
16
17
|
import { redirectTools } from './tools/redirects.js';
|
|
17
18
|
import { migrationTools } from './tools/migration.js';
|
|
@@ -44,6 +45,8 @@ const PERM_RANK = { read: 0, write: 1, admin: 2 };
|
|
|
44
45
|
* mutates.
|
|
45
46
|
*/
|
|
46
47
|
function effectFor(name) {
|
|
48
|
+
if (name === 'page_completeness')
|
|
49
|
+
return 'read';
|
|
47
50
|
if (name === 'list_apps'
|
|
48
51
|
|| name === 'read_app'
|
|
49
52
|
|| name === 'update_app'
|
|
@@ -75,8 +78,11 @@ const DEFAULT_INFO = { name: 'typeroll', version: VERSION };
|
|
|
75
78
|
*/
|
|
76
79
|
export const SERVER_INSTRUCTIONS = `
|
|
77
80
|
Typeroll MCP — operating manual. You're managing a Typeroll site (a static-site
|
|
78
|
-
CMS: database content compiles to a fast static site on a deploy).
|
|
79
|
-
|
|
81
|
+
CMS: database content compiles to a fast static site on a deploy). Every article,
|
|
82
|
+
checklist, directory entry and ordinary page is a Page. Content types supply
|
|
83
|
+
custom field schemas, URL patterns and default Page templates. Use Page IDs and
|
|
84
|
+
list_pages with content_type filters; use the same version for all operations.
|
|
85
|
+
The full playbook ships with this server — use it:
|
|
80
86
|
|
|
81
87
|
0. For the complete operating context (data model, conventions, safety
|
|
82
88
|
boundaries, tool-family reference), call read_guide once. list_skills +
|
|
@@ -90,12 +96,13 @@ playbook ships with this server — use it:
|
|
|
90
96
|
or field names — they're per-site. For installed Extension config, call
|
|
91
97
|
list_extension_installations then read_extension_installation before an
|
|
92
98
|
update.
|
|
93
|
-
3. THE BUFFER MODEL:
|
|
94
|
-
|
|
99
|
+
3. THE BUFFER MODEL: content edits to Pages, blocks and partials land in an
|
|
100
|
+
unsaved per-doc DRAFT — deploys and plain
|
|
95
101
|
previews see saved content only. Iterate freely, view your drafts with
|
|
96
102
|
include_working_copy on the preview tools, then SAVE explicitly:
|
|
97
103
|
commit_working_copy (or save:true on the write call) when the user
|
|
98
|
-
approves. Status changes and structural ops apply immediately
|
|
104
|
+
approves. Status changes and structural ops apply immediately, including
|
|
105
|
+
Content type and Page template definitions and change_page_content_type.
|
|
99
106
|
4. Branch first for anything larger than a small edit: create_branch, pass
|
|
100
107
|
version=<id> on every subsequent call, merge_branch once approved. Nothing
|
|
101
108
|
touches the live site until then.
|
|
@@ -126,7 +133,8 @@ export function buildServer(options) {
|
|
|
126
133
|
...blockTypeTools,
|
|
127
134
|
...pageBlockTools,
|
|
128
135
|
...workingCopyTools,
|
|
129
|
-
...
|
|
136
|
+
...contentTypeTools,
|
|
137
|
+
...pageTemplateTools,
|
|
130
138
|
...mediaTools,
|
|
131
139
|
...redirectTools,
|
|
132
140
|
...migrationTools,
|
package/dist/tools/bulk.js
CHANGED
|
@@ -6,7 +6,7 @@ function v(version) {
|
|
|
6
6
|
export const bulkTools = [
|
|
7
7
|
{
|
|
8
8
|
name: 'check_internal_links',
|
|
9
|
-
description: 'Check internal hrefs against the versioned database before deploy. Covers published
|
|
9
|
+
description: 'Check internal hrefs against the versioned database before deploy. Covers published Pages of every content type, partials, Page templates, generated facet routes, media URLs and redirect chains. Returns every broken source/href pair without requesting the live site.',
|
|
10
10
|
inputSchema: { version: versionParam },
|
|
11
11
|
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
12
12
|
return ok(await client.get(siteId, 'internal-links', v(args.version)));
|
|
@@ -14,15 +14,14 @@ export const bulkTools = [
|
|
|
14
14
|
},
|
|
15
15
|
{
|
|
16
16
|
name: 'bulk_replace_text',
|
|
17
|
-
description: 'Replace a literal substring or regex across
|
|
17
|
+
description: 'Replace a literal substring or regex across Page bodies, custom Page fields, and partials. ALWAYS run with dry_run=true first and show sample_diffs before the real call. Defaults to scope=pages. BUFFER MODEL: replacements land in unsaved DRAFTS; save=true commits each touched resource through the canonical save path.',
|
|
18
18
|
inputSchema: {
|
|
19
19
|
pattern: z.string().min(1).describe('Literal substring (default) or JS regex source if regex=true.'),
|
|
20
20
|
replacement: z.string(),
|
|
21
21
|
regex: z.boolean().optional().describe('Treat pattern as a regex source. Always case-insensitive + global.'),
|
|
22
|
-
scope: z.enum(['pages', '
|
|
22
|
+
scope: z.enum(['pages', 'partials', 'all']).optional().describe('Resource family to scan. Defaults to pages.'),
|
|
23
23
|
page_ids: z.array(z.string()).optional().describe('Restrict to these page ids. Omit to apply to every matching page.'),
|
|
24
|
-
|
|
25
|
-
item_ids: z.array(z.string()).optional().describe('Restrict to item ids. Requires collection.'),
|
|
24
|
+
content_type: z.string().optional().describe('Restrict replacement to Pages of this content type.'),
|
|
26
25
|
partial_ids: z.array(z.string()).optional().describe('Restrict to partial ids.'),
|
|
27
26
|
dry_run: z.boolean().optional(),
|
|
28
27
|
save: z.boolean().optional().describe('Commit every touched page\'s draft in the same call — the usual choice after the user approved the dry-run diffs.'),
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { ok, withErrorBoundary, versionParam } from './helpers.js';
|
|
3
|
+
const schema = z.array(z.object({ name: z.string(), label: z.string(), type: z.string() }).passthrough());
|
|
4
|
+
const fields = {
|
|
5
|
+
label_singular: z.string().optional(), label_plural: z.string().optional(), fields: schema.optional(),
|
|
6
|
+
page_field_rules: z.record(z.object({ label: z.string().optional(), writable_by: z.array(z.enum(['portal', 'owner', 'agent', 'app', 'import'])) })).optional(),
|
|
7
|
+
allowed_templates: z.array(z.string()).nullable().optional().describe('Allowed Page template IDs, including the default. Null allows all compatible templates; an empty list permits no template.'),
|
|
8
|
+
route_template: z.string().optional(), template: z.string().optional(), schema_type: z.string().optional(),
|
|
9
|
+
schema_field_map: z.record(z.string()).optional(),
|
|
10
|
+
icon: z.string().optional(), sort_field: z.string().optional(), sort_dir: z.enum(['asc', 'desc']).optional(),
|
|
11
|
+
facets: z.array(z.record(z.unknown())).optional(), facet_combinations: z.array(z.record(z.unknown())).optional(),
|
|
12
|
+
};
|
|
13
|
+
export const contentTypeTools = [
|
|
14
|
+
{ name: 'page_completeness', description: 'Report missing, unverified and stale fields for Pages of one content type, with per-field counts and worst-first Page IDs. Read-only; agent-writable fields only by default.', inputSchema: { content_type: z.string(), limit: z.number().int().min(1).max(200).optional(), stale_after_days: z.number().min(0).optional(), agent_writable_only: z.boolean().optional(), version: versionParam },
|
|
15
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => { const { content_type, ...query } = args; return ok(await client.get(siteId, `content-types/${encodeURIComponent(content_type)}/completeness`, query)); }) },
|
|
16
|
+
{ name: 'change_page_content_type', description: 'Change a saved Page to another content type. Preserves its identity, block body, status and existing URL. Save or discard its working copy first. If supplied, fields replaces the complete custom fields object; omitted fields are kept and must be valid for the new type. Snapshots a revision before changing.', inputSchema: { page_id: z.string(), content_type: z.string(), fields: z.record(z.unknown()).optional(), version: versionParam },
|
|
17
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => { const { page_id, version, ...body } = args; return ok(await client.post(siteId, `pages/${encodeURIComponent(page_id)}/content-type`, body, { version })); }) },
|
|
18
|
+
{ name: 'list_content_types', description: 'List the field schemas, URL patterns, sorting, allowed templates and default templates for Pages. All content is a Page; use list_pages with content_type to filter.', inputSchema: { version: versionParam },
|
|
19
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => ok(await client.get(siteId, 'content-types', { version: args.version }))) },
|
|
20
|
+
{ name: 'read_content_type', description: 'Read one Page content type.', inputSchema: { name: z.string(), version: versionParam },
|
|
21
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => ok(await client.get(siteId, `content-types/${encodeURIComponent(args.name)}`, { version: args.version }))) },
|
|
22
|
+
{ name: 'create_content_type', description: 'Create a Page content type. Title, slug, block body, SEO and status are built in; fields defines custom fields only. An empty route_template means pages of this type have no public URL.', inputSchema: { name: z.string(), ...fields, label_singular: z.string(), label_plural: z.string(), fields: schema, route_template: z.string(), version: versionParam },
|
|
23
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => { const { version, ...body } = args; return ok(await client.post(siteId, 'content-types', body, { version })); }) },
|
|
24
|
+
{ name: 'update_content_type', description: 'Update a Page content type. Changes affect all its pages at the next publication; explicit per-page paths remain overrides. The default template must be allowed; removing templates used by saved Pages is rejected.', inputSchema: { name: z.string(), patch: z.object(fields), version: versionParam },
|
|
25
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => ok(await client.patch(siteId, `content-types/${encodeURIComponent(args.name)}`, args.patch, { version: args.version }))) },
|
|
26
|
+
{ name: 'delete_content_type', description: 'Delete an unused content type. Reassign or delete its pages first; the standard Page type cannot be deleted.', inputSchema: { name: z.string(), version: versionParam },
|
|
27
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => ok(await client.del(siteId, `content-types/${encodeURIComponent(args.name)}`, { version: args.version }))) },
|
|
28
|
+
];
|
package/dist/tools/deploy.js
CHANGED
|
@@ -12,7 +12,7 @@ export const deployTools = [
|
|
|
12
12
|
},
|
|
13
13
|
{
|
|
14
14
|
name: 'trigger_deploy',
|
|
15
|
-
description: "Enqueue a deploy (static-page build → hosting) of the currently active version. Returns a job_id immediately; poll get_deploy_status until the status leaves queued/running. NOT for previewing iterative design/content edits — that's what get_preview_link is for (it renders live from the DB with no build). Reserve trigger_deploy for publishing, a stakeholder link to the compiled site, or pre-merge validation. Deploys build SAVED content only — unsaved drafts (working copies) are excluded, so commit_working_copy (or save:true on your writes) before deploying, or your changes won't ship. With `dry_run: true`, the deploy runs through every step EXCEPT the hosting-adapter upload — useful for validating a structural change (new
|
|
15
|
+
description: "Enqueue a deploy (static-page build → hosting) of the currently active version. Returns a job_id immediately; poll get_deploy_status until the status leaves queued/running. NOT for previewing iterative design/content edits — that's what get_preview_link is for (it renders live from the DB with no build). Reserve trigger_deploy for publishing, a stakeholder link to the compiled site, or pre-merge validation. Deploys build SAVED content only — unsaved drafts (working copies) are excluded, so commit_working_copy (or save:true on your writes) before deploying, or your changes won't ship. With `dry_run: true`, the deploy runs through every step EXCEPT the hosting-adapter upload — useful for validating a structural change (new content-type routes, schema bump, mode switch) without risking the live site. A dry_run job that succeeds proves the build is clean; one that fails returns the same astro stack-trace in get_deploy_status.error as a real deploy would.",
|
|
16
16
|
inputSchema: {
|
|
17
17
|
environment: z.enum(['production', 'staging']).optional(),
|
|
18
18
|
dry_run: z.boolean().optional().describe('Run the build but skip the CDN upload. The job ends in succeeded/failed the same way a real deploy does.'),
|
package/dist/tools/migration.js
CHANGED
|
@@ -63,7 +63,7 @@ export const migrationTools = [
|
|
|
63
63
|
},
|
|
64
64
|
{
|
|
65
65
|
name: 'list_migration_urls',
|
|
66
|
-
description: "The legacy site's URL inventory with LIVE coverage status. Every entry is classified on read against the site's current pages + redirects: `migrated` (a
|
|
66
|
+
description: "The legacy site's URL inventory with LIVE coverage status. Every entry is classified on read against the site's current pages + redirects: `migrated` (a Page answers at that path), `redirected` (a redirect rule covers it), `excluded` (signed off as an intentional 404), `unhandled` (nothing covers it — the work list). Returns a summary over the whole inventory plus a page of entries, sorted worst-first then by GSC clicks. Use `status: \"unhandled\"` to get exactly what's left to do before cutover. Coverage is computed, never stored, so it's current the moment you create a redirect.",
|
|
67
67
|
inputSchema: {
|
|
68
68
|
status: STATUS.optional().describe('Only return entries with this coverage status.'),
|
|
69
69
|
limit: z.number().int().positive().max(1000).optional().describe('Default 200.'),
|
|
@@ -154,11 +154,10 @@ export const migrationTools = [
|
|
|
154
154
|
name: 'repair_migration_plain_text',
|
|
155
155
|
description: 'Repair legacy WordPress entity encoding and HTML markup in fields whose contract is plain text. ONLY title, seo_title, seo_description and excerpt are eligible; rich content, slugs, paths and URLs are never changed. ALWAYS run the dry-run first (the default), show every returned before/after diff and conflict to the user, and obtain approval before calling again with dry_run:false. Existing working copies are skipped so another editor\'s draft cannot be overwritten or accidentally saved. save:false stages working copies for portal review; save:true commits through the normal revision and validation path.',
|
|
156
156
|
inputSchema: {
|
|
157
|
-
scope: z.enum(['pages', '
|
|
157
|
+
scope: z.enum(['pages', 'all']).optional().describe('Defaults to all.'),
|
|
158
158
|
fields: z.array(PLAIN_TEXT_REPAIR_FIELD).min(1).max(4).optional().describe('Narrow the fixed plain-text allowlist.'),
|
|
159
159
|
page_ids: z.array(z.string().min(1)).min(1).max(2000).optional(),
|
|
160
|
-
|
|
161
|
-
item_ids: z.array(z.string().min(1)).min(1).max(2000).optional().describe('Requires collection.'),
|
|
160
|
+
content_type: z.string().min(1).optional(),
|
|
162
161
|
dry_run: z.boolean().optional().describe('Defaults to true. Set false only after the user reviews the dry-run.'),
|
|
163
162
|
save: z.boolean().optional().describe('With dry_run:false, commit repaired working copies immediately. Defaults to false.'),
|
|
164
163
|
diff_limit: z.number().int().min(1).max(2000).optional().describe('Exact field diffs returned; default 500.'),
|
|
@@ -28,9 +28,9 @@ const blockSchema = z.object({
|
|
|
28
28
|
* `{ kind: 'page', id: page_id }`.
|
|
29
29
|
*/
|
|
30
30
|
const targetSchema = z.object({
|
|
31
|
-
kind: z.enum(['page', 'partial', 'template'
|
|
31
|
+
kind: z.enum(['page', 'partial', 'template']),
|
|
32
32
|
id: z.string(),
|
|
33
|
-
}).describe('Address a
|
|
33
|
+
}).describe('Address a page, partial or PageTemplate by its ID. All content uses kind=page; the same block editor applies to every content type.');
|
|
34
34
|
function v(version) {
|
|
35
35
|
return version ? { version } : undefined;
|
|
36
36
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { ok, withErrorBoundary, versionParam } from './helpers.js';
|
|
3
|
+
const fields = {
|
|
4
|
+
label: z.string().optional(), icon: z.string().optional(), applies_to: z.string().optional(),
|
|
5
|
+
blocks: z.array(z.any()).optional(), status: z.enum(['draft', 'published']).optional(),
|
|
6
|
+
};
|
|
7
|
+
export const pageTemplateTools = [
|
|
8
|
+
{ name: 'list_page_templates', description: 'List reusable Page templates in the selected version, including inherited templates.', inputSchema: { version: versionParam },
|
|
9
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => ok(await client.get(siteId, 'page-templates', { version: args.version }))) },
|
|
10
|
+
{ name: 'read_page_template', description: 'Read one reusable Page template and its block tree.', inputSchema: { template_id: z.string(), version: versionParam },
|
|
11
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => ok(await client.get(siteId, `page-templates/${encodeURIComponent(args.template_id)}`, { version: args.version }))) },
|
|
12
|
+
{ name: 'create_page_template', description: 'Create a reusable template. Choose a starter or provide blocks, including template_content_slot for the Page body. Assign its ID to a content type’s template or a Page’s template.', inputSchema: { name: z.string(), ...fields, starter: z.enum(['blog', 'article', 'checklist', 'team', 'events', 'products', 'custom']).optional(), version: versionParam },
|
|
13
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => { const { version, ...body } = args; return ok(await client.post(siteId, 'page-templates', body, { version })); }) },
|
|
14
|
+
{ name: 'update_page_template', description: 'Update a reusable Page template. The change is saved immediately and affects its Pages on the next publication. A version isolates the edit from main.', inputSchema: { template_id: z.string(), patch: z.object(fields), version: versionParam },
|
|
15
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => ok(await client.patch(siteId, `page-templates/${encodeURIComponent(args.template_id)}`, args.patch, { version: args.version }))) },
|
|
16
|
+
{ name: 'delete_page_template', description: 'Delete an unused Page template. Reassign any Pages or content types using it first.', inputSchema: { template_id: z.string(), version: versionParam },
|
|
17
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => ok(await client.del(siteId, `page-templates/${encodeURIComponent(args.template_id)}`, { version: args.version }))) },
|
|
18
|
+
];
|
package/dist/tools/pages.js
CHANGED
|
@@ -10,6 +10,9 @@ export const pageTools = [
|
|
|
10
10
|
description: 'List pages on the active site. Returns id, title, slug, status, and SEO summary (no html_content by default — use read_page or batch_read_pages for body content). Supports filtering by status and forward-cursor pagination. Pass full=true to include html_content + blocks in the response.',
|
|
11
11
|
inputSchema: {
|
|
12
12
|
status: z.enum(['draft', 'review', 'unlisted', 'published', 'all']).optional(),
|
|
13
|
+
sort_by: z.string().optional().describe('Sort field; defaults to the selected content type’s order. Unfiltered lists use stable Page IDs.'),
|
|
14
|
+
sort_order: z.enum(['asc', 'desc']).optional(),
|
|
15
|
+
content_type: z.string().optional().describe('Filter Pages by content type ID.'),
|
|
13
16
|
limit: z.number().int().min(1).max(200).optional(),
|
|
14
17
|
cursor: z.string().optional(),
|
|
15
18
|
full: z.boolean().optional().describe('Set true to include html_content + blocks. Default false (summary only) to avoid large payloads on sites with many pages.'),
|
|
@@ -18,6 +21,9 @@ export const pageTools = [
|
|
|
18
21
|
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
19
22
|
const res = await client.get(siteId, 'pages', {
|
|
20
23
|
status: args.status,
|
|
24
|
+
content_type: args.content_type,
|
|
25
|
+
sort_by: args.sort_by,
|
|
26
|
+
sort_order: args.sort_order,
|
|
21
27
|
limit: args.limit,
|
|
22
28
|
cursor: args.cursor,
|
|
23
29
|
full: args.full ? 'true' : undefined,
|
|
@@ -69,13 +75,15 @@ export const pageTools = [
|
|
|
69
75
|
'the server stores it under id "home" with slug "". ' +
|
|
70
76
|
'Slug must be a single path segment (no slashes). For nested URLs like ' +
|
|
71
77
|
'/erbjudanden/sommar or /tjanster/design, set the optional `path` field explicitly — ' +
|
|
72
|
-
'e.g. path="/erbjudanden/sommar". For
|
|
78
|
+
'e.g. path="/erbjudanden/sommar". For pages with the same schema use a content type with route_template ' +
|
|
73
79
|
'(see tr-blog / tr-directory), but for a small group of bespoke pages sharing a URL prefix, ' +
|
|
74
80
|
'`path` is the right primitive. ' +
|
|
75
81
|
'Returns the created page (blocks for blocks-mode, html_content for html-mode) including ' +
|
|
76
82
|
'`url` (the resolved live URL).',
|
|
77
83
|
inputSchema: {
|
|
78
84
|
title: z.string().min(1),
|
|
85
|
+
content_type: z.string().optional().describe('Content type ID; defaults to page.'),
|
|
86
|
+
fields: z.record(z.unknown()).optional().describe('Custom field values defined by the content type.'),
|
|
79
87
|
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
88
|
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
89
|
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".'),
|
|
@@ -94,7 +102,8 @@ export const pageTools = [
|
|
|
94
102
|
kind: z.enum(['page', 'article']).optional(),
|
|
95
103
|
author: z.string().optional(),
|
|
96
104
|
language: z.string().optional().describe('BCP-47 tag overriding the site default (e.g. "en" on an otherwise Swedish site).'),
|
|
97
|
-
|
|
105
|
+
sort_order: z.number().finite().optional().describe('Manual Page order; lower numbers come first in ascending lists. Content types and listings choose how to sort.'),
|
|
106
|
+
template: z.string().nullable().optional().describe('Allowed Page template ID; null uses the content type default.'),
|
|
98
107
|
image_sizes_default: z.string().optional().describe('Per-page default `sizes` for responsive images (e.g. "(max-width: 640px) 360px, 560px"). Overrides the site setting; a per-<img> `sizes` attr still wins. Set when this page\'s images render narrower than the generic default so the browser stops over-fetching.'),
|
|
99
108
|
custom_css: z.string().optional().describe('Per-page CSS, injected into <head> as a <style> AFTER the site-level custom_css (so it overrides site styling). This is the RIGHT home for page-specific styling — page metadata, not content. Put a page\'s <style> here instead of stuffing it into a core/html block (which is opaque and un-editable in the visual editor).'),
|
|
100
109
|
version: versionParam,
|
|
@@ -113,6 +122,7 @@ export const pageTools = [
|
|
|
113
122
|
patch: z
|
|
114
123
|
.object({
|
|
115
124
|
title: z.string().optional(),
|
|
125
|
+
fields: z.record(z.unknown()).optional(),
|
|
116
126
|
slug: z.string().optional(),
|
|
117
127
|
html_content: z.string().optional(),
|
|
118
128
|
blocks: z.array(z.any()).optional().describe('Block tree, only used when content_mode="blocks".'),
|
|
@@ -149,7 +159,8 @@ export const pageTools = [
|
|
|
149
159
|
url: z.string().optional(),
|
|
150
160
|
})
|
|
151
161
|
.optional(),
|
|
152
|
-
|
|
162
|
+
sort_order: z.number().finite().nullable().optional(),
|
|
163
|
+
template: z.string().nullable().optional().describe('Allowed template ID; null restores the content type default.'),
|
|
153
164
|
})
|
|
154
165
|
.passthrough(),
|
|
155
166
|
save: z.boolean().optional().describe('Also SAVE (commit) the draft in the same call — use for pre-approved or batch changes. Without it, changes stay in the unsaved draft until commit_working_copy.'),
|
|
@@ -221,6 +232,8 @@ export const pageTools = [
|
|
|
221
232
|
const srcMode = src.content_mode ?? 'html';
|
|
222
233
|
const body = {
|
|
223
234
|
title: args.title,
|
|
235
|
+
content_type: src.content_type,
|
|
236
|
+
fields: src.fields,
|
|
224
237
|
content_mode: srcMode,
|
|
225
238
|
html_content: srcMode === 'html' ? src.html_content : '',
|
|
226
239
|
blocks: srcMode === 'blocks' ? (src.blocks ?? []) : undefined,
|
package/dist/tools/preview.js
CHANGED
|
@@ -17,12 +17,10 @@ export const previewTools = [
|
|
|
17
17
|
},
|
|
18
18
|
{
|
|
19
19
|
name: 'get_preview_link',
|
|
20
|
-
description: 'Mint a signed URL the user (or your own browser tool) can open to SEE the rendered preview. Renders LIVE from the database with NO build — this is the PREFERRED way to preview design/content changes as you iterate; reach for this, not trigger_deploy, when previewing. BUFFER MODEL: your edits are unsaved drafts, so for the iteration loop mint the link with include_working_copy:true (drafts visible); a plain link shows SAVED content only — right for stakeholders reviewing what will deploy. Mint ONCE and REUSE that single URL across edits — internal links keep the token so it navigates the whole branch, and it stays valid until the TTL lapses (24h default and max). Target a page (page_id),
|
|
20
|
+
description: 'Mint a signed URL the user (or your own browser tool) can open to SEE the rendered preview. Renders LIVE from the database with NO build — this is the PREFERRED way to preview design/content changes as you iterate; reach for this, not trigger_deploy, when previewing. BUFFER MODEL: your edits are unsaved drafts, so for the iteration loop mint the link with include_working_copy:true (drafts visible); a plain link shows SAVED content only — right for stakeholders reviewing what will deploy. Mint ONCE and REUSE that single URL across edits — internal links keep the token so it navigates the whole branch, and it stays valid until the TTL lapses (24h default and max). Target a page (page_id), or a raw slug; omit all for the home page. (For a permanent public link, complete Publishing setup and wait for a verified deployment on the organization or site domain.)',
|
|
21
21
|
inputSchema: {
|
|
22
22
|
page_id: z.string().optional(),
|
|
23
23
|
slug: z.string().optional(),
|
|
24
|
-
collection_name: z.string().optional(),
|
|
25
|
-
item_id: z.string().optional(),
|
|
26
24
|
ttl_seconds: z.number().int().min(60).max(86_400).optional(),
|
|
27
25
|
include_working_copy: z
|
|
28
26
|
.boolean()
|
package/dist/tools/sites.js
CHANGED
|
@@ -28,7 +28,7 @@ export const siteTools = [
|
|
|
28
28
|
},
|
|
29
29
|
{
|
|
30
30
|
name: 'get_site_capabilities',
|
|
31
|
-
description: "Discover what the site-template renderer supports for this site. Returns a manifest of feature flags (blocks-mode, x-include,
|
|
31
|
+
description: "Discover what the site-template renderer supports for this site. Returns a manifest of feature flags (blocks-mode, x-include, content-type routes, custom block scripts, dry-run deploys, etc.) plus template_capabilities_version + the core block type ids. Call this when you're about to do something structural (set route_template on a content type, switch a page to blocks-mode, install a custom block type) and want to feature-detect rather than guess. The manifest is platform-wide today; per-site custom templates land later.",
|
|
32
32
|
handler: withErrorBoundary(async (_args, { client, siteId }) => {
|
|
33
33
|
const res = await client.get(siteId, 'capabilities');
|
|
34
34
|
return ok(res);
|
|
@@ -17,16 +17,10 @@ function v(version) {
|
|
|
17
17
|
return version ? { version } : undefined;
|
|
18
18
|
}
|
|
19
19
|
const wcTargetSchema = z.object({
|
|
20
|
-
kind: z.enum(['page', 'partial'
|
|
20
|
+
kind: z.enum(['page', 'partial']),
|
|
21
21
|
id: z.string(),
|
|
22
|
-
|
|
23
|
-
}).describe('Which doc\'s draft: page {id}, partial {id}, or collection item ({collection} + {id}). Templates/item templates have no drafts (their writes apply directly).');
|
|
22
|
+
}).describe('Which doc\'s draft: page {id} or partial {id}. Templates have no drafts (their writes apply directly).');
|
|
24
23
|
function wcPath(t) {
|
|
25
|
-
if (t.kind === 'item') {
|
|
26
|
-
if (!t.collection)
|
|
27
|
-
throw new Error('collection is required when kind="item"');
|
|
28
|
-
return `working-copy/item/${encodeURIComponent(t.collection)}/${encodeURIComponent(t.id)}`;
|
|
29
|
-
}
|
|
30
24
|
return `working-copy/${t.kind}/${encodeURIComponent(t.id)}`;
|
|
31
25
|
}
|
|
32
26
|
export const workingCopyTools = [
|
package/dist/version.js
CHANGED
package/package.json
CHANGED
package/skills/README.md
CHANGED
|
@@ -46,11 +46,10 @@ ln -s "$PWD/skills/tr-migrate-wp.md" ~/.claude/skills/
|
|
|
46
46
|
|
|
47
47
|
| File | When it triggers | What it does |
|
|
48
48
|
|---|---|---|
|
|
49
|
-
| `tr-blog.md`
|
|
49
|
+
| `tr-blog.md` | "add a blog", "set up news" | Articles are Pages of a content type, sharing a native template and Page listing. |
|
|
50
50
|
| `tr-forms.md` | "contact form", "add a form", "booking form" | Form definition → embed HTML with signed token → inline JS feedback → deploy. |
|
|
51
|
-
| `tr-directory.md` | Building a directory site, importing structured data |
|
|
52
|
-
| `tr-
|
|
53
|
-
| `tr-page-template.md` | Several pages share structure (category landings, service-detail variants) | Partials + `<x-include>` for HTML mode; formal `PageTemplate` via `set_page_template` for block mode. Refactor existing duplication. |
|
|
51
|
+
| `tr-directory.md` | Building a directory site, importing structured data | Content type → Pages → URLs via `route_template` → listing page → preview → deploy. |
|
|
52
|
+
| `tr-page-template.md` | Reusable Page templates: audio players, chapter lists, guest cards, image galleries — anything needing loops/nested data | Compose native blocks around `template_content_slot`; bind Page metadata, structured fields and references. |
|
|
54
53
|
| `tr-seo.md` | "SEO", "meta descriptions", "structured data" | Audit → fix titles/descriptions → OG images → JSON-LD → robots.txt → deploy. |
|
|
55
54
|
|
|
56
55
|
### Importera innehåll
|
|
@@ -59,12 +58,12 @@ ln -s "$PWD/skills/tr-migrate-wp.md" ~/.claude/skills/
|
|
|
59
58
|
|---|---|---|
|
|
60
59
|
| `tr-migrate-wp.md` | "migrate from WordPress", a wp-json URL is mentioned | Walks the WP REST, rebuilds each page in the target's design, transfers media, sets redirects, leaves everything as drafts for review. |
|
|
61
60
|
| `tr-migrate-multisite.md` | "multisite", "our .se/.de/.co.uk sites", migrating several sites at once | One site per domain; per-site URL inventory, design replicated via `.tcblocks`, path preservation, hreflang clusters, and a parity check against the deployed site before DNS moves. |
|
|
62
|
-
| `tr-migrate-astro.md` | "migrate an Astro site", "import from src/content" | Lifts Astro Content Collections (`src/content/*`) into Typeroll
|
|
61
|
+
| `tr-migrate-astro.md` | "migrate an Astro site", "import from src/content" | Lifts Astro Content Collections (`src/content/*`) into Typeroll content types — zod schema → field list, frontmatter → field values, Markdown body → Page blocks. Translates standalone `src/pages/*` into Typeroll pages, maps `src/layouts` chunks into partials. |
|
|
63
62
|
| `tr-import-url.md` | "import from Squarespace/Wix/Webflow", any non-WP URL | Fetch → clean → adapt to target design → media transfer → draft pages → redirects → deploy. |
|
|
64
63
|
|
|
65
64
|
## Prerequisites for every skill
|
|
66
65
|
|
|
67
|
-
1. `@typeroll/mcp-server` configured in
|
|
66
|
+
1. `@typeroll/mcp-server` configured in the agent’s MCP settings with a valid
|
|
68
67
|
`TYPEROLL_API_KEY` and `TYPEROLL_API_URL`.
|
|
69
68
|
2. The agent has read `AGENTS.md` (ships with the MCP package — see
|
|
70
69
|
`node_modules/@typeroll/mcp-server/AGENTS.md` after install, or
|
|
@@ -78,7 +77,7 @@ If those are missing, every skill will fail at the first MCP call with
|
|
|
78
77
|
Skills are markdown files that the agent loads on demand. Each one
|
|
79
78
|
should include:
|
|
80
79
|
|
|
81
|
-
- A `description:` frontmatter explaining when to load it (
|
|
80
|
+
- A `description:` frontmatter explaining when to load it (agents use
|
|
82
81
|
this to pick the right skill).
|
|
83
82
|
- A clear set of preconditions (what MCP tools must work, what state the
|
|
84
83
|
agent needs to know).
|
package/skills/tr-blog.md
CHANGED
|
@@ -1,145 +1,61 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: tr-blog
|
|
3
|
-
description:
|
|
3
|
+
description: Set up a blog, news section or dated article series using Pages, a content type, shared templates and a dynamic listing.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
#
|
|
6
|
+
# Build a blog with Pages
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
> build work, pass `save: true` on write calls (the work is pre-approved by
|
|
12
|
-
> the task itself), or run `commit_working_copy` per doc before any
|
|
13
|
-
> `trigger_deploy`. Preview your drafts with `include_working_copy: true`.
|
|
8
|
+
Every article is a Page. A Content type supplies its extra fields, URL pattern
|
|
9
|
+
and default template. Do not create a separate item store or generate listing
|
|
10
|
+
HTML by hand.
|
|
14
11
|
|
|
12
|
+
1. Read `get_site_capabilities`, `list_block_types`, `list_content_types` and
|
|
13
|
+
`list_page_templates`. For a substantial change, create a site version and
|
|
14
|
+
pass its ID as `version` on every supported operation.
|
|
15
|
+
2. Create an editable shared template:
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
the native `article` preset; the listing is a block-mode page with
|
|
20
|
-
`core/collection_list` and updates at build time.
|
|
21
|
-
|
|
22
|
-
If you find yourself about to create 20 pages for 20 articles, stop — you're using the old pattern. The recipe below is the right one.
|
|
23
|
-
|
|
24
|
-
## Preconditions
|
|
25
|
-
|
|
26
|
-
- Site exists with working header/footer.
|
|
27
|
-
- Collection name picked (`blog`, `news`, `artiklar`, `podcast`, `avsnitt`).
|
|
28
|
-
- URL structure picked: `/blog/{slug}`, `/news/{slug}`, `/podd/{slug}`. Changing later renames every URL.
|
|
17
|
+
```json
|
|
18
|
+
{ "name": "article-layout", "label": "Article", "starter": "article", "status": "published" }
|
|
19
|
+
```
|
|
29
20
|
|
|
30
|
-
|
|
21
|
+
Send it to `create_page_template`. Use block tools with
|
|
22
|
+
`target: { kind: "template", id: "article-layout" }` to refine the layout.
|
|
31
23
|
|
|
32
|
-
|
|
24
|
+
3. Call `create_content_type`:
|
|
33
25
|
|
|
34
|
-
```
|
|
35
|
-
|
|
36
|
-
"name": "
|
|
37
|
-
"
|
|
38
|
-
"
|
|
39
|
-
"icon": "📝",
|
|
40
|
-
"slug_field": "slug",
|
|
41
|
-
"sort_field": "published_at",
|
|
42
|
-
"sort_dir": "desc",
|
|
43
|
-
"route_template": "/blog/{slug}",
|
|
44
|
-
"template_kind": "article",
|
|
26
|
+
```json
|
|
27
|
+
{
|
|
28
|
+
"name": "articles", "label_singular": "Article", "label_plural": "Articles",
|
|
29
|
+
"route_template": "/articles/{slug}", "template": "article-layout",
|
|
30
|
+
"sort_field": "date_published", "sort_dir": "desc",
|
|
45
31
|
"fields": [
|
|
46
|
-
{"name": "
|
|
47
|
-
{"name": "
|
|
48
|
-
{"name": "published_at", "type": "date", "label": "Datum", "required": true},
|
|
49
|
-
{"name": "author", "type": "text", "label": "Författare"},
|
|
50
|
-
{"name": "excerpt", "type": "textarea", "label": "Ingress"},
|
|
51
|
-
{"name": "body", "type": "richtext", "label": "Brödtext"},
|
|
52
|
-
{"name": "featured_image", "type": "image", "label": "Omslagsbild"}
|
|
32
|
+
{ "name": "excerpt", "label": "Summary", "type": "textarea" },
|
|
33
|
+
{ "name": "featured_image", "label": "Cover image", "type": "image" }
|
|
53
34
|
]
|
|
54
35
|
}
|
|
55
36
|
```
|
|
56
37
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
### 3. Build the native listing page once
|
|
82
|
-
|
|
83
|
-
```
|
|
84
|
-
create_page title="Artiklar" slug="blog" status="published" content_mode="blocks" blocks=[
|
|
85
|
-
{"id":"articles-heading","type":"core/heading","data":{"text":"Artiklar","level":"h1","size":"auto","align":"left"}},
|
|
86
|
-
{"id":"articles-list","type":"core/collection_list","data":{"collection":"blog","sort_by":"published_at","sort_order":"desc","item_block":"core/post_card","layout":"grid","cols":3,"gap":"md"}}
|
|
87
|
-
]
|
|
88
|
-
```
|
|
89
|
-
|
|
90
|
-
The list resolves from current collection data on every preview/build. No
|
|
91
|
-
generated HTML marker or regeneration call is needed.
|
|
92
|
-
|
|
93
|
-
### 4. Update the header partial to link to the listing
|
|
94
|
-
|
|
95
|
-
```
|
|
96
|
-
read_partial partial_id="header"
|
|
97
|
-
replace_partial partial_id="header" html_content="<updated with /blog link>"
|
|
98
|
-
```
|
|
99
|
-
|
|
100
|
-
### 5. Preview a single article
|
|
101
|
-
|
|
102
|
-
```
|
|
103
|
-
get_preview_link collection_name="blog" item_id="<id>"
|
|
104
|
-
```
|
|
105
|
-
|
|
106
|
-
The returned URL renders the native item block tree from current database content.
|
|
107
|
-
|
|
108
|
-
### 6. Deploy
|
|
109
|
-
|
|
110
|
-
```
|
|
111
|
-
trigger_deploy
|
|
112
|
-
get_deploy_status job_id=<id>
|
|
113
|
-
```
|
|
114
|
-
|
|
115
|
-
The build produces one HTML file per published article at `/blog/<slug>` plus the listing at `/blog`, and includes them all in `sitemap.xml`.
|
|
116
|
-
|
|
117
|
-
## Adding a new article later
|
|
118
|
-
|
|
119
|
-
```
|
|
120
|
-
create_collection_item collection="blog" status="published" fields={ ... }
|
|
121
|
-
trigger_deploy
|
|
122
|
-
```
|
|
123
|
-
|
|
124
|
-
Two calls. No per-article `create_page`, listing regeneration, or HTML diffing.
|
|
125
|
-
|
|
126
|
-
## Pitfalls
|
|
127
|
-
|
|
128
|
-
- **Don't fall back to "one page per article".** Collection item routes keep
|
|
129
|
-
design, sitemap, preview, and nested URL handling centralized. `create_page`
|
|
130
|
-
rejects slashes in `slug`; use the collection's `route_template`.
|
|
131
|
-
- **Slugs must be unique within the collection.** An item whose route tokens
|
|
132
|
-
are missing cannot receive a static detail URL.
|
|
133
|
-
- **Don't use non-ASCII field names.** `datum` not `Datum`; `forfattare` not `författare` in the `name`. The `label` is free-form.
|
|
134
|
-
- **Template too clever.** First compose current native blocks and run the
|
|
135
|
-
composition preflight. Use custom/HTML-backed behavior only when it is
|
|
136
|
-
genuinely business-specific, not to repair a generic Core gap.
|
|
137
|
-
|
|
138
|
-
## When you want a page that ISN'T a collection item
|
|
139
|
-
|
|
140
|
-
A normal `create_page` is still right for:
|
|
141
|
-
- The blog's about/contact pages.
|
|
142
|
-
- Editorial standalone features.
|
|
143
|
-
- Anything that doesn't fit the "list of dated entries" mould.
|
|
144
|
-
|
|
145
|
-
Just don't use `create_page` *for the entries themselves*.
|
|
38
|
+
Title, slug, author, date, SEO, status and body are built-in Page values. Keep
|
|
39
|
+
only custom values in `fields`.
|
|
40
|
+
|
|
41
|
+
4. Call `create_page` for each article with its `title`, `slug`,
|
|
42
|
+
`content_type: "articles"`, `status`, `date_published`, `fields` and `blocks`.
|
|
43
|
+
Use native heading, prose, image, list and table blocks. Store its returned
|
|
44
|
+
Page ID; use it for every later operation.
|
|
45
|
+
5. Create the archive as another Page with `core/page_list`, using
|
|
46
|
+
`data: { content_type: "articles", sort_by: "date_published", sort_order: "desc", paginate: 10 }`.
|
|
47
|
+
A homepage teaser can use `limit: 6` instead. Both resolve saved Pages at
|
|
48
|
+
render time; adding an article needs no listing regeneration.
|
|
49
|
+
6. Link to the archive from navigation. Preview representative articles and
|
|
50
|
+
the archive on desktop and mobile with `get_preview_link page_id=...`.
|
|
51
|
+
7. Save any working copies before publication. `update_page` and block edits
|
|
52
|
+
need `save: true` or `commit_working_copy`; new Pages and template definitions
|
|
53
|
+
are saved immediately. Deploy only within the user's authorized scope and
|
|
54
|
+
verify the returned job and public URL.
|
|
55
|
+
|
|
56
|
+
Use the organization's connected media storage before importing remote images.
|
|
57
|
+
Do not hardcode a Typeroll media hostname. Use the returned media URL.
|
|
58
|
+
|
|
59
|
+
A published Page with complete route tokens becomes one static detail page.
|
|
60
|
+
Draft/review Pages stay private. Unlisted Pages have a public URL but are omitted
|
|
61
|
+
from listings and search indexes. The selected version's robots policy applies.
|
package/skills/tr-brand.md
CHANGED
|
@@ -6,7 +6,7 @@ description: Use when the user asks to create a brand identity, design system, o
|
|
|
6
6
|
# Design a brand identity for a Typeroll site
|
|
7
7
|
|
|
8
8
|
> **The buffer model (draft writes).** Every content write in this recipe
|
|
9
|
-
> (pages, blocks
|
|
9
|
+
> (pages, blocks and partials) lands in an unsaved per-doc
|
|
10
10
|
> DRAFT — deploys and plain previews only see SAVED content. For recipe-style
|
|
11
11
|
> build work, pass `save: true` on write calls (the work is pre-approved by
|
|
12
12
|
> the task itself), or run `commit_working_copy` per doc before any
|
|
@@ -177,7 +177,7 @@ Then reference `var(--brand-gradient)` etc. in page HTML and partials.
|
|
|
177
177
|
|
|
178
178
|
## Step 4b — Section + layout design defaults
|
|
179
179
|
|
|
180
|
-
These are non-negotiable defaults the rest of the platform skills inherit (`tr-new-site`, `tr-directory`, `tr-
|
|
180
|
+
These are non-negotiable defaults the rest of the platform skills inherit (`tr-new-site`, `tr-directory`, `tr-page-template`). Apply them on every page that has visible sections — they're battle-tested across real customer migrations.
|
|
181
181
|
|
|
182
182
|
### One signal per section boundary
|
|
183
183
|
|