@typeroll/mcp-server 0.7.4 → 0.7.7
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/dist/index.js +16 -1
- package/dist/install-skills.js +130 -0
- package/dist/tools/pages.js +22 -6
- package/dist/tools/partials.js +9 -4
- package/dist/tools/search.js +6 -3
- package/dist/tools/settings.js +6 -1
- package/package.json +3 -3
- package/skills/README.md +23 -5
- package/skills/tr-blog.md +177 -0
- package/skills/tr-brand.md +169 -0
- package/skills/tr-forms.md +243 -0
- package/skills/tr-import-url.md +173 -0
- package/skills/tr-new-site.md +198 -0
- package/skills/tr-seo.md +179 -0
package/dist/index.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
18
18
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
19
19
|
import { TyperollClient } from './client.js';
|
|
20
|
+
import { runInstallSkillsCli } from './install-skills.js';
|
|
20
21
|
import { pageTools } from './tools/pages.js';
|
|
21
22
|
import { partialTools } from './tools/partials.js';
|
|
22
23
|
import { collectionTools } from './tools/collections.js';
|
|
@@ -32,7 +33,7 @@ import { blockTypeTools } from './tools/block-types.js';
|
|
|
32
33
|
import { pageBlockTools } from './tools/page-blocks.js';
|
|
33
34
|
import { settingsTools } from './tools/settings.js';
|
|
34
35
|
import { siteTools } from './tools/sites.js';
|
|
35
|
-
const VERSION = '0.
|
|
36
|
+
const VERSION = '0.7.7';
|
|
36
37
|
async function resolveSiteId(client) {
|
|
37
38
|
const fromEnv = process.env.TYPEROLL_SITE_ID?.trim();
|
|
38
39
|
if (fromEnv)
|
|
@@ -53,6 +54,20 @@ function bail(message) {
|
|
|
53
54
|
process.exit(1);
|
|
54
55
|
}
|
|
55
56
|
async function main() {
|
|
57
|
+
// Subcommand dispatch. These run without any of the MCP-server env-var
|
|
58
|
+
// validation below, since they don't talk to the portal.
|
|
59
|
+
const argv = process.argv.slice(2);
|
|
60
|
+
if (argv[0] === 'install-skills') {
|
|
61
|
+
const code = await runInstallSkillsCli(argv.slice(1));
|
|
62
|
+
process.exit(code);
|
|
63
|
+
}
|
|
64
|
+
if (argv[0] === '--help' || argv[0] === '-h' || argv[0] === 'help') {
|
|
65
|
+
console.error('Usage:');
|
|
66
|
+
console.error(' typeroll-mcp Start the MCP server (reads TYPEROLL_API_URL and TYPEROLL_API_KEY)');
|
|
67
|
+
console.error(' typeroll-mcp install-skills <dir> [-f] Copy bundled skill files to <dir>');
|
|
68
|
+
console.error(' typeroll-mcp --help Show this help');
|
|
69
|
+
process.exit(0);
|
|
70
|
+
}
|
|
56
71
|
const apiUrl = process.env.TYPEROLL_API_URL?.trim();
|
|
57
72
|
const apiKey = process.env.TYPEROLL_API_KEY?.trim();
|
|
58
73
|
if (!apiUrl)
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// install-skills subcommand for the typeroll-mcp CLI.
|
|
2
|
+
//
|
|
3
|
+
// Copies the bundled skill markdown files from this package's `skills/`
|
|
4
|
+
// directory into a destination directory the user provides. Used to seed
|
|
5
|
+
// `.claude/skills/` (project scope) or `~/.claude/skills/` (user scope) so
|
|
6
|
+
// Claude Code picks up Typeroll-specific skill recipes.
|
|
7
|
+
//
|
|
8
|
+
// This subcommand does NOT need TYPEROLL_API_URL / TYPEROLL_API_KEY — it's a
|
|
9
|
+
// pure file copy. Putting the dispatch in index.ts before the env-var
|
|
10
|
+
// validation is the only thing that matters for that.
|
|
11
|
+
import { promises as fs } from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
// Resolve the bundled skills directory. At runtime this file lives at
|
|
15
|
+
// <package>/dist/install-skills.js (after tsc); skills/ is a sibling of dist/.
|
|
16
|
+
// fileURLToPath gives us a real OS path that works on every platform.
|
|
17
|
+
function skillsDir() {
|
|
18
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
return path.resolve(here, '..', 'skills');
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Copy all `tr-*.md` files from the bundled skills directory to `dest`.
|
|
23
|
+
* Returns a summary so callers can render output. Throws on filesystem errors
|
|
24
|
+
* the caller didn't ask for.
|
|
25
|
+
*/
|
|
26
|
+
export async function installSkills(opts) {
|
|
27
|
+
const source = opts.sourceDir ?? skillsDir();
|
|
28
|
+
const force = opts.force ?? false;
|
|
29
|
+
// Validate the source dir exists. If this fails the package install is
|
|
30
|
+
// broken (skills/ missing from the tarball) — surface clearly.
|
|
31
|
+
try {
|
|
32
|
+
const stat = await fs.stat(source);
|
|
33
|
+
if (!stat.isDirectory()) {
|
|
34
|
+
throw new Error(`bundled skills path is not a directory: ${source}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
catch (e) {
|
|
38
|
+
const reason = e instanceof Error ? e.message : String(e);
|
|
39
|
+
throw new Error(`could not find bundled skills directory at ${source}: ${reason}`);
|
|
40
|
+
}
|
|
41
|
+
// Resolve destination. Create it if missing (mkdir -p semantics).
|
|
42
|
+
const dest = path.resolve(opts.dest);
|
|
43
|
+
await fs.mkdir(dest, { recursive: true });
|
|
44
|
+
// Only copy tr-*.md — README.md in skills/ is package-internal, not a skill.
|
|
45
|
+
const entries = await fs.readdir(source);
|
|
46
|
+
const skillFiles = entries.filter((f) => f.startsWith('tr-') && f.endsWith('.md')).sort();
|
|
47
|
+
const copied = [];
|
|
48
|
+
const skipped = [];
|
|
49
|
+
for (const file of skillFiles) {
|
|
50
|
+
const target = path.join(dest, file);
|
|
51
|
+
if (!force) {
|
|
52
|
+
try {
|
|
53
|
+
await fs.access(target);
|
|
54
|
+
skipped.push(file);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// File doesn't exist — proceed to copy.
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
await fs.copyFile(path.join(source, file), target);
|
|
62
|
+
copied.push(file);
|
|
63
|
+
}
|
|
64
|
+
return { destination: dest, copied, skipped };
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* CLI entry point — parses argv (after the subcommand token has been
|
|
68
|
+
* removed) and prints human-readable output. Returns exit code.
|
|
69
|
+
*/
|
|
70
|
+
export async function runInstallSkillsCli(args) {
|
|
71
|
+
let dest;
|
|
72
|
+
let force = false;
|
|
73
|
+
for (const arg of args) {
|
|
74
|
+
if (arg === '--force' || arg === '-f') {
|
|
75
|
+
force = true;
|
|
76
|
+
}
|
|
77
|
+
else if (arg === '--help' || arg === '-h') {
|
|
78
|
+
printHelp();
|
|
79
|
+
return 0;
|
|
80
|
+
}
|
|
81
|
+
else if (!dest && !arg.startsWith('-')) {
|
|
82
|
+
dest = arg;
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
console.error(`typeroll-mcp install-skills: unrecognised argument: ${arg}`);
|
|
86
|
+
printHelp();
|
|
87
|
+
return 1;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (!dest) {
|
|
91
|
+
console.error('typeroll-mcp install-skills: missing destination directory.');
|
|
92
|
+
printHelp();
|
|
93
|
+
return 1;
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
const result = await installSkills({ dest, force });
|
|
97
|
+
const total = result.copied.length + result.skipped.length;
|
|
98
|
+
console.log(`typeroll-mcp: installed skills to ${result.destination}`);
|
|
99
|
+
console.log(` copied: ${result.copied.length}/${total}`);
|
|
100
|
+
if (result.skipped.length > 0) {
|
|
101
|
+
console.log(` skipped: ${result.skipped.length} (already exist; rerun with --force to overwrite)`);
|
|
102
|
+
for (const f of result.skipped)
|
|
103
|
+
console.log(` - ${f}`);
|
|
104
|
+
}
|
|
105
|
+
if (result.copied.length > 0) {
|
|
106
|
+
for (const f of result.copied)
|
|
107
|
+
console.log(` + ${f}`);
|
|
108
|
+
}
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
111
|
+
catch (e) {
|
|
112
|
+
const reason = e instanceof Error ? e.message : String(e);
|
|
113
|
+
console.error(`typeroll-mcp install-skills: ${reason}`);
|
|
114
|
+
return 1;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function printHelp() {
|
|
118
|
+
console.error('');
|
|
119
|
+
console.error('Usage: npx @typeroll/mcp-server install-skills <destination> [--force]');
|
|
120
|
+
console.error('');
|
|
121
|
+
console.error('Copies bundled Typeroll skill markdown files to the destination directory.');
|
|
122
|
+
console.error('');
|
|
123
|
+
console.error('Common destinations:');
|
|
124
|
+
console.error(' .claude/skills Project-scoped (recommended)');
|
|
125
|
+
console.error(' ~/.claude/skills User-scoped (available in every project)');
|
|
126
|
+
console.error('');
|
|
127
|
+
console.error('Options:');
|
|
128
|
+
console.error(' --force, -f Overwrite files that already exist');
|
|
129
|
+
console.error(' --help, -h Show this help');
|
|
130
|
+
}
|
package/dist/tools/pages.js
CHANGED
|
@@ -7,11 +7,12 @@ function v(version) {
|
|
|
7
7
|
export const pageTools = [
|
|
8
8
|
{
|
|
9
9
|
name: 'list_pages',
|
|
10
|
-
description: 'List pages on the active site. Returns id, title, slug, status, and SEO summary. Supports filtering by status and forward-cursor pagination.',
|
|
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
13
|
limit: z.number().int().min(1).max(200).optional(),
|
|
14
14
|
cursor: z.string().optional(),
|
|
15
|
+
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.'),
|
|
15
16
|
version: versionParam,
|
|
16
17
|
},
|
|
17
18
|
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
@@ -19,6 +20,7 @@ export const pageTools = [
|
|
|
19
20
|
status: args.status,
|
|
20
21
|
limit: args.limit,
|
|
21
22
|
cursor: args.cursor,
|
|
23
|
+
full: args.full ? 'true' : undefined,
|
|
22
24
|
...v(args.version),
|
|
23
25
|
});
|
|
24
26
|
return ok(res);
|
|
@@ -38,23 +40,37 @@ export const pageTools = [
|
|
|
38
40
|
},
|
|
39
41
|
{
|
|
40
42
|
name: 'batch_read_pages',
|
|
41
|
-
description: 'Read up to 200 pages in a single call. Use to bulk-load context before a redesign sweep.',
|
|
43
|
+
description: 'Read up to 200 pages in a single call. Returns pages_by_id — a map keyed by page_id for easy lookup — plus a not_found list. Use to bulk-load context before a redesign sweep.',
|
|
42
44
|
inputSchema: {
|
|
43
45
|
page_ids: z.array(z.string()).min(1).max(200),
|
|
44
46
|
version: versionParam,
|
|
45
47
|
},
|
|
46
48
|
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
47
49
|
const res = await client.post(siteId, 'pages/batch-read', { page_ids: args.page_ids }, v(args.version));
|
|
48
|
-
|
|
50
|
+
// Transform array → map keyed by page_id for ergonomic access.
|
|
51
|
+
const pages_by_id = {};
|
|
52
|
+
const not_found = [];
|
|
53
|
+
for (const entry of res.pages ?? []) {
|
|
54
|
+
if (entry.found && entry.page)
|
|
55
|
+
pages_by_id[entry.page_id] = entry.page;
|
|
56
|
+
else
|
|
57
|
+
not_found.push(entry.page_id);
|
|
58
|
+
}
|
|
59
|
+
return ok({ pages_by_id, not_found });
|
|
49
60
|
}),
|
|
50
61
|
},
|
|
51
62
|
{
|
|
52
63
|
name: 'create_page',
|
|
53
|
-
description: 'Create a new page. Defaults to
|
|
64
|
+
description: 'Create a new page. Defaults to html mode — pass html_content to set the body. ' +
|
|
65
|
+
'Slug is derived from the title when omitted. Default status is "draft". ' +
|
|
66
|
+
'Homepage convention: pass slug="" (empty string) or omit slug and set title to "Home"; ' +
|
|
67
|
+
'the server stores it under id "home" with slug "". ' +
|
|
68
|
+
'Do NOT pass slug="/"; strip leading slashes from slugs (use "about", not "/about"). ' +
|
|
69
|
+
'Returns the created page including html_content.',
|
|
54
70
|
inputSchema: {
|
|
55
71
|
title: z.string().min(1),
|
|
56
|
-
slug: z.string().optional(),
|
|
57
|
-
content_mode: z.enum(['blocks', 'html']).optional().describe('Default "
|
|
72
|
+
slug: z.string().optional().describe('URL slug without leading slash (e.g. "about", "services/design"). Empty string "" = homepage.'),
|
|
73
|
+
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).'),
|
|
58
74
|
html_content: z.string().optional().describe('Body HTML — only used when content_mode="html".'),
|
|
59
75
|
blocks: z.array(z.any()).optional().describe('Block tree — only used when content_mode="blocks". Omit to get the default heading+prose seed.'),
|
|
60
76
|
status: z.enum(['draft', 'review', 'unlisted', 'published']).optional(),
|
package/dist/tools/partials.js
CHANGED
|
@@ -54,14 +54,19 @@ export const partialTools = [
|
|
|
54
54
|
},
|
|
55
55
|
{
|
|
56
56
|
name: 'replace_partial',
|
|
57
|
-
description: 'Full replace of a global block (PUT).
|
|
57
|
+
description: 'Full replace of a global block (PUT). Pass html_content (and optionally name/status) directly — no wrapper object needed. ' +
|
|
58
|
+
'kind is auto-inferred from partial_id ("header" → header, "footer" → footer, anything else → free). ' +
|
|
59
|
+
'For incremental edits (changing one field without replacing the whole block) use update_partial instead.',
|
|
58
60
|
inputSchema: {
|
|
59
|
-
partial_id: z.string(),
|
|
60
|
-
|
|
61
|
+
partial_id: z.string().describe('"header", "footer", or a free-block kebab-id.'),
|
|
62
|
+
html_content: z.string().describe('Full HTML for the block. Replaces any existing content.'),
|
|
63
|
+
name: z.string().optional().describe('Human-readable label shown in the UI.'),
|
|
64
|
+
status: z.enum(['draft', 'published']).optional(),
|
|
61
65
|
version: versionParam,
|
|
62
66
|
},
|
|
63
67
|
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
64
|
-
const
|
|
68
|
+
const { version, partial_id, ...fields } = args;
|
|
69
|
+
const res = await client.put(siteId, `partials/${encodeURIComponent(partial_id)}`, fields, v(version));
|
|
65
70
|
return ok(res);
|
|
66
71
|
}),
|
|
67
72
|
},
|
package/dist/tools/search.js
CHANGED
|
@@ -6,10 +6,13 @@ function v(version) {
|
|
|
6
6
|
export const searchTools = [
|
|
7
7
|
{
|
|
8
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.'
|
|
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
|
+
'Pass either "contains" (case-insensitive literal) or "regex" (JS regex source without slashes) — not both. ' +
|
|
11
|
+
'Example: search_pages({"contains": "kontakta oss"}) finds every page mentioning that phrase. ' +
|
|
12
|
+
'Example: search_pages({"regex": "\\\\d{3}-\\\\d{3}"}) finds pages with phone-number patterns.',
|
|
10
13
|
inputSchema: {
|
|
11
|
-
contains: z.string().optional().describe('Case-insensitive literal substring'),
|
|
12
|
-
regex: z.string().optional().describe('JS regex source
|
|
14
|
+
contains: z.string().optional().describe('Case-insensitive literal substring. Example: "kontakta oss"'),
|
|
15
|
+
regex: z.string().optional().describe('JS regex source without surrounding slashes, case-insensitive. Example: "\\\\d{3}-\\\\d{3}" matches phone patterns.'),
|
|
13
16
|
limit: z.number().int().min(1).max(500).optional(),
|
|
14
17
|
version: versionParam,
|
|
15
18
|
},
|
package/dist/tools/settings.js
CHANGED
|
@@ -18,7 +18,12 @@ export const settingsTools = [
|
|
|
18
18
|
},
|
|
19
19
|
{
|
|
20
20
|
name: 'update_site_settings',
|
|
21
|
-
description: 'Patch site settings. Only the fields you pass change.
|
|
21
|
+
description: 'Patch site settings. Only the fields you pass change. ' +
|
|
22
|
+
'Pass fields at the TOP LEVEL of the object — do NOT wrap in a "settings" key. ' +
|
|
23
|
+
'Example: {"site_name": "Acme", "colors": {"primary": "#ff0"}} not {"settings": {...}}. ' +
|
|
24
|
+
'Nested objects (colors, fonts, contact, social) are shallow-merged into the existing value. ' +
|
|
25
|
+
'Unknown top-level keys return a 400 error listing the valid fields. ' +
|
|
26
|
+
'scripts_head / scripts_body_end / custom_css ARE writable here — useful for a global stylesheet across all pages.',
|
|
22
27
|
inputSchema: {
|
|
23
28
|
site_name: z.string().optional(),
|
|
24
29
|
tagline: z.string().optional(),
|
package/package.json
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@typeroll/mcp-server",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.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
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
8
|
-
"url": "https://github.com/bootingbots/typeroll.git",
|
|
8
|
+
"url": "git+https://github.com/bootingbots/typeroll.git",
|
|
9
9
|
"directory": "packages/mcp-server"
|
|
10
10
|
},
|
|
11
11
|
"homepage": "https://github.com/bootingbots/typeroll/tree/main/packages/mcp-server#readme",
|
|
12
12
|
"bugs": "https://github.com/bootingbots/typeroll/issues",
|
|
13
13
|
"type": "module",
|
|
14
14
|
"bin": {
|
|
15
|
-
"typeroll-mcp": "
|
|
15
|
+
"typeroll-mcp": "dist/index.js"
|
|
16
16
|
},
|
|
17
17
|
"main": "./dist/index.js",
|
|
18
18
|
"files": [
|
package/skills/README.md
CHANGED
|
@@ -27,13 +27,31 @@ ln -s "$PWD/skills/tr-migrate-wp.md" ~/.claude/skills/
|
|
|
27
27
|
|
|
28
28
|
## The skills
|
|
29
29
|
|
|
30
|
+
### Bygga och designa
|
|
31
|
+
|
|
32
|
+
| File | When it triggers | What it does |
|
|
33
|
+
|---|---|---|
|
|
34
|
+
| `tr-new-site.md` | "create a new site", "bootstrap a site for…" | Settings → header/footer partials → homepage → inner pages → deploy. Full setup recipe. |
|
|
35
|
+
| `tr-brand.md` | "create a brand", "choose colors", "design the look"| Palette recipes by mood, typography pairings, CSS variable setup, preview. |
|
|
36
|
+
| `tr-redesign-branch.md`| "redesign", "modernize", anything site-wide-design | Branch-isolated work with preview links, merge when approved. |
|
|
37
|
+
| `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. |
|
|
38
|
+
| `tr-images.md` | "make an image / hero / illustration", media uploads | Generates locally → signed upload URL → metadata patch → embed. |
|
|
39
|
+
|
|
40
|
+
### Funktioner
|
|
41
|
+
|
|
42
|
+
| File | When it triggers | What it does |
|
|
43
|
+
|---|---|---|
|
|
44
|
+
| `tr-blog.md` | "add a blog", "set up news", "article section" | Collection schema → seed items → listing page → per-article pages → nav update → deploy. |
|
|
45
|
+
| `tr-forms.md` | "contact form", "add a form", "booking form" | Form definition → embed HTML with signed token → inline JS feedback → deploy. |
|
|
46
|
+
| `tr-directory.md` | Building a directory site, importing structured data | Schema → items → per-item URLs via `route_template` → listing page → preview → deploy. |
|
|
47
|
+
| `tr-seo.md` | "SEO", "meta descriptions", "structured data" | Audit → fix titles/descriptions → OG images → JSON-LD → robots.txt → deploy. |
|
|
48
|
+
|
|
49
|
+
### Importera innehåll
|
|
50
|
+
|
|
30
51
|
| File | When it triggers | What it does |
|
|
31
52
|
|---|---|---|
|
|
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-
|
|
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 |
|
|
53
|
+
| `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. |
|
|
54
|
+
| `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. |
|
|
37
55
|
|
|
38
56
|
## Prerequisites for every skill
|
|
39
57
|
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: tr-blog
|
|
3
|
+
description: Use when the user wants to set up a blog, news section, or article feed on a Typeroll site. Triggers on "add a blog", "set up news", "article section", "create posts", "inlägg", "nyheter", or when the user wants to manage a list of dated articles.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Set up a blog / news section
|
|
7
|
+
|
|
8
|
+
Typeroll doesn't have a built-in blog — instead the AI writes listing HTML
|
|
9
|
+
directly into a page, sourced from a collection. This skill wires the whole
|
|
10
|
+
flow: collection schema → seed items → listing page → individual item URLs
|
|
11
|
+
via `route_template` → deploy.
|
|
12
|
+
|
|
13
|
+
## Preconditions
|
|
14
|
+
|
|
15
|
+
- Site exists with working header/footer.
|
|
16
|
+
- You know the desired collection name (e.g. `blog`, `news`, `artiklar`).
|
|
17
|
+
|
|
18
|
+
## Recipe
|
|
19
|
+
|
|
20
|
+
### 1. Create the collection
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
create_collection {
|
|
24
|
+
"name": "blog",
|
|
25
|
+
"label_singular": "Artikel",
|
|
26
|
+
"label_plural": "Artiklar",
|
|
27
|
+
"slug_field": "slug",
|
|
28
|
+
"sort_field": "date",
|
|
29
|
+
"sort_dir": "desc",
|
|
30
|
+
"route_template": "/blog/{slug}",
|
|
31
|
+
"fields": [
|
|
32
|
+
{"name": "title", "type": "text", "label": "Rubrik", "required": true},
|
|
33
|
+
{"name": "slug", "type": "text", "label": "URL-slug", "required": true},
|
|
34
|
+
{"name": "date", "type": "date", "label": "Datum", "required": true},
|
|
35
|
+
{"name": "author", "type": "text", "label": "Författare"},
|
|
36
|
+
{"name": "excerpt", "type": "textarea", "label": "Ingress"},
|
|
37
|
+
{"name": "body", "type": "richtext", "label": "Brödtext"},
|
|
38
|
+
{"name": "image", "type": "image", "label": "Omslagsbild"},
|
|
39
|
+
{"name": "tags", "type": "text", "label": "Taggar (kommaseparerade)"}
|
|
40
|
+
]
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
**Field name rule:** ASCII only, lowercase. `ä→a`, `ö→o`, `å→a`.
|
|
45
|
+
|
|
46
|
+
### 2. Seed with real content
|
|
47
|
+
|
|
48
|
+
Add 2–3 initial articles. Use `create_collection_item`:
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
create_collection_item collection="blog" fields={
|
|
52
|
+
"title": "Vår designfilosofi",
|
|
53
|
+
"slug": "var-designfilosofi",
|
|
54
|
+
"date": "2025-05-15",
|
|
55
|
+
"author": "Anna Lindström",
|
|
56
|
+
"excerpt": "Vi tror på enkelhet med syfte — varje beslut ska kunna motiveras.",
|
|
57
|
+
"body": "<p>...</p>",
|
|
58
|
+
"image": "https://cdn.typeroll.com/..."
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
If `image` is a URL, upload it first via `upload_media_from_url` and use
|
|
63
|
+
the returned CDN URL.
|
|
64
|
+
|
|
65
|
+
### 3. List all items for the listing page
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
list_collection_items collection="blog"
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Use the response to generate the listing HTML. The static site has no
|
|
72
|
+
template engine — the HTML is the listing.
|
|
73
|
+
|
|
74
|
+
### 4. Create the listing page
|
|
75
|
+
|
|
76
|
+
Build HTML manually from the collection items. Include all the data you
|
|
77
|
+
want visible without a detail click. Example structure:
|
|
78
|
+
|
|
79
|
+
```html
|
|
80
|
+
<section class="blog-listing">
|
|
81
|
+
<div class="container">
|
|
82
|
+
<h1 class="section-title">Artiklar</h1>
|
|
83
|
+
<div class="blog-grid">
|
|
84
|
+
<!-- one .blog-card per item -->
|
|
85
|
+
<article class="blog-card">
|
|
86
|
+
<a href="/blog/var-designfilosofi">
|
|
87
|
+
<img src="https://cdn.typeroll.com/..." alt="Omslagsbild">
|
|
88
|
+
<div class="blog-card__body">
|
|
89
|
+
<time class="blog-card__date">15 maj 2025</time>
|
|
90
|
+
<h2 class="blog-card__title">Vår designfilosofi</h2>
|
|
91
|
+
<p class="blog-card__excerpt">Vi tror på enkelhet med syfte...</p>
|
|
92
|
+
<span class="blog-card__cta">Läs mer →</span>
|
|
93
|
+
</div>
|
|
94
|
+
</a>
|
|
95
|
+
</article>
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
</section>
|
|
99
|
+
<style>
|
|
100
|
+
.blog-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:2rem}
|
|
101
|
+
.blog-card{border:1px solid var(--color-surface);border-radius:0.5rem;overflow:hidden}
|
|
102
|
+
.blog-card a{text-decoration:none;display:block;color:var(--color-text)}
|
|
103
|
+
.blog-card img{width:100%;aspect-ratio:16/9;object-fit:cover}
|
|
104
|
+
.blog-card__body{padding:1.5rem}
|
|
105
|
+
.blog-card__date{font-size:0.8rem;color:var(--color-text-light);display:block;margin-bottom:0.5rem}
|
|
106
|
+
.blog-card__title{font-family:var(--font-heading);font-size:1.25rem;margin-bottom:0.5rem}
|
|
107
|
+
.blog-card__excerpt{color:var(--color-text-light);font-size:0.9rem;margin-bottom:1rem}
|
|
108
|
+
.blog-card__cta{color:var(--color-accent);font-size:0.85rem;font-weight:600}
|
|
109
|
+
</style>
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
create_page title="Artiklar" slug="blog"
|
|
114
|
+
html_content="<listing HTML>"
|
|
115
|
+
content_mode="html" status="published"
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### 5. Create individual article pages
|
|
119
|
+
|
|
120
|
+
Each collection item with `route_template: "/blog/{slug}"` gets its own
|
|
121
|
+
URL at deploy time IF you create a matching page for each article. Create
|
|
122
|
+
one page per article:
|
|
123
|
+
|
|
124
|
+
```
|
|
125
|
+
create_page title="Vår designfilosofi" slug="blog/var-designfilosofi"
|
|
126
|
+
html_content="<full article HTML>"
|
|
127
|
+
content_mode="html" kind="article"
|
|
128
|
+
author="Anna Lindström"
|
|
129
|
+
seo_title="Vår designfilosofi — Acme Studio"
|
|
130
|
+
seo_description="Ingress here"
|
|
131
|
+
status="published"
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
The slug `blog/var-designfilosofi` maps to URL `/blog/var-designfilosofi`.
|
|
135
|
+
|
|
136
|
+
**Important:** Typeroll does NOT auto-generate detail pages from collection
|
|
137
|
+
items. You write the detail HTML per article — this gives full control over
|
|
138
|
+
the design but means each article is a separate `create_page` call.
|
|
139
|
+
|
|
140
|
+
### 6. Update nav to link to the blog
|
|
141
|
+
|
|
142
|
+
Read the header partial and add a "Blog" or "Artiklar" link:
|
|
143
|
+
```
|
|
144
|
+
read_partial partial_id="header"
|
|
145
|
+
replace_partial partial_id="header" html_content="<updated with blog link>"
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### 7. Keep listings in sync
|
|
149
|
+
|
|
150
|
+
When new articles are added later, the AI must:
|
|
151
|
+
1. `create_collection_item` with the article data
|
|
152
|
+
2. `create_page` for the article's detail URL
|
|
153
|
+
3. Re-read the full collection via `list_collection_items`
|
|
154
|
+
4. Regenerate the listing HTML and `update_page` the listing page
|
|
155
|
+
|
|
156
|
+
Or call `regenerate_collection_listing collection="blog"` if the site has
|
|
157
|
+
that route configured — it reruns the listing generation.
|
|
158
|
+
|
|
159
|
+
### 8. Deploy
|
|
160
|
+
|
|
161
|
+
```
|
|
162
|
+
trigger_deploy
|
|
163
|
+
get_deploy_status job_id=<id>
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## Pitfalls
|
|
167
|
+
|
|
168
|
+
- **Listing page goes stale.** Every new article needs the listing page
|
|
169
|
+
regenerated. There's no auto-sync; you must update the HTML.
|
|
170
|
+
- **Don't use non-ASCII field names.** `datum` not `datum`, `rubrik` not
|
|
171
|
+
`Rubrik` as the field name. The `label` can be anything; the `name` must
|
|
172
|
+
be `[a-z][a-z0-9_-]*`.
|
|
173
|
+
- **Article slugs must be globally unique.** `/blog/` prefix in the slug
|
|
174
|
+
avoids collisions with non-blog pages.
|
|
175
|
+
- **`route_template` is decorative without matching pages.** The template
|
|
176
|
+
declares the URL structure; actual routing depends on pages existing at
|
|
177
|
+
those paths. Always create the page.
|