@typeroll/mcp-server 0.7.4
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 +448 -0
- package/README.md +135 -0
- package/dist/client.js +103 -0
- package/dist/index.js +114 -0
- package/dist/tools/block-types.js +86 -0
- package/dist/tools/bulk.js +24 -0
- package/dist/tools/collections.js +197 -0
- package/dist/tools/deploy.js +38 -0
- package/dist/tools/forms.js +98 -0
- package/dist/tools/helpers.js +59 -0
- package/dist/tools/media.js +202 -0
- package/dist/tools/page-blocks.js +155 -0
- package/dist/tools/pages.js +209 -0
- package/dist/tools/partials.js +108 -0
- package/dist/tools/preview.js +24 -0
- package/dist/tools/redirects.js +40 -0
- package/dist/tools/search.js +22 -0
- package/dist/tools/settings.js +64 -0
- package/dist/tools/sites.js +35 -0
- package/dist/tools/versions.js +52 -0
- package/package.json +50 -0
- package/skills/README.md +63 -0
- package/skills/tr-content-write.md +105 -0
- package/skills/tr-directory.md +214 -0
- package/skills/tr-images.md +152 -0
- package/skills/tr-migrate-wp.md +151 -0
- package/skills/tr-redesign-branch.md +149 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { ok, withErrorBoundary, versionParam } from './helpers.js';
|
|
3
|
+
function v(version) {
|
|
4
|
+
return version ? { version } : undefined;
|
|
5
|
+
}
|
|
6
|
+
export const previewTools = [
|
|
7
|
+
{
|
|
8
|
+
name: 'get_preview_link',
|
|
9
|
+
description: 'Mint a short-lived signed URL the user (or your own browser tool) can open to SEE the rendered preview. Internal links inside the preview keep the same token attached, so the agent can navigate the whole site from one mint. Default TTL 15 min, max 24h. Target the preview at a page (page_id), a collection item (collection_name + item_id, resolves via route_template), or a raw slug. Omit all to land on the home page.',
|
|
10
|
+
inputSchema: {
|
|
11
|
+
page_id: z.string().optional(),
|
|
12
|
+
slug: z.string().optional(),
|
|
13
|
+
collection_name: z.string().optional(),
|
|
14
|
+
item_id: z.string().optional(),
|
|
15
|
+
ttl_seconds: z.number().int().min(60).max(86_400).optional(),
|
|
16
|
+
version: versionParam,
|
|
17
|
+
},
|
|
18
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
19
|
+
const { version, ...body } = args;
|
|
20
|
+
const res = await client.post(siteId, 'preview-link', body, v(version));
|
|
21
|
+
return ok(res);
|
|
22
|
+
}),
|
|
23
|
+
},
|
|
24
|
+
];
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { ok, withErrorBoundary, versionParam } from './helpers.js';
|
|
3
|
+
function v(version) {
|
|
4
|
+
return version ? { version } : undefined;
|
|
5
|
+
}
|
|
6
|
+
export const redirectTools = [
|
|
7
|
+
{
|
|
8
|
+
name: 'list_redirects',
|
|
9
|
+
description: 'List redirect rules (from_path โ to_path, status code).',
|
|
10
|
+
inputSchema: { version: versionParam },
|
|
11
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
12
|
+
const res = await client.get(siteId, 'redirects', v(args.version));
|
|
13
|
+
return ok(res);
|
|
14
|
+
}),
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
name: 'create_redirect',
|
|
18
|
+
description: 'Create a redirect rule. Defaults to 301; pass status_code=302 for a temporary redirect.',
|
|
19
|
+
inputSchema: {
|
|
20
|
+
from_path: z.string().describe('Old path, leading slash (e.g. "/old-about")'),
|
|
21
|
+
to_path: z.string().describe('Target path or absolute URL'),
|
|
22
|
+
status_code: z.union([z.literal(301), z.literal(302)]).optional(),
|
|
23
|
+
version: versionParam,
|
|
24
|
+
},
|
|
25
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
26
|
+
const { version, ...body } = args;
|
|
27
|
+
const res = await client.post(siteId, 'redirects', body, v(version));
|
|
28
|
+
return ok(res);
|
|
29
|
+
}),
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
name: 'delete_redirect',
|
|
33
|
+
description: 'Remove a redirect rule.',
|
|
34
|
+
inputSchema: { redirect_id: z.string(), version: versionParam },
|
|
35
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
36
|
+
const res = await client.del(siteId, `redirects/${encodeURIComponent(args.redirect_id)}`, v(args.version));
|
|
37
|
+
return ok(res);
|
|
38
|
+
}),
|
|
39
|
+
},
|
|
40
|
+
];
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { ok, withErrorBoundary, versionParam } from './helpers.js';
|
|
3
|
+
function v(version) {
|
|
4
|
+
return version ? { version } : undefined;
|
|
5
|
+
}
|
|
6
|
+
export const searchTools = [
|
|
7
|
+
{
|
|
8
|
+
name: 'search_pages',
|
|
9
|
+
description: 'Search page bodies by literal substring or regex. Returns up to 500 matches each with an excerpt around the first hit. Use this to scope a redesign or a bulk replacement before running it.',
|
|
10
|
+
inputSchema: {
|
|
11
|
+
contains: z.string().optional().describe('Case-insensitive literal substring'),
|
|
12
|
+
regex: z.string().optional().describe('JS regex source (without slashes), case-insensitive'),
|
|
13
|
+
limit: z.number().int().min(1).max(500).optional(),
|
|
14
|
+
version: versionParam,
|
|
15
|
+
},
|
|
16
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
17
|
+
const { version, ...query } = args;
|
|
18
|
+
const res = await client.get(siteId, 'search', { ...query, ...v(version) });
|
|
19
|
+
return ok(res);
|
|
20
|
+
}),
|
|
21
|
+
},
|
|
22
|
+
];
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Settings tools. scripts_head, scripts_body_end, and custom_css ARE
|
|
2
|
+
// writable via the public API (as of mcp-server 0.4.x) โ the v1 route
|
|
3
|
+
// accepts them and they round-trip through read_site_settings. Same
|
|
4
|
+
// trust model as user-authored block-type JS: a bearer-token caller
|
|
5
|
+
// takes responsibility for what they ship. The portal chat AI still
|
|
6
|
+
// doesn't expose these fields, so conversation-driven assistants can't
|
|
7
|
+
// smuggle scripts in.
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
import { ok, withErrorBoundary } from './helpers.js';
|
|
10
|
+
export const settingsTools = [
|
|
11
|
+
{
|
|
12
|
+
name: 'read_site_settings',
|
|
13
|
+
description: "Read every site setting: name, tagline, logo, favicon, colors, fonts, contact info, social links, default SEO suffix, language, robots_txt, plus the scriptable surfaces scripts_head, scripts_body_end, and custom_css.",
|
|
14
|
+
handler: withErrorBoundary(async (_args, { client, siteId }) => {
|
|
15
|
+
const res = await client.get(siteId, 'settings');
|
|
16
|
+
return ok(res);
|
|
17
|
+
}),
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
name: 'update_site_settings',
|
|
21
|
+
description: 'Patch site settings. Only the fields you pass change. Nested objects (colors, fonts, contact, social) are shallow-merged. scripts_head / scripts_body_end / custom_css ARE writable here โ useful for a global stylesheet across all pages. Whatever you ship in those fields runs verbatim on the rendered site, so treat them as code, not data.',
|
|
22
|
+
inputSchema: {
|
|
23
|
+
site_name: z.string().optional(),
|
|
24
|
+
tagline: z.string().optional(),
|
|
25
|
+
logo: z.string().optional(),
|
|
26
|
+
favicon: z.string().optional(),
|
|
27
|
+
default_seo_suffix: z.string().optional(),
|
|
28
|
+
language: z.string().optional().describe('BCP-47 tag (e.g. "en", "sv", "en-GB"). Drives <html lang> on the rendered site.'),
|
|
29
|
+
robots_txt: z.string().optional(),
|
|
30
|
+
// Scriptable surfaces. Trusted because the caller has an API key.
|
|
31
|
+
scripts_head: z.string().optional().describe('Raw HTML injected into <head> on every page. Use for analytics, fonts, third-party CSS links.'),
|
|
32
|
+
scripts_body_end: z.string().optional().describe('Raw HTML injected just before </body> on every page. Use for chat widgets, deferred analytics.'),
|
|
33
|
+
custom_css: z.string().optional().describe('Global CSS shipped in <style> at the end of <head>. Lets you define site-wide design tokens (CSS variables, @media queries, :hover states) without inlining on every element.'),
|
|
34
|
+
colors: z.record(z.string()).optional(),
|
|
35
|
+
fonts: z.record(z.unknown()).optional(),
|
|
36
|
+
contact: z
|
|
37
|
+
.object({
|
|
38
|
+
email: z.string().optional(),
|
|
39
|
+
phone: z.string().optional(),
|
|
40
|
+
// address can be a plain string (legacy) OR a structured
|
|
41
|
+
// PostalAddress for rich Schema.org JSON-LD.
|
|
42
|
+
address: z
|
|
43
|
+
.union([
|
|
44
|
+
z.string(),
|
|
45
|
+
z.object({
|
|
46
|
+
street_address: z.string().optional(),
|
|
47
|
+
postal_code: z.string().optional(),
|
|
48
|
+
address_locality: z.string().optional(),
|
|
49
|
+
address_region: z.string().optional(),
|
|
50
|
+
address_country: z.string().optional(),
|
|
51
|
+
}).passthrough(),
|
|
52
|
+
])
|
|
53
|
+
.optional(),
|
|
54
|
+
})
|
|
55
|
+
.passthrough()
|
|
56
|
+
.optional(),
|
|
57
|
+
social: z.record(z.string()).optional(),
|
|
58
|
+
},
|
|
59
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
60
|
+
const res = await client.patch(siteId, 'settings', args);
|
|
61
|
+
return ok(res);
|
|
62
|
+
}),
|
|
63
|
+
},
|
|
64
|
+
];
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Sites tools โ discovery + Site-level identity edits.
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { ok, withErrorBoundary } from './helpers.js';
|
|
4
|
+
export const siteTools = [
|
|
5
|
+
{
|
|
6
|
+
name: 'get_site',
|
|
7
|
+
description: 'Read this site\'s metadata (id, name, slug, domain, active version) + a urls object covering the production / fallback / preview_base URLs. Useful as a first call to confirm the key is wired up and to learn what URLs the site is reachable at.',
|
|
8
|
+
handler: withErrorBoundary(async (_args, { client, siteId }) => {
|
|
9
|
+
const res = await client.get(siteId, '');
|
|
10
|
+
return ok(res);
|
|
11
|
+
}),
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
name: 'get_site_capabilities',
|
|
15
|
+
description: "Discover what the site-template renderer supports for this site. Returns a manifest of feature flags (blocks-mode, x-include, collection 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 collection, 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.",
|
|
16
|
+
handler: withErrorBoundary(async (_args, { client, siteId }) => {
|
|
17
|
+
const res = await client.get(siteId, 'capabilities');
|
|
18
|
+
return ok(res);
|
|
19
|
+
}),
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
name: 'update_site',
|
|
23
|
+
description: 'Edit Site-level identity fields: name (display), slug (drives the {slug}.typeroll-fallback subdomain โ kebab-case, 3-48 chars, unique across the org), domain (the customer\'s real hostname; pass "" to clear). For colors / fonts / contact info / tagline use update_site_settings instead.',
|
|
24
|
+
inputSchema: {
|
|
25
|
+
name: z.string().min(1).optional(),
|
|
26
|
+
slug: z.string().optional().describe('Kebab-case identifier, 3-48 chars [a-z0-9-]. Empty string clears.'),
|
|
27
|
+
domain: z.string().optional().describe('Bare hostname e.g. "example.com". Empty string clears.'),
|
|
28
|
+
language: z.string().optional().describe('Default content language as a BCP-47 tag (e.g. "en", "sv", "en-GB"). Drives <html lang> and the default for alt-text generation. Empty string clears.'),
|
|
29
|
+
},
|
|
30
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
31
|
+
const res = await client.patch(siteId, '', args);
|
|
32
|
+
return ok(res);
|
|
33
|
+
}),
|
|
34
|
+
},
|
|
35
|
+
];
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Version (branch) tools.
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { ok, withErrorBoundary } from './helpers.js';
|
|
4
|
+
export const versionTools = [
|
|
5
|
+
{
|
|
6
|
+
name: 'list_versions',
|
|
7
|
+
description: 'List versions (main + branches).',
|
|
8
|
+
handler: withErrorBoundary(async (_args, { client, siteId }) => {
|
|
9
|
+
const res = await client.get(siteId, 'versions');
|
|
10
|
+
return ok(res);
|
|
11
|
+
}),
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
name: 'create_branch',
|
|
15
|
+
description: 'Create a copy-on-write branch from main (or the version passed in `base`). New branches default to robots_blocked:true. Pass the new branch\'s id as ?version= on subsequent calls to read/write against it.',
|
|
16
|
+
inputSchema: {
|
|
17
|
+
name: z.string().min(1),
|
|
18
|
+
base: z.string().optional().describe('Source version id; defaults to main.'),
|
|
19
|
+
},
|
|
20
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
21
|
+
const res = await client.post(siteId, 'versions', args);
|
|
22
|
+
return ok(res);
|
|
23
|
+
}),
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
name: 'read_version',
|
|
27
|
+
description: 'Read one version\'s metadata.',
|
|
28
|
+
inputSchema: { version_id: z.string() },
|
|
29
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
30
|
+
const res = await client.get(siteId, `versions/${encodeURIComponent(args.version_id)}`);
|
|
31
|
+
return ok(res);
|
|
32
|
+
}),
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: 'delete_branch',
|
|
36
|
+
description: 'Discard a branch (main cannot be deleted).',
|
|
37
|
+
inputSchema: { version_id: z.string() },
|
|
38
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
39
|
+
const res = await client.del(siteId, `versions/${encodeURIComponent(args.version_id)}`);
|
|
40
|
+
return ok(res);
|
|
41
|
+
}),
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: 'merge_branch',
|
|
45
|
+
description: 'Promote a branch\'s overrides + tombstones onto main. The branch is left in place so you can keep iterating; delete_branch removes it when you\'re done.',
|
|
46
|
+
inputSchema: { version_id: z.string() },
|
|
47
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
48
|
+
const res = await client.post(siteId, `versions/${encodeURIComponent(args.version_id)}/merge`);
|
|
49
|
+
return ok(res);
|
|
50
|
+
}),
|
|
51
|
+
},
|
|
52
|
+
];
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@typeroll/mcp-server",
|
|
3
|
+
"version": "0.7.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
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/bootingbots/typeroll.git",
|
|
9
|
+
"directory": "packages/mcp-server"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/bootingbots/typeroll/tree/main/packages/mcp-server#readme",
|
|
12
|
+
"bugs": "https://github.com/bootingbots/typeroll/issues",
|
|
13
|
+
"type": "module",
|
|
14
|
+
"bin": {
|
|
15
|
+
"typeroll-mcp": "./dist/index.js"
|
|
16
|
+
},
|
|
17
|
+
"main": "./dist/index.js",
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"AGENTS.md",
|
|
21
|
+
"README.md",
|
|
22
|
+
"skills"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "tsc -p .",
|
|
26
|
+
"typecheck": "tsc --noEmit",
|
|
27
|
+
"test": "vitest run",
|
|
28
|
+
"test:watch": "vitest"
|
|
29
|
+
},
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=20"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
35
|
+
"zod": "^3.23.0"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"typescript": "^5.6.0",
|
|
39
|
+
"vitest": "^4.1.7"
|
|
40
|
+
},
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
},
|
|
44
|
+
"keywords": [
|
|
45
|
+
"mcp",
|
|
46
|
+
"typeroll",
|
|
47
|
+
"claude",
|
|
48
|
+
"model-context-protocol"
|
|
49
|
+
]
|
|
50
|
+
}
|
package/skills/README.md
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Typeroll skills for Claude Code
|
|
2
|
+
|
|
3
|
+
Boilerplate skills that pair with [`@typeroll/mcp-server`](../packages/mcp-server/README.md).
|
|
4
|
+
Each one is a self-contained markdown file the agent reads when its
|
|
5
|
+
description matches the user's request. Recipes call MCP tools; the agent
|
|
6
|
+
adapts them to the specific job.
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
Copy whichever skills you need into your Claude Code skills directory.
|
|
11
|
+
Either:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
# project-scoped (recommended)
|
|
15
|
+
mkdir -p .claude/skills
|
|
16
|
+
cp skills/*.md .claude/skills/
|
|
17
|
+
|
|
18
|
+
# or user-scoped (available in every project)
|
|
19
|
+
cp skills/*.md ~/.claude/skills/
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Symlinks work too, so you can stay in sync with the upstream:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
ln -s "$PWD/skills/tr-migrate-wp.md" ~/.claude/skills/
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## The skills
|
|
29
|
+
|
|
30
|
+
| File | When it triggers | What it does |
|
|
31
|
+
|---|---|---|
|
|
32
|
+
| `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 |
|
|
33
|
+
| `tr-content-write.md` | "write a page aboutโฆ", "draft copy forโฆ" | Discovery first (settings + sample pages), then drafts in the site's voice, previews, iterates |
|
|
34
|
+
| `tr-images.md` | "make an image / hero / illustration", media uploads | Generates locally โ signed upload URL โ metadata patch โ embed |
|
|
35
|
+
| `tr-directory.md` | Building a directory site, importing structured data | Schema โ items โ per-item URLs via `route_template` โ listing page โ preview โ deploy |
|
|
36
|
+
| `tr-redesign-branch.md`| "redesign", "modernize", anything site-wide-design | Branch-isolated work with preview links, merge when approved |
|
|
37
|
+
|
|
38
|
+
## Prerequisites for every skill
|
|
39
|
+
|
|
40
|
+
1. `@typeroll/mcp-server` configured in `.claude.json` with a valid
|
|
41
|
+
`TYPEROLL_API_KEY` and `TYPEROLL_API_URL`.
|
|
42
|
+
2. The agent has read `AGENTS.md` (ships with the MCP package โ see
|
|
43
|
+
`node_modules/@typeroll/mcp-server/AGENTS.md` after install, or
|
|
44
|
+
reference it directly).
|
|
45
|
+
|
|
46
|
+
If those are missing, every skill will fail at the first MCP call with
|
|
47
|
+
"Missing bearer token" or "Invalid or revoked token".
|
|
48
|
+
|
|
49
|
+
## Authoring more
|
|
50
|
+
|
|
51
|
+
Skills are markdown files that the agent loads on demand. Each one
|
|
52
|
+
should include:
|
|
53
|
+
|
|
54
|
+
- A `description:` frontmatter explaining when to load it (Claude uses
|
|
55
|
+
this to pick the right skill).
|
|
56
|
+
- A clear set of preconditions (what MCP tools must work, what state the
|
|
57
|
+
agent needs to know).
|
|
58
|
+
- A numbered recipe with concrete tool calls.
|
|
59
|
+
- A "Pitfalls" section that captures lessons learned across customer
|
|
60
|
+
jobs.
|
|
61
|
+
|
|
62
|
+
Keep them under ~150 lines so the agent reads them quickly. If a skill
|
|
63
|
+
balloons, split it.
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: tr-content-write
|
|
3
|
+
description: Use when the user asks to write, draft, or rewrite a page on a Typeroll site. Loads the site's design conventions before writing so the new content matches the existing voice and style.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Write a page that fits the site
|
|
7
|
+
|
|
8
|
+
The default failure mode for an AI writing a page is "good generic
|
|
9
|
+
HTML in the wrong voice." This skill makes the discovery step
|
|
10
|
+
non-optional.
|
|
11
|
+
|
|
12
|
+
## Recipe
|
|
13
|
+
|
|
14
|
+
### 1. Always discover first
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
get_site # site name (use it in copy)
|
|
18
|
+
read_site_settings # tagline, contact info, brand colors
|
|
19
|
+
read_partial partial_id="header" # what other pages exist in the nav
|
|
20
|
+
list_pages limit=5
|
|
21
|
+
batch_read_pages page_ids=[<2-3 representative pages>]
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Read the actual HTML of an existing page. Note:
|
|
25
|
+
- Heading structure (single `<h1>` per page? subtitle pattern?)
|
|
26
|
+
- Whether the site uses CSS variables (`var(--color-primary)`) or
|
|
27
|
+
hardcoded values
|
|
28
|
+
- Tone (sober, playful, technical, marketing-y)
|
|
29
|
+
- Length conventions (do existing pages run 200 words or 2000?)
|
|
30
|
+
- Whether internal links use absolute or relative URLs
|
|
31
|
+
|
|
32
|
+
### 2. Ask for the brief
|
|
33
|
+
|
|
34
|
+
If the user hasn't told you, ask:
|
|
35
|
+
|
|
36
|
+
- **Topic + purpose**: what's the page for, who's it for?
|
|
37
|
+
- **Key points**: must-include facts, calls to action
|
|
38
|
+
- **Target length**: short landing vs. long-form
|
|
39
|
+
- **Audience**: anything specific (existing customers, agencies,
|
|
40
|
+
developers)
|
|
41
|
+
- **Reference page**: is there an existing page to match in tone or
|
|
42
|
+
structure?
|
|
43
|
+
|
|
44
|
+
### 3. Draft
|
|
45
|
+
|
|
46
|
+
Write in semantic HTML, matching the site's conventions you observed
|
|
47
|
+
in step 1:
|
|
48
|
+
|
|
49
|
+
- Use `<section>`, `<article>`, `<h1>`/`<h2>`, `<p>`, `<ul>` โ avoid
|
|
50
|
+
div soup.
|
|
51
|
+
- Match the existing site's class naming or CSS variable usage. Don't
|
|
52
|
+
introduce a new design system mid-page.
|
|
53
|
+
- Insert images via `<img src="https://cdn..." alt="...">` โ use
|
|
54
|
+
`list_media` to find existing images first; only generate new ones
|
|
55
|
+
if necessary (see `tr-images` skill).
|
|
56
|
+
- Default status: `draft`. Don't auto-publish unless the user said so.
|
|
57
|
+
|
|
58
|
+
### 4. Create or update
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
# New page:
|
|
62
|
+
create_page title="..." slug="..." html_content="<full body>"
|
|
63
|
+
status="draft" kind="page"
|
|
64
|
+
seo_title="..." seo_description="..."
|
|
65
|
+
|
|
66
|
+
# Or update an existing one:
|
|
67
|
+
update_page page_id=<id> patch={ html_content: "..." }
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
For an existing page, `read_page` first and preserve the existing
|
|
71
|
+
structure โ replace one section at a time rather than rewriting the
|
|
72
|
+
whole body, unless the user explicitly asked for a full redo.
|
|
73
|
+
|
|
74
|
+
### 5. Preview + iterate
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
get_preview_link page_id=<id>
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Show the URL to the user. Iterate on feedback. Common rounds:
|
|
81
|
+
shortening, adding a CTA, tweaking SEO description.
|
|
82
|
+
|
|
83
|
+
### 6. Status change is the user's call
|
|
84
|
+
|
|
85
|
+
Don't `update_page status:"published"` without an explicit "looks
|
|
86
|
+
good, publish it" from the user. Same for `trigger_deploy`.
|
|
87
|
+
|
|
88
|
+
## SEO conventions worth knowing
|
|
89
|
+
|
|
90
|
+
- **`kind: "article"`** for blog posts and news. Switches to
|
|
91
|
+
`og:type=article` + emits Article JSON-LD. Set `author` too โ empty
|
|
92
|
+
author = no Person schema = no author rich-result eligibility.
|
|
93
|
+
- **SEO title** target 50-60 chars. Past 60 Google truncates.
|
|
94
|
+
- **Meta description** target 150-160 chars. Don't write fluff to
|
|
95
|
+
fill it; Google rewrites descriptions when they go off-topic.
|
|
96
|
+
- **OG image** per page matters for shareable content. For articles
|
|
97
|
+
especially.
|
|
98
|
+
|
|
99
|
+
## Pitfalls
|
|
100
|
+
|
|
101
|
+
- Reading 0 pages and just inventing a design is the most common
|
|
102
|
+
failure. Always sample at least one existing page first.
|
|
103
|
+
- Skipping the brief and producing 1000 words of plausible filler when
|
|
104
|
+
the user wanted a 200-word landing. Ask up front.
|
|
105
|
+
- Auto-publishing. Don't.
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: tr-directory
|
|
3
|
+
description: Use when the user wants to build a directory site or import a structured dataset (restaurants, products, events, agencies, etc.) where each item should have its own URL. Covers collection schema creation, per-item URLs via route_template, listing page, deploy.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Build a directory site from external data
|
|
7
|
+
|
|
8
|
+
Typeroll collections support per-item URLs: every published item
|
|
9
|
+
in a collection with a `route_template` materialises as its own static
|
|
10
|
+
page at build time. This is the right pattern when you have hundreds
|
|
11
|
+
of similar entities (restaurants, products, listings, profiles).
|
|
12
|
+
|
|
13
|
+
## Big-picture flow
|
|
14
|
+
|
|
15
|
+
1. **Data source** โ 2. **Collection schema** โ 3. **Items** โ 4. **Listing page**
|
|
16
|
+
โ 5. **Preview** โ 6. **Deploy**
|
|
17
|
+
|
|
18
|
+
You drive everything from Claude Code locally โ the scrape, the data
|
|
19
|
+
shaping, the writes. The MCP just receives the final shape.
|
|
20
|
+
|
|
21
|
+
## Recipe
|
|
22
|
+
|
|
23
|
+
### 1. Get the data
|
|
24
|
+
|
|
25
|
+
Whatever source the user has โ scraped CSV, public API, vendor feed,
|
|
26
|
+
manual research, another LLM's output. Normalise to a flat shape:
|
|
27
|
+
one object per item with stable, kebab-case field names.
|
|
28
|
+
|
|
29
|
+
```jsonc
|
|
30
|
+
[
|
|
31
|
+
{
|
|
32
|
+
"title": "Joe's Pizza",
|
|
33
|
+
"slug": "joes-pizza",
|
|
34
|
+
"address": "123 Main St, Anytown",
|
|
35
|
+
"phone": "+1-555-0100",
|
|
36
|
+
"cuisine": "italian",
|
|
37
|
+
"rating": 4.5,
|
|
38
|
+
"image": "https://...", // optional: a hosted image URL
|
|
39
|
+
"excerpt": "Family-run since 1987...",
|
|
40
|
+
"body": "<p>Long-form description with HTML.</p>"
|
|
41
|
+
}
|
|
42
|
+
]
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The `slug` field is what populates `route_template`. Make it
|
|
46
|
+
kebab-case, unique within the dataset. If the source doesn't have one,
|
|
47
|
+
derive from `title`: lowercase, replace non-alphanumeric with `-`,
|
|
48
|
+
collapse consecutive dashes.
|
|
49
|
+
|
|
50
|
+
### 2. Decide the URL structure with the user
|
|
51
|
+
|
|
52
|
+
Common patterns:
|
|
53
|
+
|
|
54
|
+
- `/restaurants/{slug}` (default โ simple, predictable)
|
|
55
|
+
- `/r/{slug}` (compact)
|
|
56
|
+
- `/{cuisine}/{slug}` (categorised)
|
|
57
|
+
- `/dir/{slug}` (short prefix to avoid collisions with page slugs)
|
|
58
|
+
|
|
59
|
+
Pick one before creating the collection โ changing `route_template`
|
|
60
|
+
later renames every URL and requires redirect rules.
|
|
61
|
+
|
|
62
|
+
### 3. Create the collection schema
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
create_collection
|
|
66
|
+
name="restaurants"
|
|
67
|
+
label_singular="Restaurant"
|
|
68
|
+
label_plural="Restaurants"
|
|
69
|
+
icon="๐"
|
|
70
|
+
fields=[
|
|
71
|
+
{"name":"title","label":"Name","type":"text","required":true},
|
|
72
|
+
{"name":"slug","label":"Slug","type":"text","required":true},
|
|
73
|
+
{"name":"address","label":"Address","type":"text"},
|
|
74
|
+
{"name":"phone","label":"Phone","type":"text"},
|
|
75
|
+
{"name":"cuisine","label":"Cuisine","type":"text"},
|
|
76
|
+
{"name":"rating","label":"Rating","type":"number"},
|
|
77
|
+
{"name":"image","label":"Image URL","type":"text"},
|
|
78
|
+
{"name":"excerpt","label":"Excerpt","type":"textarea"},
|
|
79
|
+
{"name":"body","label":"Body","type":"richtext"}
|
|
80
|
+
]
|
|
81
|
+
slug_field="slug"
|
|
82
|
+
sort_field="title"
|
|
83
|
+
sort_dir="asc"
|
|
84
|
+
route_template="/restaurants/{slug}"
|
|
85
|
+
item_template_html="<article class=\"directory-item\">
|
|
86
|
+
<header>
|
|
87
|
+
<h1>{{title}}</h1>
|
|
88
|
+
{{cuisine}} ยท โญ {{rating}}
|
|
89
|
+
</header>
|
|
90
|
+
<img src=\"{{image}}\" alt=\"{{title}}\" />
|
|
91
|
+
<address>{{address}} ยท <a href=\"tel:{{phone}}\">{{phone}}</a></address>
|
|
92
|
+
<section class=\"description\">{{{body}}}</section>
|
|
93
|
+
</article>"
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The `item_template_html` is what renders for each item. `{{field}}`
|
|
97
|
+
HTML-escapes; `{{{field}}}` leaves raw (use for richtext bodies that
|
|
98
|
+
intentionally carry HTML).
|
|
99
|
+
|
|
100
|
+
### 4. Bulk-import items
|
|
101
|
+
|
|
102
|
+
Loop over your data array. For each item:
|
|
103
|
+
|
|
104
|
+
```
|
|
105
|
+
create_collection_item
|
|
106
|
+
collection="restaurants"
|
|
107
|
+
fields={ title:"Joe's Pizza", slug:"joes-pizza", ... }
|
|
108
|
+
status="published"
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
For larger datasets (1000+), batch outside the MCP โ spawn 5 parallel
|
|
112
|
+
`create_collection_item` calls at a time, watch the 60-writes/min rate
|
|
113
|
+
limit (you'll hit it on big imports, the API returns 429 with
|
|
114
|
+
`Retry-After`).
|
|
115
|
+
|
|
116
|
+
Set `status: "draft"` for items the user still needs to review; only
|
|
117
|
+
published items get static pages.
|
|
118
|
+
|
|
119
|
+
### 5. Build a listing page
|
|
120
|
+
|
|
121
|
+
Items have per-item URLs but not a default index. Create one with a
|
|
122
|
+
marker pair that `regenerate_collection_listing` will keep up to date:
|
|
123
|
+
|
|
124
|
+
```
|
|
125
|
+
create_page
|
|
126
|
+
title="Restaurants"
|
|
127
|
+
slug="restaurants"
|
|
128
|
+
status="published"
|
|
129
|
+
html_content="<h1>All restaurants</h1>
|
|
130
|
+
<!-- typeroll:listing:restaurants -->
|
|
131
|
+
<!-- /typeroll:listing:restaurants -->
|
|
132
|
+
"
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Then populate (and refresh whenever items change) with one call:
|
|
136
|
+
|
|
137
|
+
```
|
|
138
|
+
regenerate_collection_listing
|
|
139
|
+
collection="restaurants"
|
|
140
|
+
page_id="restaurants"
|
|
141
|
+
item_template="<article class=\"directory-card\">
|
|
142
|
+
<h2><a href=\"{{url}}\">{{title}}</a></h2>
|
|
143
|
+
<p>{{cuisine}} ยท โญ {{rating}}</p>
|
|
144
|
+
<p>{{address}}</p>
|
|
145
|
+
</article>"
|
|
146
|
+
wrap_open="<div class=\"directory-grid\">"
|
|
147
|
+
wrap_close="</div>"
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
`{{field}}` substitutes HTML-escaped, `{{{field}}}` raw (for richtext
|
|
151
|
+
fields), `{{url}}` resolves through the collection's `route_template`.
|
|
152
|
+
The tool replaces only what's between the marker pair โ anything before
|
|
153
|
+
or after the markers stays put.
|
|
154
|
+
|
|
155
|
+
When the customer adds a new restaurant later, the agent re-runs the
|
|
156
|
+
same `regenerate_collection_listing` call and the index updates. No
|
|
157
|
+
diff-the-HTML-by-hand, no stale listings.
|
|
158
|
+
|
|
159
|
+
(When the block editor lands, you'll be able to drop in a "collection
|
|
160
|
+
listing" block instead of hand-writing this. For now, raw HTML.)
|
|
161
|
+
|
|
162
|
+
### 6. Preview an item
|
|
163
|
+
|
|
164
|
+
```
|
|
165
|
+
get_preview_link collection_name="restaurants" item_id="<id>"
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Returns a URL the user can open. Internal links inside the preview
|
|
169
|
+
stay inside the preview surface, so navigating to another item works.
|
|
170
|
+
|
|
171
|
+
### 7. Deploy
|
|
172
|
+
|
|
173
|
+
```
|
|
174
|
+
trigger_deploy
|
|
175
|
+
get_deploy_status job_id=<id>
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Each published item gets its own URL in the static build, with
|
|
179
|
+
`sitemap.xml` automatically including them all.
|
|
180
|
+
|
|
181
|
+
## Pitfalls
|
|
182
|
+
|
|
183
|
+
- **Slugs must be unique within the collection** โ duplicates cause
|
|
184
|
+
build failures (two pages claiming the same URL). De-dupe before
|
|
185
|
+
importing.
|
|
186
|
+
- **Don't reuse `slug` across collections without thinking.**
|
|
187
|
+
`/restaurants/joes` and `/products/joes` are fine; just avoid
|
|
188
|
+
`/joes` for both (collection items vs. pages don't collide because
|
|
189
|
+
pages always win, but two collections sharing a `slug_field=slug`
|
|
190
|
+
with the same `route_template` is a foot-gun).
|
|
191
|
+
- **Required fields.** `route_template="/restaurants/{slug}"` will
|
|
192
|
+
silently skip items where `slug` is missing. Check
|
|
193
|
+
`list_collection_items` after import โ if you imported 500 and the
|
|
194
|
+
listing only shows 480, look at the dropped 20's source data.
|
|
195
|
+
- **Template too clever.** Substitution is plain `{{field}}` โ no
|
|
196
|
+
loops, no conditionals. If your design needs more, prefer flat
|
|
197
|
+
fields (`star_html`, `rating_label`) prebuilt in the data step.
|
|
198
|
+
- **Field type changes drift data.** Adding a new field after import
|
|
199
|
+
is fine; renaming one orphans the old data on every item. Plan the
|
|
200
|
+
schema before import.
|
|
201
|
+
|
|
202
|
+
## Mixing scraped + generated content
|
|
203
|
+
|
|
204
|
+
The whole point of the local-agent model: you can blend sources.
|
|
205
|
+
|
|
206
|
+
- Scrape addresses + phone from a yellow-pages site.
|
|
207
|
+
- Generate excerpt + body from a local Claude pass over the raw
|
|
208
|
+
scraped HTML.
|
|
209
|
+
- Generate hero images per item via `tr-images`.
|
|
210
|
+
- All three merged into one `create_collection_item` per record.
|
|
211
|
+
|
|
212
|
+
Keep a local manifest (`./directory-state.json`) of what's been
|
|
213
|
+
imported so a partial run is resumable. The MCP doesn't track that
|
|
214
|
+
state โ your local script does.
|