@typeroll/mcp-server 0.7.5 → 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 +11 -2
- package/dist/tools/partials.js +9 -4
- package/dist/tools/search.js +6 -3
- package/package.json +1 -1
- 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
|
@@ -40,14 +40,23 @@ export const pageTools = [
|
|
|
40
40
|
},
|
|
41
41
|
{
|
|
42
42
|
name: 'batch_read_pages',
|
|
43
|
-
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.',
|
|
44
44
|
inputSchema: {
|
|
45
45
|
page_ids: z.array(z.string()).min(1).max(200),
|
|
46
46
|
version: versionParam,
|
|
47
47
|
},
|
|
48
48
|
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
49
49
|
const res = await client.post(siteId, 'pages/batch-read', { page_ids: args.page_ids }, v(args.version));
|
|
50
|
-
|
|
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 });
|
|
51
60
|
}),
|
|
52
61
|
},
|
|
53
62
|
{
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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": {
|
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.
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: tr-brand
|
|
3
|
+
description: Use when the user asks to create a brand identity, design system, or visual style for a site. Triggers on "create a brand", "design the look", "choose colors", "pick fonts", "make it look like [reference]", or "rebrand the site". Produces a cohesive palette, typography scale, and CSS custom properties applied to an existing site.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Design a brand identity for a Typeroll site
|
|
7
|
+
|
|
8
|
+
This skill turns a brief (or a reference URL/screenshot) into a complete
|
|
9
|
+
visual design system applied to the site's settings and partials.
|
|
10
|
+
|
|
11
|
+
## Preconditions
|
|
12
|
+
|
|
13
|
+
- Site exists and MCP is configured.
|
|
14
|
+
- You have at least one of: industry, mood words, reference URL, existing
|
|
15
|
+
logo colors, or competitor sites to contrast with.
|
|
16
|
+
|
|
17
|
+
## Step 1 — Gather context
|
|
18
|
+
|
|
19
|
+
Ask (or infer from the brief):
|
|
20
|
+
|
|
21
|
+
1. **Industry + audience.** Law firm → formal, trust. Café → warm, approachable.
|
|
22
|
+
Tech startup → clean, modern. Interior design → refined, editorial.
|
|
23
|
+
2. **Mood words.** 3–5 adjectives the brand should feel: "minimal, Nordic,
|
|
24
|
+
calm" or "bold, energetic, playful".
|
|
25
|
+
3. **Reference.** A URL, a screenshot, or a competitor they like (and what
|
|
26
|
+
they want to be different from it).
|
|
27
|
+
4. **Must-keep.** Existing logo color? Legal industry color conventions?
|
|
28
|
+
|
|
29
|
+
If the user provided a URL, fetch it and note the dominant colors,
|
|
30
|
+
typeface categories, and layout density.
|
|
31
|
+
|
|
32
|
+
## Step 2 — Build the palette
|
|
33
|
+
|
|
34
|
+
A Typeroll site uses 7 color tokens:
|
|
35
|
+
|
|
36
|
+
| Token | Role | Design rule |
|
|
37
|
+
|---|---|---|
|
|
38
|
+
| `primary` | Brand identity. CTA buttons, active nav, links. | High contrast on `background`. |
|
|
39
|
+
| `secondary` | Header, footer, darker sections. | Darker or more neutral than primary. |
|
|
40
|
+
| `accent` | Highlights, price tags, badges, hover states. | High-energy complement. |
|
|
41
|
+
| `background` | Page background. | Near-white for light themes, near-black for dark. |
|
|
42
|
+
| `surface` | Cards, input boxes, code blocks. | Slightly off from `background`. |
|
|
43
|
+
| `text` | Body copy. | ≥4.5:1 contrast ratio on `background`. |
|
|
44
|
+
| `text_light` | Secondary labels, captions, placeholders. | ≥3:1 on `background`. |
|
|
45
|
+
|
|
46
|
+
**Palette recipes by mood:**
|
|
47
|
+
|
|
48
|
+
*Nordic / minimal:*
|
|
49
|
+
```
|
|
50
|
+
primary: #1f2a30 secondary: #142027 accent: #c9b89a
|
|
51
|
+
background: #faf8f4 surface: #f2ede5 text: #1f2a30 text_light: #7a7265
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
*Warm / artisan:*
|
|
55
|
+
```
|
|
56
|
+
primary: #3d2b1f secondary: #2a1d14 accent: #c8860a
|
|
57
|
+
background: #fdf6ee surface: #f7ede0 text: #1a1008 text_light: #8a7060
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
*Modern / tech:*
|
|
61
|
+
```
|
|
62
|
+
primary: #2563eb secondary: #1e293b accent: #f59e0b
|
|
63
|
+
background: #ffffff surface: #f8fafc text: #0f172a text_light: #64748b
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
*Editorial / dark:*
|
|
67
|
+
```
|
|
68
|
+
primary: #e2c08d secondary: #0f0f0f accent: #e2c08d
|
|
69
|
+
background: #0f0f0f surface: #1a1a1a text: #f5f5f0 text_light: #a0a090
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Check WCAG contrast ratios mentally: text on background must be ≥4.5:1.
|
|
73
|
+
The online tool `https://webaim.org/resources/contrastchecker/` is useful
|
|
74
|
+
but not accessible during a tool call — reason about perceived contrast
|
|
75
|
+
instead (light grey on white = bad; dark grey on white = fine).
|
|
76
|
+
|
|
77
|
+
## Step 3 — Choose typefaces
|
|
78
|
+
|
|
79
|
+
Pick from high-quality Google Fonts pairings:
|
|
80
|
+
|
|
81
|
+
| Heading | Body | Mood |
|
|
82
|
+
|---|---|---|
|
|
83
|
+
| Cormorant Garamond | Raleway | Luxury, editorial |
|
|
84
|
+
| Playfair Display | Source Sans 3 | Classic, readable |
|
|
85
|
+
| DM Serif Display | DM Sans | Contemporary, clean |
|
|
86
|
+
| Fraunces | Mulish | Artisan, craft |
|
|
87
|
+
| Syne | Inter | Bold, modern |
|
|
88
|
+
| Plus Jakarta Sans | Plus Jakarta Sans | Clean, versatile |
|
|
89
|
+
| Libre Baskerville | Libre Franklin | Traditional, trustworthy |
|
|
90
|
+
|
|
91
|
+
Same font for heading and body is fine if it has enough weight variation
|
|
92
|
+
(Inter at 700 + 400 works well).
|
|
93
|
+
|
|
94
|
+
`size_base` should be 16 for most sites; 17–18 for text-heavy editorial
|
|
95
|
+
sites; 15 for dense dashboards.
|
|
96
|
+
|
|
97
|
+
## Step 4 — Apply to the site
|
|
98
|
+
|
|
99
|
+
One call sets everything:
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
update_site_settings {
|
|
103
|
+
"colors": { ...all 7 tokens },
|
|
104
|
+
"fonts": { "heading": "...", "body": "...", "size_base": 16 },
|
|
105
|
+
"custom_css": "/* optional: utility classes or @keyframes */"
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Read back to confirm: `read_site_settings`.
|
|
110
|
+
|
|
111
|
+
## Step 5 — Update partials to use the new palette
|
|
112
|
+
|
|
113
|
+
Partials that hardcoded hex colors need updating. Fetch the header:
|
|
114
|
+
|
|
115
|
+
```
|
|
116
|
+
read_partial partial_id="header"
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
If it has hardcoded colors, replace them with CSS variable references
|
|
120
|
+
(`var(--color-primary)`) and call `replace_partial`:
|
|
121
|
+
|
|
122
|
+
```
|
|
123
|
+
replace_partial partial_id="header" html_content="<updated HTML>"
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Same for footer.
|
|
127
|
+
|
|
128
|
+
## Step 6 — Custom CSS for advanced tokens (optional)
|
|
129
|
+
|
|
130
|
+
If the brand needs things beyond the 7 base tokens — e.g. a gradient,
|
|
131
|
+
a special border radius, or a branded highlight color — add them via
|
|
132
|
+
`custom_css`:
|
|
133
|
+
|
|
134
|
+
```css
|
|
135
|
+
:root {
|
|
136
|
+
--brand-gradient: linear-gradient(135deg, var(--color-primary), var(--color-accent));
|
|
137
|
+
--radius-brand: 2px; /* sharp corners for formal brands */
|
|
138
|
+
--letter-spacing-display: -0.03em; /* tight tracking for display headings */
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Then reference `var(--brand-gradient)` etc. in page HTML and partials.
|
|
143
|
+
|
|
144
|
+
## Step 7 — Preview
|
|
145
|
+
|
|
146
|
+
```
|
|
147
|
+
get_preview_link
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Open in browser. Check:
|
|
151
|
+
- Colors render as intended (not "undefined" or missing)
|
|
152
|
+
- Fonts load (Google Fonts link is in `<head>`)
|
|
153
|
+
- Nav text is readable against header background
|
|
154
|
+
- Body text has sufficient contrast
|
|
155
|
+
|
|
156
|
+
## Pitfalls
|
|
157
|
+
|
|
158
|
+
- **Don't set colors without checking the header contrast.** If `primary`
|
|
159
|
+
is light, white nav text becomes unreadable. Either darken `primary` or
|
|
160
|
+
make the header use `secondary`.
|
|
161
|
+
- **Custom_css is global.** Rules here apply to every page. Keep it to
|
|
162
|
+
`:root {}` token additions and truly global utilities. Page-specific
|
|
163
|
+
styles go in the page's HTML `<style>` block.
|
|
164
|
+
- **Google Fonts load time.** Two different font families is fine; three
|
|
165
|
+
adds measurable LCP impact. Stick to two families with variable-font
|
|
166
|
+
versions when possible.
|
|
167
|
+
- **Dark themes need dark surface too.** Setting `background: #0f0f0f`
|
|
168
|
+
but leaving `surface: #f8fafc` (white) breaks every card/input. Always
|
|
169
|
+
update all 7 tokens as a set.
|