@typeroll/mcp-server 0.29.0 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +162 -1
- package/README.md +9 -4
- package/dist/bundled-content.js +4 -3
- package/dist/server.js +2 -0
- package/dist/tools/block-types.js +4 -4
- package/dist/tools/collections.js +16 -0
- package/dist/tools/migration.js +112 -0
- package/dist/tools/pages.js +10 -0
- package/dist/tools/redirects.js +9 -3
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/skills/README.md +1 -0
- package/skills/tr-migrate-multisite.md +348 -0
- package/skills/tr-migrate-wp.md +71 -6
|
@@ -122,7 +122,7 @@ export const blockTypeTools = [
|
|
|
122
122
|
// audit + UI distinguish agent-authored types.
|
|
123
123
|
{
|
|
124
124
|
name: 'create_block_type',
|
|
125
|
-
description: "Create a new custom block type usable on this site. Origin is stamped 'ai' automatically. The block ships immediately to every page editor + the renderer's registry. Custom JS via `script` is accepted under your API key's authority (audit-logged; the response carries a notice) — review before deploy. Returns the created BlockType. Responsive fields: mark a schema field `responsive: true` and expose it as style=\"--{field}:{{field}}\" on the outermost element so it can vary per breakpoint. If the value is directly usable CSS, read var(--{field}); if it's a token that maps to CSS (e.g. layout 'icon-left' → flex-direction:row) add a `responsive_css` map on the field ({ 'icon-left': '--dir: row;' }), otherwise per-breakpoint overrides won't take effect.",
|
|
125
|
+
description: "Create a new custom block type usable on this site. Origin is stamped 'ai' automatically. The block ships immediately to every page editor + the renderer's registry. Custom JS via `script` is accepted under your API key's authority (audit-logged; the response carries a notice) — review before deploy. This is the supported way to ship per-page JS: `<script>` written into page or block MARKUP is stripped by the renderer's sanitizer no matter which credential wrote it, so package the behaviour as a block type instead. Site-wide tags belong in settings.scripts_head / scripts_body_end. Returns the created BlockType. Responsive fields: mark a schema field `responsive: true` and expose it as style=\"--{field}:{{field}}\" on the outermost element so it can vary per breakpoint. If the value is directly usable CSS, read var(--{field}); if it's a token that maps to CSS (e.g. layout 'icon-left' → flex-direction:row) add a `responsive_css` map on the field ({ 'icon-left': '--dir: row;' }), otherwise per-breakpoint overrides won't take effect.",
|
|
126
126
|
inputSchema: {
|
|
127
127
|
name: z.string().describe('Machine name (lowercase kebab/underscore, 1-64 chars). Becomes the id.'),
|
|
128
128
|
label: z.string().optional().describe('Display label (defaults to name).'),
|
|
@@ -142,7 +142,7 @@ export const blockTypeTools = [
|
|
|
142
142
|
schema: z.array(z.unknown()).optional().describe('FieldDefinition[]. Each field has name, type, label, optional required/default/options/responsive.'),
|
|
143
143
|
template: z.string().optional().describe('HTML with {{field}}, {{{field}}}, {{=tag}}, {{children}}, {{slot:NAME}} substitutions.'),
|
|
144
144
|
styles: z.string().optional().describe('Block-scoped CSS. Selectors should start with [data-block="<name>"].'),
|
|
145
|
-
script: z.string().optional().describe(
|
|
145
|
+
script: z.string().optional().describe("Client-side JS shipped with the block (runs on the published site). Accepted under your API key's authority — the write is audit-logged and the response carries a notice naming the stored JS. It runs on the published site's own origin, which shares nothing with the Typeroll portal."),
|
|
146
146
|
version: versionParam,
|
|
147
147
|
},
|
|
148
148
|
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
@@ -157,7 +157,7 @@ export const blockTypeTools = [
|
|
|
157
157
|
},
|
|
158
158
|
{
|
|
159
159
|
name: 'update_block_type',
|
|
160
|
-
description: "Update fields of an existing custom block type. Core blocks (id starts with 'core/') are read-only — they're managed in platform code. Schema/template/styles replace wholesale (no deep merge); other fields shallow-merge. The `script` field follows the same
|
|
160
|
+
description: "Update fields of an existing custom block type. Core blocks (id starts with 'core/') are read-only — they're managed in platform code. Schema/template/styles replace wholesale (no deep merge); other fields shallow-merge. The `script` field follows the same policy as create_block_type — accepted under your key's authority, audit-logged, notice in the response.",
|
|
161
161
|
inputSchema: {
|
|
162
162
|
type_id: z.string().describe('Block type id (custom or third_party). Cannot be a core/* block.'),
|
|
163
163
|
label: z.string().optional(),
|
|
@@ -174,7 +174,7 @@ export const blockTypeTools = [
|
|
|
174
174
|
schema: z.array(z.unknown()).optional(),
|
|
175
175
|
template: z.string().optional(),
|
|
176
176
|
styles: z.string().optional(),
|
|
177
|
-
script: z.string().optional().describe(
|
|
177
|
+
script: z.string().optional().describe("Accepted under your API key's authority; audit-logged, and the response carries a notice naming the stored JS."),
|
|
178
178
|
version: versionParam,
|
|
179
179
|
},
|
|
180
180
|
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
@@ -100,6 +100,22 @@ export const collectionTools = [
|
|
|
100
100
|
return ok(res);
|
|
101
101
|
}),
|
|
102
102
|
},
|
|
103
|
+
{
|
|
104
|
+
name: 'collection_completeness',
|
|
105
|
+
description: "Which records in a collection need work, worst first. Returns per-field gap counts across the whole collection plus the N worst items (missing fields, never-verified fields, and fields whose last write is older than the staleness window). Use this INSTEAD of paging every item and diffing client-side — it's the intended entry point for an enrichment pass. Computed at read time, so it's never stale. By default only fields an API key may actually write are counted, since a gap you're forbidden to close is noise; pass all_fields: true for a human audit.",
|
|
106
|
+
inputSchema: {
|
|
107
|
+
collection: z.string(),
|
|
108
|
+
limit: z.number().int().min(1).max(500).optional().describe('Worst-N items to return. Default 50.'),
|
|
109
|
+
stale_after_days: z.number().int().min(1).optional().describe('A field untouched for longer than this is reported stale. Default 180.'),
|
|
110
|
+
all_fields: z.boolean().optional().describe('Include fields no API key may write. Default false.'),
|
|
111
|
+
version: versionParam,
|
|
112
|
+
},
|
|
113
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
114
|
+
const { collection, version, ...query } = args;
|
|
115
|
+
const res = await client.get(siteId, `collections/${encodeURIComponent(collection)}/completeness`, { ...query, ...v(version) });
|
|
116
|
+
return ok(res);
|
|
117
|
+
}),
|
|
118
|
+
},
|
|
103
119
|
{
|
|
104
120
|
name: 'list_collection_items',
|
|
105
121
|
description: 'List items in a collection with status filter + cursor pagination (cap 200 per page). Defaults to summary mode (richtext/textarea fields replaced with a byte-count hint) so a 200-item directory listing stays compact; pass include_richtext: true if you actually need full bodies.',
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { ok, withErrorBoundary } from './helpers.js';
|
|
3
|
+
const STATUS = z.enum(['migrated', 'redirected', 'excluded', 'unhandled']);
|
|
4
|
+
export const migrationTools = [
|
|
5
|
+
{
|
|
6
|
+
name: 'get_migration_readiness',
|
|
7
|
+
description: "Preflight for an import: is this site actually ready to receive a migration? CALL THIS FIRST, before moving any content. Every check exists because its failure is INVISIBLE afterwards — the pages import, the previews render, the customer signs off, and something is quietly wrong. The blockers: media storage (without it every <img> keeps its original URL, so the shiny new site is still served images by the old host, and the day that hosting is cancelled every image breaks at once) and the hosting adapter (without credentials, deploys return a job id and publish nothing while reporting success). Warnings cover the pre-cutover verification URL, AI reconstruction, form notification email and whether the target has a design to rebuild INTO. Returns { ready, blockers[], warnings[], checks[] } — each with a `fix`. If `ready` is false, stop and report the blockers to the user rather than starting the import; the content work would have to be redone.",
|
|
8
|
+
inputSchema: {
|
|
9
|
+
source_url: z
|
|
10
|
+
.string()
|
|
11
|
+
.optional()
|
|
12
|
+
.describe('The site you are migrating FROM, e.g. "https://oldsite.com". When given, the source is probed too: unreachable or bot-blocked (403/429) is a BLOCKER because an import from a host that refuses our requests produces empty pages, and whether /wp-json answers is reported as a warning (without it the importer must scrape HTML and loses ACF/custom fields).'),
|
|
13
|
+
},
|
|
14
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
15
|
+
const res = await client.get(siteId, 'migration-preflight', args.source_url ? { source_url: args.source_url } : undefined);
|
|
16
|
+
return ok(res);
|
|
17
|
+
}),
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
name: 'list_migration_urls',
|
|
21
|
+
description: "The legacy site's URL inventory with LIVE coverage status. Every entry is classified on read against the site's current pages + redirects: `migrated` (a page/collection item answers at that path), `redirected` (a redirect rule covers it), `excluded` (signed off as an intentional 404), `unhandled` (nothing covers it — the work list). Returns a summary over the whole inventory plus a page of entries, sorted worst-first then by GSC clicks. Use `status: \"unhandled\"` to get exactly what's left to do before cutover. Coverage is computed, never stored, so it's current the moment you create a redirect.",
|
|
22
|
+
inputSchema: {
|
|
23
|
+
status: STATUS.optional().describe('Only return entries with this coverage status.'),
|
|
24
|
+
limit: z.number().int().positive().max(1000).optional().describe('Default 200.'),
|
|
25
|
+
offset: z.number().int().min(0).optional(),
|
|
26
|
+
},
|
|
27
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
28
|
+
const res = await client.get(siteId, 'migration-urls', {
|
|
29
|
+
status: args.status,
|
|
30
|
+
limit: args.limit,
|
|
31
|
+
offset: args.offset,
|
|
32
|
+
});
|
|
33
|
+
return ok(res);
|
|
34
|
+
}),
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
name: 'add_migration_urls',
|
|
38
|
+
description: "Add old-site URLs to the inventory in bulk (up to 2000 per call). This is how the inventory gets populated outside the in-portal WordPress migration: walk the old sitemap.xml, a GSC export, or a crawl, and post what you found. Idempotent — re-posting a known URL merges its `source` label instead of duplicating. Pass `source_origin` when the site you're inventorying has its own domain: absolute URLs from a different origin are then REJECTED rather than silently folded in, which is what keeps a ten-domain multisite migration from pouring domain B's `/kontakt` into domain A's inventory. Rejected entries come back with a reason — nothing is dropped silently.",
|
|
39
|
+
inputSchema: {
|
|
40
|
+
urls: z
|
|
41
|
+
.array(z.object({
|
|
42
|
+
url: z.string().describe('Absolute URL (preferred) or a bare path like "/om-oss".'),
|
|
43
|
+
source: z.string().optional().describe('Where you found it: "sitemap", "gsc", "crawl", "manual", …'),
|
|
44
|
+
notes: z.string().optional(),
|
|
45
|
+
gsc_clicks: z.number().optional().describe('Search Console clicks — drives prioritisation in the coverage report.'),
|
|
46
|
+
gsc_impressions: z.number().optional(),
|
|
47
|
+
excluded: z.boolean().optional().describe('Mark immediately as an intentional 404 (e.g. /wp-admin, tag archives you are dropping).'),
|
|
48
|
+
}))
|
|
49
|
+
.min(1)
|
|
50
|
+
.max(2000),
|
|
51
|
+
source: z.string().optional().describe('Default source label for entries that omit one.'),
|
|
52
|
+
source_origin: z
|
|
53
|
+
.string()
|
|
54
|
+
.optional()
|
|
55
|
+
.describe('Origin of the old site, e.g. "https://old.example.com". Rejects absolute URLs from other origins.'),
|
|
56
|
+
},
|
|
57
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
58
|
+
const res = await client.post(siteId, 'migration-urls', args);
|
|
59
|
+
return ok(res);
|
|
60
|
+
}),
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: 'update_migration_url',
|
|
64
|
+
description: 'Annotate one inventory entry. `excluded: true` is the sign-off that this URL is MEANT to 404 after cutover — it moves the entry out of the "unhandled" work list without inventing a redirect for it. Also accepts notes and GSC metrics. The url_id is the entry id from list_migration_urls (the path with slashes replaced by underscores).',
|
|
65
|
+
inputSchema: {
|
|
66
|
+
url_id: z.string(),
|
|
67
|
+
excluded: z.boolean().optional(),
|
|
68
|
+
notes: z.string().optional(),
|
|
69
|
+
gsc_clicks: z.number().optional(),
|
|
70
|
+
gsc_impressions: z.number().optional(),
|
|
71
|
+
},
|
|
72
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
73
|
+
const { url_id, ...patch } = args;
|
|
74
|
+
const res = await client.patch(siteId, `migration-urls/${encodeURIComponent(url_id)}`, patch);
|
|
75
|
+
return ok(res);
|
|
76
|
+
}),
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
name: 'delete_migration_url',
|
|
80
|
+
description: 'Remove an entry from the inventory entirely. Use for junk the crawl picked up (session URLs, faceted duplicates). To record a deliberate 404 instead, prefer update_migration_url with excluded: true — that keeps the decision visible in the coverage report.',
|
|
81
|
+
inputSchema: { url_id: z.string() },
|
|
82
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
83
|
+
const res = await client.del(siteId, `migration-urls/${encodeURIComponent(args.url_id)}`);
|
|
84
|
+
return ok(res);
|
|
85
|
+
}),
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
name: 'verify_migration_urls',
|
|
89
|
+
description: "Pre-cutover parity check: actually REQUEST every inventory URL against the new site and report what it answers. list_migration_urls tells you what the data says; this tells you what the server does — a redirect pointing at an unpublished page, a typo'd path, or a redirect loop all read as \"handled\" in coverage and as a 404 to Googlebot. Runs against the site's fallback subdomain by default, which is the right target while the real domain still points at the old host. Verdicts: `ok` (200 at the same path), `ok_redirect` (redirects to a 200), `missing` (404/410 — the gap), `broken_redirect` (loop or chain ending badly), `error` (5xx/timeout, inconclusive). Also stamps verified/last_checked on the redirect rules it exercised. Deploy before running this — it tests the DEPLOYED site, not your drafts.",
|
|
90
|
+
inputSchema: {
|
|
91
|
+
target_origin: z
|
|
92
|
+
.string()
|
|
93
|
+
.optional()
|
|
94
|
+
.describe('Origin to test, e.g. "https://acme.sites.typeroll.com". Defaults to the site\'s fallback subdomain, then its live domain.'),
|
|
95
|
+
source_origin: z.string().optional().describe('Old site origin, e.g. "https://old.example.com".'),
|
|
96
|
+
check_source: z
|
|
97
|
+
.boolean()
|
|
98
|
+
.optional()
|
|
99
|
+
.describe('Also request each path on the OLD site (requires source_origin), so a URL that already 404s upstream is distinguishable from one the migration lost. Doubles the request count.'),
|
|
100
|
+
statuses: z
|
|
101
|
+
.array(STATUS)
|
|
102
|
+
.optional()
|
|
103
|
+
.describe('Only check entries with these coverage statuses. Default: all — "migrated" is exactly the claim this check exists to falsify.'),
|
|
104
|
+
limit: z.number().int().positive().max(500).optional().describe('Max URLs per run (default 150). `truncated: true` in the response means there are more.'),
|
|
105
|
+
concurrency: z.number().int().positive().max(12).optional(),
|
|
106
|
+
},
|
|
107
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
108
|
+
const res = await client.post(siteId, 'migration-urls/verify', args);
|
|
109
|
+
return ok(res);
|
|
110
|
+
}),
|
|
111
|
+
},
|
|
112
|
+
];
|
package/dist/tools/pages.js
CHANGED
|
@@ -85,6 +85,10 @@ export const pageTools = [
|
|
|
85
85
|
seo_title: z.string().optional(),
|
|
86
86
|
seo_description: z.string().optional(),
|
|
87
87
|
seo_image_alt: z.string().optional(),
|
|
88
|
+
alternates: z
|
|
89
|
+
.array(z.object({ hreflang: z.string(), href: z.string() }))
|
|
90
|
+
.optional()
|
|
91
|
+
.describe('Cross-domain hreflang cluster: the equivalents of THIS page on sister language sites, as [{ hreflang, href }]. One Typeroll site owns one domain, so a multi-language family (example.se / example.de / example.co.uk) is several sites and the mapping can\'t be derived — declare it here. List only the OTHER variants; the renderer injects this page\'s self-reference automatically. hreflang is a BCP-47 tag ("sv", "en-GB") or "x-default"; href must be an absolute http(s) URL. Invalid entries are rejected at write time with the reason, so a half-written cluster never ships. Every page in a cluster must link every other one — write all sides.'),
|
|
88
92
|
schema_type: z.string().optional().describe('Free-form Schema.org type ("Service", "Course", "Product", …) for auto JSON-LD.'),
|
|
89
93
|
kind: z.enum(['page', 'article']).optional(),
|
|
90
94
|
author: z.string().optional(),
|
|
@@ -123,7 +127,13 @@ export const pageTools = [
|
|
|
123
127
|
og_image: z.string().optional(),
|
|
124
128
|
seo_image_alt: z.string().optional().describe('Alt text for og:image/twitter:image. Falls back to first <img alt> on the page when unset.'),
|
|
125
129
|
canonical_url: z.string().optional(),
|
|
130
|
+
path: z.string().optional().describe('Explicit URL path for nested pages (e.g. "/erbjudanden/sommar-2026"). Takes precedence over slug for routing; changing it auto-creates a 301 from the old URL on save.'),
|
|
126
131
|
noindex: z.boolean().optional(),
|
|
132
|
+
alternates: z
|
|
133
|
+
.array(z.object({ hreflang: z.string(), href: z.string() }))
|
|
134
|
+
.nullable()
|
|
135
|
+
.optional()
|
|
136
|
+
.describe('Cross-domain hreflang cluster: the equivalents of THIS page on sister language sites, as [{ hreflang, href }]. One Typeroll site owns one domain, so a multi-language family (example.se / example.de / example.co.uk) is several sites and the mapping can\'t be derived — declare it here. List only the OTHER variants; the renderer injects this page\'s self-reference automatically. hreflang is a BCP-47 tag ("sv", "en-GB") or "x-default"; href must be an absolute http(s) URL. Invalid entries are rejected at write time with the reason, so a half-written cluster never ships. Every page in a cluster must link every other one — write all sides. Pass null to clear the cluster.'),
|
|
127
137
|
lastmod_override: z.string().optional().describe('Override the sitemap <lastmod>. Empty string suppresses lastmod for this page entirely.'),
|
|
128
138
|
image_sizes_default: z.string().optional().describe('Per-page default `sizes` for responsive images (e.g. "(max-width: 640px) 360px, 560px"). Overrides the site setting; a per-<img> `sizes` attr still wins. Set when this page\'s images render narrower than the generic default so the browser stops over-fetching the larger variant.'),
|
|
129
139
|
custom_css: z.string().optional().describe('Per-page CSS, injected into <head> as a <style> AFTER the site-level custom_css (so it overrides site styling). The RIGHT home for page-specific styling — put a page\'s <style> here instead of stuffing it into a core/html block.'),
|
package/dist/tools/redirects.js
CHANGED
|
@@ -15,10 +15,16 @@ export const redirectTools = [
|
|
|
15
15
|
},
|
|
16
16
|
{
|
|
17
17
|
name: 'create_redirect',
|
|
18
|
-
description: 'Create a redirect rule. Defaults to 301; pass status_code=302 for a temporary redirect.'
|
|
18
|
+
description: 'Create a redirect rule. Defaults to 301; pass status_code=302 for a temporary redirect. ' +
|
|
19
|
+
'WILDCARDS: a trailing "*" captures everything under a prefix and ":splat" replays it into the ' +
|
|
20
|
+
'target — `from_path="/category/*", to_path="/blogg/:splat"` retires an entire WordPress taxonomy ' +
|
|
21
|
+
'in one rule. `:name` matches exactly one segment (`"/blog/:slug"` → `"/artiklar/:slug"`). ' +
|
|
22
|
+
'Rules that would hide a live page are refused (Cloudflare applies redirects before serving files, ' +
|
|
23
|
+
'so the page would become unreachable) — narrow the pattern. Query strings cannot be matched: an ' +
|
|
24
|
+
'old `/?p=123` URL has no path to key on.',
|
|
19
25
|
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'),
|
|
26
|
+
from_path: z.string().describe('Old path, leading slash (e.g. "/old-about"). May be a pattern: "/category/*" (trailing splat only) or "/blog/:slug" (one segment).'),
|
|
27
|
+
to_path: z.string().describe('Target path or absolute URL. May reference ":splat" (requires a "*" in from_path) or any ":name" from_path declares.'),
|
|
22
28
|
status_code: z.union([z.literal(301), z.literal(302)]).optional(),
|
|
23
29
|
version: versionParam,
|
|
24
30
|
},
|
package/dist/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@typeroll/mcp-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.0",
|
|
4
4
|
"description": "Model Context Protocol server for the Typeroll public API. Use with Claude Code or any MCP-compatible client to manage a Typeroll site.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
package/skills/README.md
CHANGED
|
@@ -58,6 +58,7 @@ ln -s "$PWD/skills/tr-migrate-wp.md" ~/.claude/skills/
|
|
|
58
58
|
| File | When it triggers | What it does |
|
|
59
59
|
|---|---|---|
|
|
60
60
|
| `tr-migrate-wp.md` | "migrate from WordPress", a wp-json URL is mentioned | Walks the WP REST, rebuilds each page in the target's design, transfers media, sets redirects, leaves everything as drafts for review. |
|
|
61
|
+
| `tr-migrate-multisite.md` | "multisite", "our .se/.de/.co.uk sites", migrating several sites at once | One site per domain; per-site URL inventory, design replicated via `.tcblocks`, path preservation, hreflang clusters, and a parity check against the deployed site before DNS moves. |
|
|
61
62
|
| `tr-migrate-astro.md` | "migrate an Astro site", "import from src/content" | Lifts Astro Content Collections (`src/content/*`) into Typeroll collections — zod schema → field list, frontmatter → field values, markdown body → richtext field. Translates standalone `src/pages/*` into Typeroll pages, maps `src/layouts` chunks into partials. |
|
|
62
63
|
| `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. |
|
|
63
64
|
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: tr-migrate-multisite
|
|
3
|
+
description: Use when migrating SEVERAL sites at once — a WordPress multisite network, a group of country/language sites on different top-level domains, or any batch of related sites moving to Typeroll together. Triggers on "multisite", "network of sites", "10 sites", "our .se/.de/.co.uk sites", "language versions", "migrate all our sites". For a single WordPress site use tr-migrate-wp; for a single non-WP source use tr-import-url.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Migrate a multisite / multi-domain family to Typeroll
|
|
7
|
+
|
|
8
|
+
> **The buffer model (draft writes).** Every content write in this recipe
|
|
9
|
+
> (pages, blocks, partials, collection items) lands in an unsaved per-doc
|
|
10
|
+
> DRAFT — deploys and plain previews only see SAVED content. For recipe-style
|
|
11
|
+
> build work, pass `save: true` on write calls (the work is pre-approved by
|
|
12
|
+
> the task itself), or run `commit_working_copy` per doc before any
|
|
13
|
+
> `trigger_deploy`. Preview your drafts with `include_working_copy: true`.
|
|
14
|
+
|
|
15
|
+
## The first decision: one site or many?
|
|
16
|
+
|
|
17
|
+
**One Typeroll site owns exactly one domain** (plus its apex/www sibling).
|
|
18
|
+
So:
|
|
19
|
+
|
|
20
|
+
| The old family looks like | Build it as |
|
|
21
|
+
|---|---|
|
|
22
|
+
| `example.se`, `example.de`, `example.co.uk` — separate domains | **N Typeroll sites**, one per domain |
|
|
23
|
+
| `example.com/se/`, `example.com/de/` — one domain, language folders | **One site**, using `path` on each page (`/se/om-oss`) |
|
|
24
|
+
| A WP multisite on subdomains that the customer wants to consolidate onto one domain | **One site** + redirects from every old subdomain |
|
|
25
|
+
|
|
26
|
+
Confirm this with the user before creating anything — it's the one decision
|
|
27
|
+
that is expensive to reverse (domains, deploys, and analytics all hang off
|
|
28
|
+
it). The rest of this recipe assumes the common case: **N sites, one per
|
|
29
|
+
domain**, sharing a design.
|
|
30
|
+
|
|
31
|
+
Requires an **org-scoped API key** (`create_site` needs it, and one key
|
|
32
|
+
reaching every site is the whole point here). With MCP you pass `site_id`
|
|
33
|
+
per call; over stdio you'll be re-pointing `TYPEROLL_SITE_ID` per site.
|
|
34
|
+
|
|
35
|
+
## Phase 0 — Plan the batch (do this once, in writing)
|
|
36
|
+
|
|
37
|
+
Produce a table and get the user to confirm it before touching the platform:
|
|
38
|
+
|
|
39
|
+
| Old URL | Language | New Typeroll site | Domain | Notes |
|
|
40
|
+
|---|---|---|---|---|
|
|
41
|
+
|
|
42
|
+
**Check platform readiness before the plan is even agreed:**
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
get_migration_readiness site_id=<any existing site>
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Then once per market, with that market's own source:
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
get_migration_readiness site_id=<de-site> source_url="https://example.de"
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The blockers (media storage, hosting credentials) are **platform-level, not
|
|
55
|
+
per-site** — if they fail for one site they fail for all ten, and finding out
|
|
56
|
+
after building three sites means redoing three sites' worth of image work.
|
|
57
|
+
Get them fixed before Phase 1, and re-run the check per site once the sites
|
|
58
|
+
exist (the warnings — verification URL, form email, design reference — are
|
|
59
|
+
per-site).
|
|
60
|
+
|
|
61
|
+
Ask explicitly:
|
|
62
|
+
- Which site is the **design reference**? Build that one properly first.
|
|
63
|
+
- Are the sites **translations of each other** (same page structure) or
|
|
64
|
+
independent? This decides whether hreflang clusters are mechanical or
|
|
65
|
+
hand-mapped.
|
|
66
|
+
- Any domains being **retired or merged**? Those need redirects at the DNS
|
|
67
|
+
level, not just inside a site.
|
|
68
|
+
|
|
69
|
+
## Phase 1 — Inventory EVERY old site, before building anything
|
|
70
|
+
|
|
71
|
+
Do this for all sites up front. It's cheap, it's the only artefact that tells
|
|
72
|
+
you when you're done, and it stops you discovering an untouched 400-URL blog
|
|
73
|
+
in week three.
|
|
74
|
+
|
|
75
|
+
For each source site, create the target site first (the inventory lives on
|
|
76
|
+
it):
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
create_site name="Example DE" domain="example.de"
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Then walk the source and post what you find:
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
fetch https://example.de/sitemap.xml # follow sitemap-index children
|
|
86
|
+
fetch https://example.de/wp-json/wp/v2/pages?per_page=100 # walk X-WP-TotalPages
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
add_migration_urls site_id=<de-site> source_origin="https://example.de" source="sitemap" urls=[
|
|
91
|
+
{ url: "https://example.de/ueber-uns" },
|
|
92
|
+
{ url: "https://example.de/kontakt" },
|
|
93
|
+
…
|
|
94
|
+
]
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
**`source_origin` is not optional in a multisite job.** It rejects URLs from
|
|
98
|
+
another origin, which is the guard that stops domain B's `/kontakt` landing
|
|
99
|
+
in domain A's inventory — where it would silently read as "covered" because
|
|
100
|
+
domain A happens to have a `/kontakt` too.
|
|
101
|
+
|
|
102
|
+
Add every source you have, each with its own label — they merge per URL:
|
|
103
|
+
- `source="sitemap"` — the sitemap(s)
|
|
104
|
+
- `source="rest"` — WP REST pages/posts/custom types
|
|
105
|
+
- `source="gsc"` — a Search Console export, **with `gsc_clicks`**. This is
|
|
106
|
+
what makes the coverage report prioritise itself: the 12 URLs carrying all
|
|
107
|
+
the traffic sort to the top.
|
|
108
|
+
- `source="crawl"` — anything you found by following internal links
|
|
109
|
+
|
|
110
|
+
Mark the obvious throwaways immediately, so the work list is real:
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
add_migration_urls urls=[{ url: "/wp-admin", excluded: true }, { url: "/tag/nyheter", excluded: true }]
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Read it back and note the starting number:
|
|
117
|
+
|
|
118
|
+
```
|
|
119
|
+
list_migration_urls site_id=<de-site> status="unhandled" limit=50
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Phase 2 — Build the reference site
|
|
123
|
+
|
|
124
|
+
Follow `tr-migrate-wp` (or `tr-import-url`) for the ONE reference site:
|
|
125
|
+
design, header/footer partials, page templates, block types. Get the user to
|
|
126
|
+
approve it before replicating — every fix you make after this point costs
|
|
127
|
+
N times as much.
|
|
128
|
+
|
|
129
|
+
## Phase 3 — Replicate the design to the other sites
|
|
130
|
+
|
|
131
|
+
Design travels as a block-type package, not by hand:
|
|
132
|
+
|
|
133
|
+
```
|
|
134
|
+
export_block_types site_id=<reference> # → .tcblocks JSON
|
|
135
|
+
import_block_types site_id=<other> package=<that JSON>
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Then per site:
|
|
139
|
+
- `read_site_settings` on the reference → `update_site_settings` on the
|
|
140
|
+
target with the same colors/fonts (translate `site_name`, `tagline`,
|
|
141
|
+
contact details — those are per-market, not shared).
|
|
142
|
+
- Recreate header/footer partials with translated nav labels.
|
|
143
|
+
- Page templates: rebuild with `add_block target={kind:'template', id:…}`.
|
|
144
|
+
|
|
145
|
+
Set the language per site — it drives `<html lang>`, `og:locale` and
|
|
146
|
+
alt-text generation:
|
|
147
|
+
|
|
148
|
+
```
|
|
149
|
+
update_site site_id=<de-site> language="de"
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## Phase 4 — Migrate content, preserving paths
|
|
153
|
+
|
|
154
|
+
Per site, per URL, follow `tr-migrate-wp` §3. Two rules that matter more here
|
|
155
|
+
than in a single-site migration:
|
|
156
|
+
|
|
157
|
+
1. **Preserve the path verbatim** unless there's a reason not to. Use
|
|
158
|
+
`path` for anything nested: `create_page title="Über uns" slug="ueber-uns"
|
|
159
|
+
path="/ueber-uns"`. A preserved path needs no redirect and loses nothing.
|
|
160
|
+
2. **Rewrite internal links to the NEW paths.** Imported HTML is full of
|
|
161
|
+
absolute links to the old domain. Sweep them per site:
|
|
162
|
+
|
|
163
|
+
```
|
|
164
|
+
bulk_replace_text site_id=<de-site> find="https://example.de/" replace="/" dry_run=true
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Check the dry-run count against what you expect before running it for real.
|
|
168
|
+
Cross-domain links between sister sites stay absolute — only the site's own
|
|
169
|
+
domain becomes relative.
|
|
170
|
+
|
|
171
|
+
### Media: per site, not shared
|
|
172
|
+
|
|
173
|
+
Every Typeroll site has its own media library, so a shared asset (the group
|
|
174
|
+
logo, a product shot used in all markets) is uploaded once **per site** and
|
|
175
|
+
gets a different CDN URL in each. That's correct — the sites are independent
|
|
176
|
+
and one market's deploy must not depend on another's assets — but it means:
|
|
177
|
+
|
|
178
|
+
- Don't try to reuse a `cdn_url` from site A inside site B's HTML. It will
|
|
179
|
+
render, and it will break the day site A is deleted or moved.
|
|
180
|
+
- Do write alt text per market, in that market's language:
|
|
181
|
+
`update_media media_id=… alt_text="…"`. The alt text is content, not
|
|
182
|
+
metadata, and a Swedish alt on a German page is a real accessibility defect.
|
|
183
|
+
|
|
184
|
+
Images referenced only from a stylesheet or from unrendered page-builder JSON
|
|
185
|
+
are NOT found by an HTML scan. Spot-check the hero/background images of the
|
|
186
|
+
top pages in the preview before you call a site done.
|
|
187
|
+
|
|
188
|
+
### Forms are NOT migrated — plan to rebuild them
|
|
189
|
+
|
|
190
|
+
The HTML cleaner strips `<form>`, `<input>`, `<select>` and `<button>`
|
|
191
|
+
entirely, on purpose: a Contact Form 7 / Gravity / Elementor form posts to
|
|
192
|
+
WordPress endpoints that no longer exist, so importing the markup would give
|
|
193
|
+
you a form that looks alive and silently drops every submission.
|
|
194
|
+
|
|
195
|
+
So, per site:
|
|
196
|
+
|
|
197
|
+
```
|
|
198
|
+
create_form name="Kontakt" fields=[…] # or steps=[…] for a funnel
|
|
199
|
+
add_block target={kind:'page', id:'kontakt'} block={type:'core/form', data:{form_id:'<id>'}}
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Then, still per site:
|
|
203
|
+
|
|
204
|
+
- **Recipient address per market** — the German enquiries rarely go to the
|
|
205
|
+
Swedish inbox. Check this explicitly; it is the single most common thing
|
|
206
|
+
to get wrong in a batch of ten.
|
|
207
|
+
- **Email delivery is configured per site** by an admin in the portal
|
|
208
|
+
(Settings → Integrations), not through this API. Flag it to the user as a
|
|
209
|
+
manual step — a form that saves submissions but sends no notification looks
|
|
210
|
+
fine in testing and loses leads in production.
|
|
211
|
+
- **Submit a real test through every form** after deploy, and confirm both the
|
|
212
|
+
stored submission and the notification email.
|
|
213
|
+
|
|
214
|
+
Count the old site's forms during Phase 1 and put them in the plan table.
|
|
215
|
+
Ten sites × three forms is thirty forms, and it is the part of the job that
|
|
216
|
+
never shows up in a URL inventory.
|
|
217
|
+
|
|
218
|
+
## Phase 5 — Redirects for everything you didn't preserve
|
|
219
|
+
|
|
220
|
+
Work the coverage report, not your memory:
|
|
221
|
+
|
|
222
|
+
```
|
|
223
|
+
list_migration_urls site_id=<de-site> status="unhandled"
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
For each entry, one of three outcomes — no fourth option:
|
|
227
|
+
|
|
228
|
+
- It moved → `create_redirect from_path="/alte-seite" to_path="/neue-seite"`
|
|
229
|
+
- It's gone on purpose → `update_migration_url url_id=… excluded=true notes="Old campaign LP, signed off by <name>"`
|
|
230
|
+
- It should exist and doesn't → go back and migrate it
|
|
231
|
+
|
|
232
|
+
Re-read the list. `unhandled` reaching zero is the exit condition for this
|
|
233
|
+
phase.
|
|
234
|
+
|
|
235
|
+
**Clear the URL families with one rule each**, per site — a WP network
|
|
236
|
+
multiplies the same dead shapes across every market:
|
|
237
|
+
|
|
238
|
+
```
|
|
239
|
+
create_redirect site_id=<de-site> from_path="/category/*" to_path="/blogg/:splat"
|
|
240
|
+
create_redirect site_id=<de-site> from_path="/tag/*" to_path="/blogg"
|
|
241
|
+
create_redirect site_id=<de-site> from_path="/2019/*" to_path="/blogg/:splat"
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
Only a TRAILING `*` is supported (`:splat` replays the remainder); `:name`
|
|
245
|
+
matches one segment. Pattern-covered inventory URLs count as `redirected`, so
|
|
246
|
+
the work list actually empties. A pattern that would hide a live page is
|
|
247
|
+
refused, naming the pages — narrow the prefix rather than working around it.
|
|
248
|
+
|
|
249
|
+
Watch the per-market prefixes: the German site's archive base is `/kategorie/`,
|
|
250
|
+
not `/category/`. Write the rules from each site's own inventory, never by
|
|
251
|
+
copying the reference site's.
|
|
252
|
+
|
|
253
|
+
## Phase 6 — Wire the hreflang cluster
|
|
254
|
+
|
|
255
|
+
This is the step that only exists because the family is multi-domain, and the
|
|
256
|
+
one most likely to be skipped. Each page declares its siblings on the other
|
|
257
|
+
domains; the renderer adds the page's own self-reference.
|
|
258
|
+
|
|
259
|
+
```
|
|
260
|
+
update_page site_id=<se-site> page_id="om-oss" patch={ alternates: [
|
|
261
|
+
{ hreflang: "de", href: "https://example.de/ueber-uns" },
|
|
262
|
+
{ hreflang: "en-GB", href: "https://example.co.uk/about-us" },
|
|
263
|
+
{ hreflang: "x-default", href: "https://example.com/about-us" }
|
|
264
|
+
]}
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Rules the search engines actually enforce:
|
|
268
|
+
- **Reciprocal.** Every page in a cluster must list every other one. Write
|
|
269
|
+
all N sides or the cluster is ignored. `batch_update_pages` is the sane way
|
|
270
|
+
to do this once you have the mapping table.
|
|
271
|
+
- **One `x-default`** per cluster, pointing at the language selector or the
|
|
272
|
+
fallback market. Optional, but useful when the family doesn't cover a
|
|
273
|
+
visitor's language.
|
|
274
|
+
- **Absolute URLs on the final domain** — not the `*.typeroll` fallback
|
|
275
|
+
subdomain. The cluster is what you want live after cutover, and a fallback
|
|
276
|
+
URL in there is a leak you'll be cleaning up for months.
|
|
277
|
+
- Invalid entries are **rejected at write time** with the reason. If a write
|
|
278
|
+
fails, fix the tag/href — don't strip the field to make it pass.
|
|
279
|
+
|
|
280
|
+
Pages that have no equivalent on the other domains get no alternates at all.
|
|
281
|
+
A cluster of one is meaningless markup.
|
|
282
|
+
|
|
283
|
+
## Phase 7 — Verify BEFORE touching DNS
|
|
284
|
+
|
|
285
|
+
Deploy each site (`trigger_deploy`), then check what it actually serves:
|
|
286
|
+
|
|
287
|
+
```
|
|
288
|
+
verify_migration_urls site_id=<de-site> source_origin="https://example.de" check_source=true
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
This requests every inventory URL against the site's fallback subdomain —
|
|
292
|
+
the real domain still points at the old host, which is exactly why the check
|
|
293
|
+
is possible at all. Verdicts:
|
|
294
|
+
|
|
295
|
+
| Verdict | Meaning | Action |
|
|
296
|
+
|---|---|---|
|
|
297
|
+
| `ok` | 200 at the same path | none |
|
|
298
|
+
| `ok_redirect` | redirects to a 200 | none; flatten if `hops` > 1 |
|
|
299
|
+
| `missing` | 404/410 | **the gap** — redirect it or migrate it |
|
|
300
|
+
| `broken_redirect` | loop, or chain ending on an error | fix the rule |
|
|
301
|
+
| `error` | 5xx / timeout | inconclusive, re-run |
|
|
302
|
+
|
|
303
|
+
`check_source=true` also requests the OLD site, so a URL that already 404s
|
|
304
|
+
upstream shows up as noise in the inventory rather than as a migration
|
|
305
|
+
failure — mark those `excluded`.
|
|
306
|
+
|
|
307
|
+
Note what the check does NOT catch: it verifies that a URL *resolves*, not
|
|
308
|
+
that the page at the other end is the right content. Spot-check the top
|
|
309
|
+
`gsc_clicks` URLs by eye.
|
|
310
|
+
|
|
311
|
+
Iterate until `missing` and `broken_redirect` are both zero **on every site**.
|
|
312
|
+
Then, per site:
|
|
313
|
+
|
|
314
|
+
1. `add_domain` / follow the DNS instructions the platform returns
|
|
315
|
+
2. Point DNS
|
|
316
|
+
3. `poll_domain` until verified → `activate_domain`
|
|
317
|
+
4. Re-run `verify_migration_urls target_origin="https://example.de"` against
|
|
318
|
+
the real domain, to confirm the cutover kept what the pre-check proved
|
|
319
|
+
5. Submit the new sitemap in Search Console; keep the old property open for
|
|
320
|
+
a few weeks and watch the 404 report
|
|
321
|
+
|
|
322
|
+
## Definition of done (per site)
|
|
323
|
+
|
|
324
|
+
- [ ] `get_migration_readiness source_url=<this market's old site>` → `ready: true`, warnings reviewed
|
|
325
|
+
- [ ] `list_migration_urls status="unhandled"` → 0
|
|
326
|
+
- [ ] `verify_migration_urls` → 0 `missing`, 0 `broken_redirect`
|
|
327
|
+
- [ ] hreflang cluster written on both/all sides, absolute, final domains
|
|
328
|
+
- [ ] `language` set on the site; `<html lang>` correct in the deployed HTML
|
|
329
|
+
- [ ] Internal links rewritten (no lingering absolute links to the old domain)
|
|
330
|
+
- [ ] Forms rebuilt, recipient address correct for THIS market, test submission sent and received
|
|
331
|
+
- [ ] Media uploaded to this site's own library (no cross-site `cdn_url`), alt text in this market's language
|
|
332
|
+
- [ ] Domain verified + activated; sitemap submitted
|
|
333
|
+
|
|
334
|
+
## Pitfalls specific to this job
|
|
335
|
+
|
|
336
|
+
- **Don't share one inventory across domains.** Inventory entries key on
|
|
337
|
+
path; two markets both have `/kontakt`. One site, one inventory.
|
|
338
|
+
- **Don't build all N sites in parallel from scratch.** Build one, approve,
|
|
339
|
+
replicate. Parallel building multiplies every design mistake by N.
|
|
340
|
+
- **Don't skip the parity check because coverage says 100%.** Coverage is a
|
|
341
|
+
claim about the data; parity is a measurement of the server. They disagree
|
|
342
|
+
exactly when it matters — an unpublished target page, a typo'd path, a
|
|
343
|
+
redirect chain.
|
|
344
|
+
- **Don't point DNS site-by-site on a whim.** Cutting over one market at a
|
|
345
|
+
time is fine and usually wise, but the hreflang cluster spans markets: a
|
|
346
|
+
page pointing at a domain that still serves the old site is pointing at
|
|
347
|
+
content that doesn't match. Either cut over close together, or write the
|
|
348
|
+
cluster after the last market lands.
|