@typeroll/mcp-server 0.31.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.
@@ -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
+ ];
@@ -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.'),
@@ -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
@@ -8,4 +8,4 @@
8
8
  //
9
9
  // Keep it in lockstep with package.json: tests/version.test.ts asserts
10
10
  // VERSION === package.json.version, so a bump that forgets this line fails CI.
11
- export const VERSION = '0.31.0';
11
+ export const VERSION = '0.32.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@typeroll/mcp-server",
3
- "version": "0.31.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.
@@ -20,13 +20,43 @@ agency) reviews each step in their terminal.
20
20
 
21
21
  ## Preconditions
22
22
 
23
+ **Run the readiness check FIRST — before touching any content:**
24
+
25
+ ```
26
+ get_migration_readiness source_url="https://oldsite.com"
27
+ ```
28
+
29
+ Pass `source_url` — that adds the checks on the site you're migrating FROM.
30
+ An old host that answers 403/429 to server-side requests is a **blocker**: the
31
+ import would produce empty pages, or pages containing the host's block page,
32
+ which reads as real content and is worse. Whether `/wp-json` answers is a
33
+ warning, since scraping is a real fallback (it just loses ACF/custom fields).
34
+
35
+ If `ready` is false, STOP and report the blockers to the user. Do not start
36
+ the import "and fix it after": every blocker is one whose failure is invisible
37
+ once the work is done, so discovering it late means redoing the expensive part.
38
+
39
+ - **Media storage** — without it, every `<img>` keeps its WordPress URL. The
40
+ new site looks perfect and is still served images by the old host. It breaks
41
+ the day the customer cancels that hosting, months later, all at once.
42
+ - **Hosting adapter** — without credentials, deploys return a job id and
43
+ publish nothing, while reporting success.
44
+
45
+ Warnings are worth relaying but don't stop you: no verification origin (the
46
+ pre-cutover parity check can't run), no AI reconstruction key, forms without a
47
+ notification address, or a target site with no design to rebuild INTO.
48
+
49
+ Then the ordinary preconditions:
50
+
23
51
  - `@typeroll/mcp-server` configured with a valid `TYPEROLL_API_KEY`.
24
52
  - The source WP site has `/wp-json` reachable (Google for "wordpress
25
53
  REST API disabled" if not — common for hardened hosts).
26
- - The Typeroll target site exists. New, blank sites with the
27
- starter design work best. If the target already has content, you
28
- must NOT clobber it always `list_pages` first and only write to
29
- slugs that don't already exist.
54
+ - The Typeroll target site exists **and already carries the design** —
55
+ settings, header/footer, one or two example pages. The migration rebuilds
56
+ old content in the NEW design; with nothing to imitate it inherits the old
57
+ site's look.
58
+ - If the target already has content, you must NOT clobber it — always
59
+ `list_pages` first and only write to slugs that don't already exist.
30
60
 
31
61
  ## Recipe
32
62
 
@@ -112,8 +142,43 @@ create_redirect from_path="/old-services" to_path="/services"
112
142
 
113
143
  Walk the inventory; for each URL: did it become a page with the same
114
144
  path? If yes, no redirect. If renamed, `create_redirect`. If
115
- intentionally dropped, mark it excluded in your notes (the customer
116
- should sign off on every dropped URL).
145
+ intentionally dropped, mark it `excluded` via `update_migration_url` (the
146
+ customer should sign off on every dropped URL).
147
+
148
+ **Use wildcards for WordPress's URL families.** A WP site's dead URLs come in
149
+ shapes, not as individuals — and the inventory only knows the ones it found,
150
+ while the old site had more (paginated archives, feeds, attachment pages). One
151
+ pattern rule retires the whole family:
152
+
153
+ | WordPress shape | Rule |
154
+ |---|---|
155
+ | Category archives | `from_path="/category/*"` → `to_path="/blogg/:splat"` (or a single landing page) |
156
+ | Tag archives | `from_path="/tag/*"` → `to_path="/blogg"` |
157
+ | Author archives | `from_path="/author/*"` → `to_path="/om-oss"` |
158
+ | Date-based permalinks | `from_path="/2019/*"` → `to_path="/blogg/:splat"` — one rule per year |
159
+ | Old post prefix → new | `from_path="/blog/:slug"` → `to_path="/artiklar/:slug"` |
160
+ | Feeds | `from_path="/feed/*"` → `to_path="/blogg"` |
161
+
162
+ Rules:
163
+
164
+ - **Trailing `*` only.** A mid-path splat (`/blog/*/comments`) is dropped
165
+ silently by Cloudflare — the platform refuses it at write time.
166
+ - **`:splat`** carries the captured remainder; **`:name`** matches exactly one
167
+ segment and can be replayed by name.
168
+ - **A pattern that would hide a live page is refused**, naming the pages. That
169
+ is the platform protecting you: redirects are applied before static files, so
170
+ `/blogg/*` would make every real article under `/blogg/` unreachable. Narrow
171
+ the prefix instead.
172
+ - **Query-string URLs can't be matched.** WP's `/?p=123` has no path to key on;
173
+ those need handling at the source (or accept the loss and mark them excluded).
174
+ - Rules are emitted most-specific-first, so a narrow rule always beats a broad
175
+ one — you can safely have `/blogg/recept/*` alongside `/blogg/*`.
176
+
177
+ Then verify against reality before anything is cut over:
178
+
179
+ ```
180
+ verify_migration_urls # after trigger_deploy
181
+ ```
117
182
 
118
183
  ### 5. Preview + review with the user
119
184