@typeroll/mcp-server 0.44.66 → 0.44.68

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.
@@ -14,10 +14,10 @@ export const BUNDLED_SKILLS = {
14
14
  "tr-header-footer": "---\nname: tr-header-footer\ndescription: Build accessible native header and footer partials with the site logo, semantic navigation, responsive disclosure, and optional native cookie consent.\n---\n\n# Native header and footer compositions\n\nUse block-mode partials for new work. Core 0.1.8 / capability contract 0.43.0\nships `template/site_logo`, `core/navigation`, layout primitives, and a\nrevision-safe partial mode operation. HTML partials remain supported for\nlegacy markup, but generic navigation and responsive layout no longer require\ntenant HTML, CSS, or JavaScript.\n\n## Preconditions\n\n1. Read `get_site_capabilities`, `read_site_settings`, `list_partials`, and\n `list_block_types`.\n2. Require `supports_native_navigation`, `supports_partial_mode_switching`,\n `supports_responsive_data_fields`, and\n `supports_versioned_block_type_inheritance`.\n3. Read the existing header/footer before replacing anything. Work in a branch\n for redesigns and preview at desktop, 390 px, keyboard-only, and 200% zoom.\n\n## Header tree\n\nStage this tree with `update_partial partial_id=\"header\" patch={blocks:[...]}\nsave=true`, then call `set_partial_mode partial_id=\"header\" to=\"blocks\"` and\nread it back. Localize both accessible labels and every link label.\n\n```json\n[\n {\n \"type\": \"core/section\",\n \"data\": { \"width\": \"wide\", \"padding_y\": \"sm\" },\n \"children\": [{\n \"type\": \"core/container\",\n \"data\": {\n \"direction\": { \"mobile\": \"column\", \"tablet\": \"row\" },\n \"align_main\": \"space-between\",\n \"align_cross\": \"center\",\n \"gap\": \"md\",\n \"width\": \"wide\",\n \"padding_y\": \"none\",\n \"padding_x\": \"none\"\n },\n \"children\": [\n { \"type\": \"template/site_logo\", \"data\": { \"height\": \"md\", \"link_to_home\": true } },\n {\n \"type\": \"core/navigation\",\n \"data\": {\n \"aria_label\": \"Main navigation\",\n \"menu_label\": \"Menu\",\n \"links\": [\n { \"label\": \"Home\", \"href\": \"/\" },\n { \"label\": \"About\", \"href\": \"/about/\" },\n { \"label\": \"Contact\", \"href\": \"/contact/\" }\n ]\n }\n }\n ]\n }]\n }\n]\n```\n\n`core/navigation` emits a real `nav` and list in initial HTML, marks the\nmatching page with `aria-current=\"page\"`, and progressively enhances to a\nbutton-controlled mobile menu. Verify Tab and Shift-Tab order, Enter/Space,\nEscape returning focus to the disclosure, visible focus, long translated\nlabels, and no horizontal overflow. Without JavaScript the links remain\nvisible and usable.\n\n## Footer tree and consent\n\nUse the same tree for `footer`, normally with `padding_y: \"lg\"`, a small site\nlogo, localized footer navigation, and optional contact/prose blocks. The\nfooter navigation gets its own accessible label; it is not the main\nnavigation landmark.\n\nCookie consent is a site feature, not a footer widget. Configure it separately:\n\n```json\n{\n \"cookie_consent\": {\n \"enabled\": true,\n \"text\": \"We use optional cookies. <a href=\\\"/privacy/\\\">Privacy policy</a>\",\n \"privacy_policy_url\": \"/privacy/\",\n \"reload_after_consent\": false\n }\n}\n```\n\nUse `update_site_settings`, then read settings back. `scripts_necessary` and\n`scripts_optional` execute in visitors' browsers and must be reviewed like\nother API-key-authorized script surfaces. Verify accept/necessary/reject,\noptional-script gating, and keyboard focus in preview and the actual build.\nThe signed hosted preview runs at an opaque origin. Consent choices therefore\nuse in-memory state for the current preview document; they are intentionally\nnot persisted as portal cookies across a reload or page navigation. Accepting\nall must still activate optional scripts immediately, which is enough to test\nthe gate without weakening the preview sandbox.\n\n## Version and dependency rules\n\nChild versions inherit installed, custom, and third-party block types from\ntheir base chain. If preview reports a missing type, stop and inspect\n`list_block_types` for that exact version; do not duplicate an Extension or\ninstallation. A type missing after capability 0.43.0 is a dependency error to\nreport, not a reason to let the block silently disappear.\n\n## Final checks\n\n- Header and footer landmarks exist exactly once around their content.\n- Logo is fully visible, keeps its aspect ratio, has useful alt text, and links home.\n- Current-page state and localized navigation labels are correct.\n- Mobile disclosure is keyboard accessible and does not trap focus.\n- No tenant CSS is required for spacing, focus, wrapping, or the narrow layout.\n- Preview and a hosted static build match before promotion.\n",
15
15
  "tr-imagegen": "---\nname: tr-imagegen\ndescription: Use when the user wants to generate images for a Typeroll site with AI models (Gemini, OpenAI, Higgsfield) — hero images, illustrations, section backgrounds, og-images. Triggers on \"generera bilder\", \"generate images\", \"skapa en hero-bild\", \"AI-bilder\", \"bildgenerering\", or when a brief calls for imagery that doesn't exist in assets/. Covers the local lab loop (generate → review → pick) and uploading winners to the Typeroll media library.\n---\n\n# Generate images for a Typeroll site (local lab → media library)\n\nImage generation runs **locally** in the site workdir — provider API keys\nlive in the folder's `.env`, candidates land in `images/lab/`, and only\nthe picked winners are uploaded to the Typeroll media library via the\nregular media tools (see `tr-images` for the upload/variants half).\n\nWhy local: you can look at the candidates (Read the files), iterate on\nprompts cheaply, and never ship a key or a reject anywhere.\n\n## Folder convention\n\n```\n<site>/\n├── .env # provider keys — GITIGNORED, never committed\n├── prompts/\n│ └── image-style.md # the site's image style profile (see below)\n└── images/\n └── lab/ # generated candidates — gitignored, disposable\n```\n\n`.env` keys (only the ones the user has — check before assuming):\n\n```\nGEMINI_API_KEY=...\nOPENAI_API_KEY=...\n```\n\n(Higgsfield needs no key here — it connects as an MCP server, see below.)\n\nLoad them per-command (`source .env` doesn't persist between Bash calls):\n\n```bash\nexport $(grep -v '^#' .env | xargs) # prepend to each generation command\n```\n\n## The style profile — prompts/image-style.md\n\nEvery site gets ONE style profile that you **prepend to every image\nprompt**. This is what keeps 20 images generated across 5 sessions\nlooking like one site. Derive it from `assets/brand.md` + the brief if\nit doesn't exist yet, and confirm it with the user before generating at\nscale. Keep it short (5–10 lines): art direction, palette, mood,\nphotography vs illustration, what to avoid.\n\nExample shape:\n\n```markdown\n# Bildstil — <Sajtnamn>\nVarm, folklig illustration med mjuka rundade former. Platt 2D med\nsubtila skuggor — ingen 3D, ingen fotorealism. Palett: kobolt #1F4FB8,\nsol #FFC83D, grädde #FFF8EC; accenter sparsamt. Människor: enkla,\ninkluderande, glada — inga karikatyrer. Undvik: stockfoto-känsla, text\ni bilden, logotyper, watermarks.\n```\n\n## Generate candidates\n\nName candidates descriptively: `images/lab/<motiv>-<modell>-<n>.png`.\nGenerate 3–6 candidates per slot (mix models when several keys exist),\nthen **Read the files to actually look at them** before showing the\nuser your shortlist.\n\n### Gemini (gemini-2.5-flash-image)\n\n```bash\ncurl -s \"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-image:generateContent\" \\\n -H \"x-goog-api-key: $GEMINI_API_KEY\" -H 'Content-Type: application/json' \\\n -d '{\n \"contents\": [{\"parts\": [{\"text\": \"<STYLE PROFILE>\\n\\n<MOTIF PROMPT>\"}]}],\n \"generationConfig\": {\"imageConfig\": {\"aspectRatio\": \"16:9\"}}\n }' | jq -r '.candidates[0].content.parts[] | select(.inlineData) | .inlineData.data' \\\n | base64 -d > images/lab/hero-gemini-1.png\n```\n\nAspect ratios: `1:1`, `16:9`, `4:3`, `3:4`, `9:16`. Gemini also does\nimage *editing* — pass an existing image as an `inlineData` part plus an\ninstruction to restyle/extend it (useful for \"same illustration but\nwinter\").\n\n### OpenAI (gpt-image-1)\n\n```bash\ncurl -s https://api.openai.com/v1/images/generations \\\n -H \"Authorization: Bearer $OPENAI_API_KEY\" -H 'Content-Type: application/json' \\\n -d '{\n \"model\": \"gpt-image-1\",\n \"prompt\": \"<STYLE PROFILE>\\n\\n<MOTIF PROMPT>\",\n \"size\": \"1536x1024\",\n \"quality\": \"high\"\n }' | jq -r '.data[0].b64_json' | base64 -d > images/lab/hero-openai-1.png\n```\n\nSizes: `1024x1024`, `1536x1024` (landscape), `1024x1536` (portrait).\n\n### Higgsfield (MCP server — no API key)\n\nHiggsfield exposes its models through a hosted MCP server at\n`https://mcp.higgsfield.ai/mcp` (OAuth-protected — first connect opens a\nbrowser login; no secret lands in any file). Add it next to the\ntyperoll server in the site folder's `.mcp.json`:\n\n```json\n\"higgsfield\": { \"type\": \"http\", \"url\": \"https://mcp.higgsfield.ai/mcp\" }\n```\n\nThen use its tools directly — list what's available rather than\nassuming tool names. Save/download outputs into `images/lab/` with the\nsame naming convention, and prepend the style profile to prompts here\ntoo. **Headless caveat:** OAuth-protected MCP servers need an existing\nlogin session on the machine — connect once interactively before\nrelying on it in a headless run. (The same URL works as a custom\nconnector in Claude Desktop, for editors who don't use Claude Code.)\n\n## Review → pick → upload\n\n1. **Look at every candidate** (Read the image files) and write one line\n per candidate in `build-log.md` (keep/reject + why).\n2. Show the user the shortlist (file paths) and let them pick — unless\n they've delegated the pick to you.\n3. Upload winners with the regular media tools (see `tr-images`):\n - small files → `upload_media_inline` (base64);\n - larger → `create_upload_url` + HTTP PUT + `finalize_media`.\n Give real `alt` text and a descriptive filename at upload time.\n4. `generate_image_variants` for responsive sizes when the image is\n placed full-width.\n5. Place via `update_block` (`core/image`, hero fields, …) or page HTML.\n6. `images/lab/` is disposable — leave rejects there; never upload them.\n\n## Pitfalls\n\n- **Never put text in generated images** (headlines, buttons) — text is\n HTML's job; generated text renders as gibberish in non-English. And\n models sneak text onto surfaces where it \"feels natural\" — jerseys,\n signs, banners, packages — even when the prompt doesn't ask for any.\n Forbid it explicitly in the prompt (\"plain unmarked clothing, no\n text, no letters, no numbers anywhere in the image\") and make the\n same rule part of every site's style profile.\n- **Don't commit `.env` or `images/lab/`** — both are gitignored by the\n kit convention; keep it that way.\n- **Cost discipline:** generation costs real money per image. Batch\n thoughtfully (3–6 per slot, not 20), and reuse via Gemini's\n edit-an-image mode instead of regenerating from scratch.\n- **Licensing/provenance:** AI-generated imagery is fine for site\n decoration, but never generate fake \"photos\" of real people, products\n the customer doesn't sell, or anything presented as documentary fact.\n- **The style profile is the contract.** If the user rejects a batch on\n style grounds, fix `prompts/image-style.md` first, then regenerate —\n don't just tweak one prompt.\n",
16
16
  "tr-images": "---\nname: tr-images\ndescription: Use when the user asks for an image, hero, illustration, or logo to be created and embedded in a Typeroll page. Covers the two-step signed-URL upload flow so the agent doesn't try to POST bytes through the CMS API (it can't).\n---\n\n# Add images to a Typeroll site\n\nThe Typeroll API does NOT accept image bytes directly. Uploads go\nthrough a signed PUT URL straight to Cloudflare R2, and the API only\nsees the metadata. Two-step flow:\n\nFor public source URLs, prefer `upload_media_from_url`; for 2–50 files use\n`upload_media_batch_from_urls` (max 25 MiB per file). Both download, upload,\nand finalize each asset automatically. Batch results preserve input order and\nreport failures per item, so retry only the failed entries. Use the manual\nsigned-URL flow below for local files or when you need direct control over the\nPUT.\n\n## Recipe\n\n### 1. Get an image\n\nOptions, in order of preference:\n\na. **Reuse an existing one.** `list_media` returns CDN URLs for every\n image already on this site. Search the list before generating\n anything — saves bandwidth and keeps the visual catalog tight.\n\nb. **Generate locally.** The user's Claude Code installation has\n access to whatever image-gen tools they've configured (DALL-E,\n Midjourney, Stable Diffusion, Replicate, etc.). Generate and save\n to a tempfile.\n\nc. **Source from the web** with appropriate licensing (the user is\n responsible for clearing rights). Save locally before upload.\n\n### 2. Mint a signed upload URL\n\n```\ncreate_upload_url filename=\"hero-services.png\"\n content_type=\"image/png\"\n size=<bytes>\n alt_text=\"Office worker reviewing documents at a desk\"\n```\n\nReturns:\n\n```json\n{\n \"upload_url\": \"https://...r2.cloudflarestorage.com/.../signed-...\",\n \"cdn_url\": \"https://cdn.example.com/orgs/.../images/...png\",\n \"key\": \"orgs/.../images/...png\",\n \"media_id\": \"abc123\",\n \"expires_in\": 300\n}\n```\n\nThe signed URL is valid for 5 minutes. The media doc is already\nregistered — even before the upload completes — so it'll show in\n`list_media` immediately.\n\n### 3. PUT the bytes\n\nOutside the MCP, hit the signed URL directly:\n\n```\nPUT <upload_url>\nContent-Type: <same content_type as in step 2>\nBody: <file bytes>\n```\n\nIn a shell:\n\n```bash\ncurl -sS -X PUT --data-binary @hero-services.png \"$UPLOAD_URL\"\n```\n\nOptionally with `-H \"Content-Type: image/png\"` if you need to override what R2 will infer. **Nothing else.**\n\n> The signed URL embeds checksum-related query parameters\n> (`x-amz-checksum-crc32`, `x-amz-sdk-checksum-algorithm`) for legacy\n> SDK compatibility, but `X-Amz-SignedHeaders=host` — only the host\n> header is part of the signature. Sending the `x-amz-*` values as\n> request headers yields `403 SignatureDoesNotMatch`. Don't. Just\n> `--data-binary` the file at the URL.\n\nOr from JS (Claude Code can run a one-line script):\n\n```js\nawait fetch(uploadUrl, {\n method: 'PUT',\n headers: { 'Content-Type': contentType },\n body: await fs.readFile(path),\n});\n```\n\nParallelise N uploads via shell `&` + `wait`:\n\n```bash\nwhile IFS=$'\\t' read -r filename signed_url; do\n curl -sS -X PUT --data-binary @\"$filename\" \"$signed_url\" &\ndone < manifest.tsv\nwait\n```\n\nA 200 OK from R2 means the image is now live at `cdn_url`.\n\n### 4. Patch metadata (alt text, etc.)\n\nYou set `alt_text` at create time, but if you generate the image first\nand only THEN realize what to caption it as, patch later:\n\n```\nupdate_media media_id=<id> alt_text=\"...\" filename=\"hero-services-v2.png\"\n```\n\n### 4b. Fill missing alt-text on existing media\n\nWhen a customer has uploaded a bunch of images without alt-text (very\ncommon after a WP migration), don't make it up — use vision:\n\n```\nlist_media → find items with empty alt_text\nsuggest_alt_text_context media_id=<id> → returns { image_url, suggested_prompt,\n language, used_on_pages, current_alt_text }\n# Pass image_url + the returned suggested_prompt to YOUR OWN vision\n# capability (you can fetch the URL and pass bytes to vision).\nupdate_media media_id=<id> alt_text=\"<what vision returned>\"\n```\n\nThe prompt is tuned for SEO-grade output: short (5-15 words), no \"image\nof / picture of\" filler, written in the site's content language,\ndecorative images return empty string. Run it sequentially on a\nlist_media batch and you can fix alt-text gaps across a whole site\nwithout burning your context on prompt design. The platform does NOT\nrun vision on your behalf — your model does, your usage.\n\n### 5. Embed in a page\n\n`read_page` the target, insert `<img>` in the right spot:\n\n```html\n<img src=\"<cdn_url>\"\n alt=\"<alt_text>\"\n style=\"width: 100%; height: auto; display: block; margin: 2rem 0;\" />\n```\n\nThen `update_page` with the new HTML. Or, if you're generating a hero\nfor a brand-new page, include the `<img>` directly in `create_page`'s\n`html_content`.\n\n## Pitfalls\n\n- **Always set `alt_text`.** Empty alt is bad for SEO + accessibility.\n Default to a one-sentence description of what's in the image.\n- **`<script>` etc. in SVGs.** The page sanitizer drops `<script>`\n inside SVG, so an icon set that includes script-based animations\n won't render correctly. Use static SVG or a JPG/PNG export.\n- **CSS background-image references aren't dedup'd.** If you set the\n same image as a CSS background on multiple pages, the alt-text +\n metadata are page-irrelevant. The sanitizer allows\n `background-image: url(...)` in inline styles, but think about\n whether an `<img>` is actually better.\n- **Source URL leakage.** If you generated the image from a prompt\n that contains internal info, don't bake that prompt into the\n filename. Use a descriptive but generic filename.\n\n## Format choice\n\n- **PNG** for logos, icons with hard edges, anything with text.\n- **JPG** for photos. Smaller file, better for big hero images.\n- **WebP** if the target audience runs modern browsers (95%+ in 2026).\n- **SVG** for icons + simple illustrations. Vector scales perfectly.\n- **PDF** is supported by `create_upload_url` for document downloads;\n link with `<a href>`, not `<img>`.\n",
17
- "tr-import-url": "---\nname: tr-import-url\ndescription: Use when the user wants to import or migrate content from a non-WordPress website — a Squarespace site, a Wix site, a static HTML site, a Webflow export, or any URL the user points at. Also triggers on \"copy content from\", \"rebuild this site\", \"import from Squarespace/Wix/Webflow\", or \"make it look like this site\". For WordPress sources use tr-migrate-wp instead.\n---\n\n# Import content from a non-WordPress site\n\n> **The buffer model (draft writes).** Every content write in this recipe\n> (pages, blocks, partials, collection items) lands in an unsaved per-doc\n> DRAFT — deploys and plain previews only see SAVED content. For recipe-style\n> build work, pass `save: true` on write calls (the work is pre-approved by\n> the task itself), or run `commit_working_copy` per doc before any\n> `trigger_deploy`. Preview your drafts with `include_working_copy: true`.\n\n\n## When to use this vs tr-migrate-wp\n\n| Source | Use |\n|---|---|\n| WordPress with `/wp-json` accessible | `tr-migrate-wp` |\n| WordPress with REST disabled | This skill (scrape HTML) |\n| Squarespace, Wix, Webflow, static HTML | This skill |\n| CSV / spreadsheet data | This skill (skip scraping, just parse) |\n| Any URL the user points at | This skill |\n\n## Preconditions\n\n- Target Typeroll site exists with working header/footer.\n- Source URL(s) accessible (check with a quick `fetch`; if blocked, mention\n it and ask the user for an HTML export or screenshot).\n\n## Recipe\n\n### 1. Inventory the source site\n\nFetch the homepage and build a URL list:\n\n```\nfetch <source-url> # root HTML\nfetch <source-url>/sitemap.xml # XML sitemap if it exists\n```\n\nParse `<a href>` links to discover internal pages. Build a list:\n- Homepage\n- Top-level pages (About, Services, Contact, etc.)\n- Any sub-pages that look important\n\nAvoid: pagination URLs, session URLs, `/wp-admin`, `/cdn-cgi/`, query strings.\n\n### 2. Learn the target's design\n\n```\nread_site_settings\nread_partial partial_id=\"header\"\nlist_pages limit=5\n```\n\nThe goal is to understand what CSS variables, class names, and structural\nconventions the target site uses so the imported content looks native.\n\n### 3. Fetch and clean each source page\n\nFor each URL:\n\n**a. Fetch the HTML.**\n```\nfetch <page-url>\n```\n\nIf the site returns a bot-block (Cloudflare, 403, or clearly JS-only\nSPA output), note it. Tell the user: \"This page blocked direct fetching.\nCan you provide the page source or an HTML export?\"\n\n**b. Extract the main content.**\n\nDiscard: nav, header, footer, cookie banners, chat widgets, scripts.\nKeep: `<main>`, `<article>`, the largest content region.\n\nClean the HTML:\n- Strip platform-specific classes: `sqsrte-*`, `wf-*`, `et_*`,\n `elementor-*`, `fl-*`, `divi-*`, `vc_*`\n- Remove empty `<div>`, `<span>`, `<section>` wrappers (no class, no content)\n- Unwrap redundant nesting: `<div><p>text</p></div>` → `<p>text</p>`\n- Keep: `<h1>`–`<h6>`, `<p>`, `<ul>`, `<ol>`, `<img>`, `<a>`, `<table>`,\n `<blockquote>`, `<figure>`, `<figcaption>`, `<strong>`, `<em>`\n- Fix headings: ensure exactly one `<h1>` per page (the page title)\n\n**c. Transfer images.** For each `<img src>`:\n```\nupload_media_from_url url=\"<source-img-url>\" alt=\"...\"\n```\nReplace the src with the returned CDN URL. Skip tracking pixels\n(1×1 images), decorative SVGs that are just icons, and anything\nthat 404s.\n\n**d. Adapt to the target's design.**\nReplace source-specific CSS classes with target conventions.\nUse `var(--color-*)` for colors, `var(--font-*)` for type.\n\n### 4. Create pages as drafts\n\n```\ncreate_page title=\"Om oss\" slug=\"om-oss\"\n html_content=\"<cleaned, adapted HTML>\"\n content_mode=\"html\" status=\"draft\"\n seo_title=\"Om oss — Acme\"\n seo_description=\"...\"\n```\n\nAlways draft first. The user signs off before publishing.\n\n### 5. Handle redirects\n\nIf the source URLs differ from the target slugs, create redirects:\n\n```\ncreate_redirect from_path=\"/about\" to_path=\"/om-oss\"\ncreate_redirect from_path=\"/services.html\" to_path=\"/tjanster\"\n```\n\n### 6. Preview with the user\n\n```\nget_preview_link\n```\n\nWalk through every imported page with the user. Common issues:\n- Heading hierarchy wrong (two H1s, or H3 used where H2 belongs)\n- Images missing alt text\n- Squarespace column layouts that don't work without their grid system\n- Embedded forms or maps that need re-setup\n\n### 7. Publish + deploy\n\nAfter approval:\n```\nbatch_update_pages updates=[\n {page_id: \"om-oss\", patch: {status: \"published\"}},\n {page_id: \"tjanster\", patch: {status: \"published\"}}\n]\ntrigger_deploy\nget_deploy_status job_id=<id>\n```\n\n## Platform-specific notes\n\n### Squarespace\n- Main content is inside `.content-wrapper` or `[data-section-theme]` blocks\n- Portfolio images are usually high-resolution originals in `/universal/images/`\n- JSON-LD is Squarespace's own schema — strip it\n- Gallery blocks → convert to CSS grid with inline `<img>` tags\n\n### Wix\n- Wix sites are React SPAs — `fetch` returns an empty shell\n- Ask the user for the Wix site's \"Export to HTML\" (available in some plans)\n or take screenshots for reference\n- Best path: get content from the user (text + image files), rebuild clean\n\n### Webflow\n- Usually fetchable; clean output\n- Classes like `w-container`, `w-row`, `w-col-*` can be stripped\n- Webflow CMS items are server-rendered — they appear in the HTML\n\n### Static HTML / old sites\n- Often the cleanest import. Fetch, strip nav/footer, keep body.\n- Watch for table-based layouts (pre-2010 sites) — convert to CSS grid\n\n## Pitfalls\n\n- **Don't import `<style>` blocks from the source site.** They reference\n external fonts, resets, and classes that don't exist in the target.\n Strip all `<style>` tags from source HTML and rewrite styles in the\n target's conventions.\n- **Don't break the single-H1 rule.** Many source sites have no H1 or\n several. Fix it.\n- **Squarespace/Wix forms.** They won't work after import — the backend\n is vendor-locked. Create a Typeroll form instead: `create_form`.\n- **Analytics/tracking code.** If the source has GA4 or similar, don't\n copy it into pages. Set it via `update_site_settings scripts_head=\"...\"`.\n- **Videos.** YouTube/Vimeo embeds are fine (`<iframe>` is allowed).\n Hosted MP4s need re-uploading if the source URL won't persist.\n",
18
- "tr-migrate-astro": "---\nname: tr-migrate-astro\ndescription: Use when the user wants to migrate an Astro site — particularly an Astro Content Collections-backed site — to Typeroll. Walks `src/content/<collection>/*.md(x)`, lifts frontmatter into Typeroll collection schemas, converts markdown bodies into richtext fields, batch-imports items, then maps `src/pages/*` into Typeroll pages/partials. Triggers on \"migrate an Astro site\", \"import from src/content\", \"convert content collections\", or when the user names a local Astro repo as the source.\n---\n\n# Migrate an Astro site to Typeroll\n\n> **The buffer model (draft writes).** Every content write in this recipe\n> (pages, blocks, partials, collection items) lands in an unsaved per-doc\n> DRAFT — deploys and plain previews only see SAVED content. For recipe-style\n> build work, pass `save: true` on write calls (the work is pre-approved by\n> the task itself), or run `commit_working_copy` per doc before any\n> `trigger_deploy`. Preview your drafts with `include_working_copy: true`.\n\n\nAstro's [Content Collections](https://docs.astro.build/en/guides/content-collections/) and Typeroll's `Collection` + `CollectionItem` map one-to-one. A collection in Astro is a directory of frontmatter-bearing files under `src/content/<name>/`; in Typeroll it's a schema + items doc set under `organizations/{org}/sites/{site}/collections/{name}/`. The Astro schema (`zod.object(...)` in `src/content/config.ts`) is your field list. The frontmatter values are field values. The markdown bodies are richtext fields.\n\nThis skill walks the migration from a checked-out Astro repo on the user's machine to a target Typeroll site. You run it locally — the source repo is on disk, the MCP just receives the final shape.\n\n## Preconditions\n\n- The Astro repo is checked out locally and `npm install`d (so we can read `src/content/config.ts` to extract the schemas).\n- `@typeroll/mcp-server` configured with a valid `TYPEROLL_API_KEY` pointing at the target site (or org-scoped key + a `site_id` argument per call).\n- Target Typeroll site exists. **Empty starter site is best.** If non-empty, treat existing pages and collections as off-limits unless the user explicitly says otherwise.\n- The Astro design / theme is **not** being migrated — Typeroll has its own design layer. We migrate content; the user re-skins on the Typeroll side.\n\n## Recipe\n\n### 1. Map the source\n\nFrom the Astro repo root:\n\n```bash\nls src/content/ # which collections exist?\ncat src/content/config.ts # collection schemas (zod)\nls src/pages/ # standalone pages\nls public/ # static assets and images\n```\n\nBuild a working manifest:\n\n| Astro source | Typeroll target |\n|---|---|\n| `src/content/blog/*.md` | Collection `blog` with items |\n| `src/content/projects/*.mdx` | Collection `projects` with items |\n| `src/pages/about.astro` | Page with slug `about` |\n| `src/pages/services/[slug].astro` | If dynamic from a collection → that collection's `route_template`. If genuinely per-page → individual Typeroll pages. |\n| `public/og/*.png`, `public/images/*` | Upload to Typeroll media via `upload_media_from_url` (after staging them on a temporary public URL) or via local upload if the MCP supports it. |\n| `src/layouts/*.astro` | Header / footer / shared chunks → Typeroll partials. The rest of the layout is the site design, owned by the target site. |\n| `src/components/*.astro` | Either become partials (if reused across pages) or get inlined into the page that uses them. |\n\n### 2. Learn the target's design (don't skip)\n\n```\nget_site\nread_site_settings # colours, fonts, voice\nlist_partials\nread_partial partial_id=\"header\"\nlist_pages limit=5\n```\n\nSame rule as in `tr-migrate-wp`: you're moving content into the *target's* visual language, not preserving the source's. Note the existing fonts, colour vars, header structure.\n\n### 3. Translate one collection schema\n\nPick the most representative collection first (usually `blog`). Read its zod schema:\n\n```ts\n// src/content/config.ts\nconst blog = defineCollection({\n type: 'content',\n schema: z.object({\n title: z.string(),\n description: z.string(),\n pubDate: z.coerce.date(),\n updatedDate: z.coerce.date().optional(),\n heroImage: z.string().optional(),\n author: z.string().default('Editorial'),\n tags: z.array(z.string()).default([]),\n draft: z.boolean().default(false),\n }),\n});\n```\n\nMap zod types to Typeroll field types:\n\n| Astro zod | Typeroll field type |\n|---|---|\n| `z.string()` | `text` (or `textarea` if it's a description/excerpt — judge by typical length) |\n| `z.string().long()` / a description field | `textarea` |\n| `z.coerce.date()` / `z.date()` | `date` |\n| `z.number()` | `number` |\n| `z.boolean()` | `boolean` |\n| `z.string()` with image path / `image()` helper | `image` |\n| `z.array(z.string())` | `text` (comma-joined) or `tags` if you have a tags field type |\n| `z.enum([...])` | `text` with a comment about the allowed values; the model writes the listing logic |\n| `z.object({...})` (nested) | Flatten into prefixed fields, or pre-render into a `*_html` field (see `tr-collection-template`) |\n| Markdown body | `body: richtext` (the markdown content of the file, converted to HTML — see §4) |\n\nCreate the collection with the design template baked in (this is the new pattern — read `tr-blog` if you haven't yet):\n\n```\ncreate_collection {\n \"name\": \"blog\",\n \"label_singular\": \"Article\",\n \"label_plural\": \"Articles\",\n \"slug_field\": \"slug\",\n \"sort_field\": \"date\",\n \"sort_dir\": \"desc\",\n \"route_template\": \"/blog/{slug}\",\n \"fields\": [\n {\"name\":\"title\", \"type\":\"text\", \"required\":true},\n {\"name\":\"slug\", \"type\":\"text\", \"required\":true},\n {\"name\":\"description\", \"type\":\"textarea\"},\n {\"name\":\"date\", \"type\":\"date\", \"required\":true},\n {\"name\":\"updated_date\",\"type\":\"date\"},\n {\"name\":\"hero_image\", \"type\":\"image\"},\n {\"name\":\"author\", \"type\":\"text\"},\n {\"name\":\"tags\", \"type\":\"text\"},\n {\"name\":\"body\", \"type\":\"richtext\"}\n ],\n \"item_template_html\": \"<article class=\\\"post\\\">...</article>\"\n}\n```\n\nField-name rules: ASCII, lowercase, `[a-z][a-z0-9_-]*`. The Astro source `pubDate` → Typeroll `date`. `updatedDate` → `updated_date` (snake_case is fine; camelCase isn't).\n\n### 4. Convert markdown bodies → richtext\n\nAstro stores the markdown body as the file content after the `---` frontmatter fence. Typeroll's `body: richtext` wants HTML. Pick one of:\n\n**Option A — use Astro's own markdown renderer (preferred when the repo already builds):**\n\n```js\nimport { unified } from 'unified';\nimport remarkParse from 'remark-parse';\nimport remarkRehype from 'remark-rehype';\nimport rehypeStringify from 'rehype-stringify';\n\nconst md2html = async (md) => {\n const file = await unified()\n .use(remarkParse)\n .use(remarkRehype, { allowDangerousHtml: true })\n .use(rehypeStringify, { allowDangerousHtml: true })\n .process(md);\n return String(file);\n};\n```\n\nRun this on the body of every collection file. Image references inside the markdown (`![alt](./hero.png)`) need their `src` rewritten to the uploaded Typeroll CDN URLs after step 6.\n\n**Option B — convert ad-hoc:** `marked`, `markdown-it`, or any other parser. Just stay consistent across files so the HTML output looks uniform.\n\nTyperoll's sanitiser will strip `<script>` and event handlers from the output regardless. If the markdown carried embedded raw HTML you want to keep (iframes for video, etc.), check the sanitiser config in `packages/site-template/src/lib/sanitize.ts` for the whitelist.\n\n### 5. Resolve and upload images\n\nFor every image referenced by an item (`heroImage`, images inside the markdown body):\n\n1. Stage the local file at a temporary public URL (or use a local-upload MCP tool if one is configured).\n2. `upload_media_from_url url=<staged-url> alt=<from frontmatter or filename>` — record the returned CDN URL.\n3. Substitute the CDN URL into both the `hero_image` field value AND the markdown-converted HTML body (regex replace `src=` references).\n\nKeep a local map (`./astro-migration-state.json`):\n\n```json\n{\n \"media\": {\n \"./hero.png\": \"https://cdn.typeroll.com/<orgId>/<siteId>/abc123.png\"\n }\n}\n```\n\nSo a partial run is resumable and you don't re-upload the same image twice.\n\n### 6. Batch-import items\n\nFor each file in `src/content/<collection>/`:\n\n```js\nimport fs from 'node:fs';\nimport path from 'node:path';\nimport matter from 'gray-matter';\n\nconst files = fs.readdirSync('src/content/blog').filter(f => /\\.mdx?$/.test(f));\nfor (const file of files) {\n const raw = fs.readFileSync(`src/content/blog/${file}`, 'utf8');\n const { data, content } = matter(raw);\n const slug = file.replace(/\\.mdx?$/, '');\n const body = await md2html(content);\n\n // create_collection_item via the MCP\n await mcp.callTool('create_collection_item', {\n collection: 'blog',\n status: data.draft ? 'draft' : 'published',\n fields: {\n title: data.title,\n slug,\n description: data.description,\n date: data.pubDate ? new Date(data.pubDate).toISOString().slice(0, 10) : null,\n updated_date: data.updatedDate ? new Date(data.updatedDate).toISOString().slice(0, 10) : null,\n hero_image: mediaMap[data.heroImage] || data.heroImage,\n author: data.author,\n tags: (data.tags || []).join(', '),\n body,\n },\n });\n}\n```\n\nRate-limit awareness: spawn at most 5 parallel `create_collection_item` calls; the API caps at ~60 writes/minute and responds with 429 + `Retry-After` if exceeded.\n\nSet `status: 'draft'` (or honour Astro's `draft: true` frontmatter) for items the user should review before publishing. Only published items get static pages.\n\n### 7. Build the listing page\n\n```\ncreate_page title=\"Blog\" slug=\"blog\" status=\"published\" content_mode=\"html\"\n html_content=\"<section><h1>Blog</h1>\n <!-- typeroll:listing:blog -->\n <!-- /typeroll:listing:blog -->\n</section>\"\n\nregenerate_collection_listing\n collection=\"blog\"\n page_id=\"blog\"\n item_template=\"<article class=\\\"blog-card\\\"><a href=\\\"{{url}}\\\"><h2>{{title}}</h2><p>{{description}}</p><time>{{date}}</time></a></article>\"\n wrap_open=\"<div class=\\\"blog-grid\\\">\"\n wrap_close=\"</div>\"\n```\n\nSee `tr-blog` for full styling and post-import update flow.\n\n### 8. Translate standalone pages\n\nFor each non-dynamic `.astro` page under `src/pages/`:\n\n```\nread_partial partial_id=\"header\" # learn target's nav style\n# Re-skin the page content using target site's CSS variables and partials.\ncreate_page title=\"About\" slug=\"about\" status=\"draft\" content_mode=\"html\"\n html_content=\"...\"\n```\n\nDefault to **draft** — the user reviews each page before publishing.\n\nFor dynamic Astro pages (`[slug].astro` that consume a collection), you're already done: the matching Typeroll collection's `route_template` produces the same URLs at build time.\n\n### 9. Add redirects for URL changes\n\nIf Astro's slugs differed from what you derived for Typeroll (e.g. Astro had `/posts/my-article` but you want `/blog/my-article`), bulk-add redirects:\n\n```\nadd_redirect from=\"/posts/my-article\" to=\"/blog/my-article\" status=301\n```\n\nOr build a redirect map from the `astro-migration-state.json` and apply it in one pass.\n\n### 10. Deploy\n\n```\ntrigger_deploy\nget_deploy_status job_id=<id>\n```\n\nBrowse the resulting site, compare against the Astro source, surface anything that drifted.\n\n## Astro-specific gotchas\n\n- **`.mdx` files with custom components.** Components inside MDX (`<MyCustom prop=\"...\" />`) will not render in Typeroll because the component definitions don't migrate. Two options: (1) replace each component with its rendered HTML output (run the Astro build, scrape the rendered HTML, use that as `body`); (2) if the component is a reusable visual element used across many items, factor it into a Typeroll partial and replace MDX usages with `<x-include name=\"...\" />` calls. See `tr-page-template` for the partial-include pattern.\n- **`src/content/config.ts` typed `image()` helper.** Astro resolves `image()` fields at build time to optimised assets. After migration the Typeroll `hero_image` field carries the original source URL — re-upload via step 5 to get it onto the Typeroll CDN.\n- **`getCollection` filters at runtime.** If `src/pages/blog/index.astro` does `getCollection('blog', ({ data }) => !data.draft)`, Typeroll handles this via `status` on the item. Don't translate the filter — set `status: 'draft'` on items where `data.draft === true`.\n- **Per-tag pages (`/blog/tag/[tag].astro`).** Typeroll doesn't auto-generate these. Either (a) drop tag pages and use a client-side filter in the blog listing JS, (b) generate them by enumerating unique tags and calling `create_page` per tag with a server-side-pre-filtered listing. (a) is preferable for ≤dozens of tags.\n- **`rehype-pretty-code` / shiki / fenced code with syntax highlighting.** The HTML output of these contains inline styles that the Typeroll sanitiser preserves. The colours match the Astro site's theme at import time; redoing the target design later means re-running the highlighter against the same markdown source. Keep a copy of the raw markdown if you might.\n- **`og:image` per page from `astro-og-image` style plugins.** Typeroll has its own SEO surface (`seo_title`, `seo_description`, `seo_og_image`). Set them explicitly on each page during step 8.\n- **i18n via `src/content/<lang>/<collection>/`.** Typeroll's site-level `default_language` + per-page `language` field cover this (capabilities: `supports_language_per_page: true`). Map each language directory to per-item `language: 'sv-SE' | 'en-US' | …` instead of separate collections.\n\n## Mixing imported + new content\n\nHalf-migrate, leave drafts, let the user inspect, iterate. You can:\n\n- Import only collections, skip standalone pages, let the user rebuild those from scratch.\n- Import everything as `status: 'draft'`, treat publishing as a per-item human review pass.\n- Mix sources: pull article bodies from Astro markdown, use Claude to generate fresh excerpts/SEO metadata before writing the item.\n\nKeep the local `astro-migration-state.json` honest — it's the only way to make a partial run resumable when the API rate-limits or a markdown parser trips on an edge case file.\n",
19
- "tr-migrate-multisite": "---\nname: tr-migrate-multisite\ndescription: 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.\n---\n\n# Migrate a multisite / multi-domain family to Typeroll\n\n> **The buffer model (draft writes).** Every content write in this recipe\n> (pages, blocks, partials, collection items) lands in an unsaved per-doc\n> DRAFT — deploys and plain previews only see SAVED content. For recipe-style\n> build work, pass `save: true` on write calls (the work is pre-approved by\n> the task itself), or run `commit_working_copy` per doc before any\n> `trigger_deploy`. Preview your drafts with `include_working_copy: true`.\n\n## The first decision: one site or many?\n\n**One Typeroll site owns exactly one domain** (plus its apex/www sibling).\nSo:\n\n| The old family looks like | Build it as |\n|---|---|\n| `example.se`, `example.de`, `example.co.uk` — separate domains | **N Typeroll sites**, one per domain |\n| `example.com/se/`, `example.com/de/` — one domain, language folders | **One site**, using `path` on each page (`/se/om-oss`) |\n| A WP multisite on subdomains that the customer wants to consolidate onto one domain | **One site** + redirects from every old subdomain |\n\nConfirm this with the user before creating anything — it's the one decision\nthat is expensive to reverse (domains, deploys, and analytics all hang off\nit). The rest of this recipe assumes the common case: **N sites, one per\ndomain**, sharing a design.\n\nRequires an **org-scoped API key** (`create_site` needs it, and one key\nreaching every site is the whole point here). With MCP you pass `site_id`\nper call; over stdio you'll be re-pointing `TYPEROLL_SITE_ID` per site.\n\n## Phase 0 — Plan the batch (do this once, in writing)\n\nProduce a table and get the user to confirm it before touching the platform:\n\n| Old URL | Language | New Typeroll site | Domain | Notes |\n|---|---|---|---|---|\n\n**Check platform readiness before the plan is even agreed:**\n\n```\nget_migration_readiness site_id=<any existing site>\n```\n\nThen once per market, with that market's own source:\n\n```\nget_migration_readiness site_id=<de-site> source_url=\"https://example.de\"\n```\n\nThe blockers (media storage, hosting credentials) are **platform-level, not\nper-site** — if they fail for one site they fail for all ten, and finding out\nafter building three sites means redoing three sites' worth of image work.\nGet them fixed before Phase 1, and re-run the check per site once the sites\nexist (the warnings — verification URL, form email, design reference — are\nper-site).\n\nAsk explicitly:\n- Which site is the **design reference**? Build that one properly first.\n- Are the sites **translations of each other** (same page structure) or\n independent? This decides whether hreflang clusters are mechanical or\n hand-mapped.\n- Any domains being **retired or merged**? Those need redirects at the DNS\n level, not just inside a site.\n\n## Phase 1 — Inventory EVERY old site, before building anything\n\nDo this for all sites up front. It's cheap, it's the only artefact that tells\nyou when you're done, and it stops you discovering an untouched 400-URL blog\nin week three.\n\nFor each source site, create the target site first (the inventory lives on\nit):\n\n```\ncreate_site name=\"Example DE\" domain=\"example.de\"\n```\n\nThen walk the source and post what you find:\n\n```\nfetch https://example.de/sitemap.xml # follow sitemap-index children\nfetch https://example.de/wp-json/wp/v2/pages?per_page=100 # walk X-WP-TotalPages\n```\n\n```\nadd_migration_urls site_id=<de-site> source_origin=\"https://example.de\" source=\"sitemap\" urls=[\n { url: \"https://example.de/ueber-uns\" },\n { url: \"https://example.de/kontakt\" },\n …\n]\n```\n\n**`source_origin` is not optional in a multisite job.** It rejects URLs from\nanother origin, which is the guard that stops domain B's `/kontakt` landing\nin domain A's inventory — where it would silently read as \"covered\" because\ndomain A happens to have a `/kontakt` too.\n\nAdd every source you have, each with its own label — they merge per URL:\n- `source=\"sitemap\"` — the sitemap(s)\n- `source=\"rest\"` — WP REST pages/posts/custom types\n- `source=\"gsc\"` — a Search Console export, **with `gsc_clicks`**. This is\n what makes the coverage report prioritise itself: the 12 URLs carrying all\n the traffic sort to the top.\n- `source=\"crawl\"` — anything you found by following internal links\n\nMark the obvious throwaways immediately, so the work list is real:\n\n```\nadd_migration_urls urls=[{ url: \"/wp-admin\", excluded: true }, { url: \"/tag/nyheter\", excluded: true }]\n```\n\nRead it back and note the starting number:\n\n```\nlist_migration_urls site_id=<de-site> status=\"unhandled\" limit=50\n```\n\n## Phase 2 — Build the reference site\n\nFollow `tr-migrate-wp` (or `tr-import-url`) for the ONE reference site:\ndesign, header/footer partials, page templates, block types. Get the user to\napprove it before replicating — every fix you make after this point costs\nN times as much.\n\n## Phase 3 — Replicate the design to the other sites\n\nDesign travels as a block-type package, not by hand:\n\n```\nexport_block_types site_id=<reference> # → .tcblocks JSON\nimport_block_types site_id=<other> package=<that JSON>\n```\n\nThen per site:\n- `read_site_settings` on the reference → `update_site_settings` on the\n target with the same colors/fonts (translate `site_name`, `tagline`,\n contact details — those are per-market, not shared).\n- Recreate header/footer partials with translated nav labels.\n- Page templates: rebuild with `add_block target={kind:'template', id:…}`.\n\nSet the language per site — it drives `<html lang>`, `og:locale` and\nalt-text generation:\n\n```\nupdate_site site_id=<de-site> language=\"de\"\n```\n\n## Phase 4 — Migrate content, preserving paths\n\nPer site, per URL, follow `tr-migrate-wp` §3. Two rules that matter more here\nthan in a single-site migration:\n\n1. **Preserve the path verbatim** unless there's a reason not to. Use\n `path` for anything nested: `create_page title=\"Über uns\" slug=\"ueber-uns\"\n path=\"/ueber-uns\"`. A preserved path needs no redirect and loses nothing.\n2. **Rewrite internal links to the NEW paths.** Imported HTML is full of\n absolute links to the old domain. Sweep them per site:\n\n```\nbulk_replace_text site_id=<de-site> find=\"https://example.de/\" replace=\"/\" dry_run=true\n```\n\nCheck the dry-run count against what you expect before running it for real.\nCross-domain links between sister sites stay absolute — only the site's own\ndomain becomes relative.\n\n### Media: per site, not shared\n\nEvery Typeroll site has its own media library, so a shared asset (the group\nlogo, a product shot used in all markets) is uploaded once **per site** and\ngets a different CDN URL in each. That's correct — the sites are independent\nand one market's deploy must not depend on another's assets — but it means:\n\n- Don't try to reuse a `cdn_url` from site A inside site B's HTML. It will\n render, and it will break the day site A is deleted or moved.\n- Do write alt text per market, in that market's language:\n `update_media media_id=… alt_text=\"…\"`. The alt text is content, not\n metadata, and a Swedish alt on a German page is a real accessibility defect.\n\nImages referenced only from a stylesheet or from unrendered page-builder JSON\nare NOT found by an HTML scan. Spot-check the hero/background images of the\ntop pages in the preview before you call a site done.\n\n### Forms are NOT migrated — plan to rebuild them\n\nThe HTML cleaner strips `<form>`, `<input>`, `<select>` and `<button>`\nentirely, on purpose: a Contact Form 7 / Gravity / Elementor form posts to\nWordPress endpoints that no longer exist, so importing the markup would give\nyou a form that looks alive and silently drops every submission.\n\nSo, per site:\n\n```\ncreate_form name=\"Kontakt\" fields=[…] # or steps=[…] for a funnel\nadd_block target={kind:'page', id:'kontakt'} block={type:'core/form', data:{form_id:'<id>'}}\n```\n\nThen, still per site:\n\n- **Recipient address per market** — the German enquiries rarely go to the\n Swedish inbox. Check this explicitly; it is the single most common thing\n to get wrong in a batch of ten.\n- **Email delivery is configured per site** by an admin in the portal\n (Settings → Integrations), not through this API. Flag it to the user as a\n manual step — a form that saves submissions but sends no notification looks\n fine in testing and loses leads in production.\n- **Submit a real test through every form** after deploy, and confirm both the\n stored submission and the notification email.\n\nCount the old site's forms during Phase 1 and put them in the plan table.\nTen sites × three forms is thirty forms, and it is the part of the job that\nnever shows up in a URL inventory.\n\n## Phase 5 — Redirects for everything you didn't preserve\n\nWork the coverage report, not your memory:\n\n```\nlist_migration_urls site_id=<de-site> status=\"unhandled\"\n```\n\nFor each entry, one of three outcomes — no fourth option:\n\n- It moved → `create_redirect from_path=\"/alte-seite\" to_path=\"/neue-seite\"`\n- It's gone on purpose → `update_migration_url url_id=… excluded=true notes=\"Old campaign LP, signed off by <name>\"`\n- It should exist and doesn't → go back and migrate it\n\nRe-read the list. `unhandled` reaching zero is the exit condition for this\nphase.\n\n**Clear the URL families with one rule each**, per site — a WP network\nmultiplies the same dead shapes across every market:\n\n```\ncreate_redirect site_id=<de-site> from_path=\"/category/*\" to_path=\"/blogg/:splat\"\ncreate_redirect site_id=<de-site> from_path=\"/tag/*\" to_path=\"/blogg\"\ncreate_redirect site_id=<de-site> from_path=\"/2019/*\" to_path=\"/blogg/:splat\"\n```\n\nOnly a TRAILING `*` is supported (`:splat` replays the remainder); `:name`\nmatches one segment. Pattern-covered inventory URLs count as `redirected`, so\nthe work list actually empties. A pattern that would hide a live page is\nrefused, naming the pages — narrow the prefix rather than working around it.\n\nWatch the per-market prefixes: the German site's archive base is `/kategorie/`,\nnot `/category/`. Write the rules from each site's own inventory, never by\ncopying the reference site's.\n\n## Phase 6 — Wire the hreflang cluster\n\nThis is the step that only exists because the family is multi-domain, and the\none most likely to be skipped. Each page declares its siblings on the other\ndomains; the renderer adds the page's own self-reference.\n\n```\nupdate_page site_id=<se-site> page_id=\"om-oss\" patch={ alternates: [\n { hreflang: \"de\", href: \"https://example.de/ueber-uns\" },\n { hreflang: \"en-GB\", href: \"https://example.co.uk/about-us\" },\n { hreflang: \"x-default\", href: \"https://example.com/about-us\" }\n]}\n```\n\nRules the search engines actually enforce:\n- **Reciprocal.** Every page in a cluster must list every other one. Write\n all N sides or the cluster is ignored. `batch_update_pages` is the sane way\n to do this once you have the mapping table.\n- **One `x-default`** per cluster, pointing at the language selector or the\n fallback market. Optional, but useful when the family doesn't cover a\n visitor's language.\n- **Absolute URLs on the final domain** — not the `*.typeroll` fallback\n subdomain. The cluster is what you want live after cutover, and a fallback\n URL in there is a leak you'll be cleaning up for months.\n- Invalid entries are **rejected at write time** with the reason. If a write\n fails, fix the tag/href — don't strip the field to make it pass.\n\nPages that have no equivalent on the other domains get no alternates at all.\nA cluster of one is meaningless markup.\n\n## Phase 7 — Verify BEFORE touching DNS\n\nDeploy each site (`trigger_deploy`), then check what it actually serves:\n\n```\nverify_migration_urls site_id=<de-site> source_origin=\"https://example.de\" check_source=true\n```\n\nThis requests every inventory URL against the site's fallback subdomain —\nthe real domain still points at the old host, which is exactly why the check\nis possible at all. Verdicts:\n\n| Verdict | Meaning | Action |\n|---|---|---|\n| `ok` | 200 at the same path | none |\n| `ok_redirect` | redirects to a 200 | none; flatten if `hops` > 1 |\n| `missing` | 404/410 | **the gap** — redirect it or migrate it |\n| `broken_redirect` | loop, or chain ending on an error | fix the rule |\n| `error` | 5xx / timeout | inconclusive, re-run |\n\n`check_source=true` also requests the OLD site, so a URL that already 404s\nupstream shows up as noise in the inventory rather than as a migration\nfailure — mark those `excluded`.\n\nSlash-equivalent source URLs are one inventory decision but remain separate\nverification requests through `observed_paths`. Therefore `checked` can exceed\nthe normalized inventory size, and a missing slash variant remains a launch\nblocker. Redirect builds cover both source spellings and send internal targets\nto the site's configured trailing-slash style.\n\nNote what the check does NOT catch: it verifies that a URL *resolves*, not\nthat the page at the other end is the right content. Review content and SEO\nparity, then record the exact dataset and result with\n`record_migration_seo_acceptance`.\n\nIterate until `missing` and `broken_redirect` are both zero **on every site**.\nThen, per site:\n\n1. `add_domain` with the intended apex/www canonical; leave `auto_deploy` on\n2. Wait for that canonical/sitemap deploy to succeed, then point DNS\n3. `poll_domain` until verified — there is no separate activation step\n4. Re-run `verify_migration_urls target_origin=\"https://example.de\"` against\n the real domain, to confirm the cutover kept what the pre-check proved\n5. Submit the new sitemap in Search Console; keep the old property open for\n a few weeks and watch the 404 report\n6. Rerun the complete, unfiltered URL check and SEO review, then require\n `get_migration_launch_report` to return `launch_ready: true`\n\n## Definition of done (per site)\n\n- [ ] `get_migration_readiness source_url=<this market's old site>` → `ready: true`, warnings reviewed\n- [ ] `list_migration_urls status=\"unhandled\"` → 0\n- [ ] Complete `verify_migration_urls` → 0 `missing`, 0 `broken_redirect`, 0 `error`, 0 untested variants\n- [ ] `record_migration_seo_acceptance` is accepted for the latest deploy\n- [ ] `get_migration_launch_report` → `launch_ready: true`\n- [ ] hreflang cluster written on both/all sides, absolute, final domains\n- [ ] `language` set on the site; `<html lang>` correct in the deployed HTML\n- [ ] Internal links rewritten (no lingering absolute links to the old domain)\n- [ ] Forms rebuilt, recipient address correct for THIS market, test submission sent and received\n- [ ] Media uploaded to this site's own library (no cross-site `cdn_url`), alt text in this market's language\n- [ ] Domain declared, canonical deploy succeeded, DNS verified; sitemap submitted\n\n## Pitfalls specific to this job\n\n- **Don't share one inventory across domains.** Inventory entries key on\n path; two markets both have `/kontakt`. One site, one inventory.\n- **Don't build all N sites in parallel from scratch.** Build one, approve,\n replicate. Parallel building multiplies every design mistake by N.\n- **Don't skip the parity check because coverage says 100%.** Coverage is a\n claim about the data; parity is a measurement of the server. They disagree\n exactly when it matters — an unpublished target page, a typo'd path, a\n redirect chain.\n- **Don't point DNS site-by-site on a whim.** Cutting over one market at a\n time is fine and usually wise, but the hreflang cluster spans markets: a\n page pointing at a domain that still serves the old site is pointing at\n content that doesn't match. Either cut over close together, or write the\n cluster after the last market lands.\n",
20
- "tr-migrate-wp": "---\nname: tr-migrate-wp\ndescription: Use when the user asks to migrate a WordPress site to Typeroll, mentions wp-json, or names a WP source URL. Walks the WP REST API, rebuilds pages in the target site's design, transfers media, sets redirects, leaves everything as drafts for human review.\n---\n\n# Migrate from WordPress to Typeroll\n\n> **The buffer model (draft writes).** Every content write in this recipe\n> (pages, blocks, partials, collection items) lands in an unsaved per-doc\n> DRAFT — deploys and plain previews only see SAVED content. For recipe-style\n> build work, pass `save: true` on write calls (the work is pre-approved by\n> the task itself), or run `commit_working_copy` per doc before any\n> `trigger_deploy`. Preview your drafts with `include_working_copy: true`.\n\n\nThe platform's in-portal migration workflow is the \"managed\" path for\ncustomers who want one-click. This skill is the \"power-user\" path: you\ndo it locally, mix data sources freely, and the user (consultant /\nagency) reviews each step in their terminal.\n\n## Preconditions\n\n**Run the readiness check FIRST — before touching any content:**\n\n```\nget_migration_readiness source_url=\"https://oldsite.com\"\n```\n\nWhen the migration includes reusable page/item layouts, include each proposed\nblock composition and its collection fields in this same call. A\n`waiting_for_native_support` result means leave that template intact and do\nnot replace the gap with generic custom blocks, raw HTML, or corrective site\nCSS. Independent content and SEO work may continue. Rerun the review after the\nrequired Core version is deployed, then verify preview and a fresh hosted\nbuild.\n\nPass `source_url` — that adds the checks on the site you're migrating FROM.\nAn old host that answers 403/429 to server-side requests is a **blocker**: the\nimport would produce empty pages, or pages containing the host's block page,\nwhich reads as real content and is worse. Whether `/wp-json` answers is a\nwarning, since scraping is a real fallback (it just loses ACF/custom fields).\n\nIf `ready` is false, STOP and report the blockers to the user. Do not start\nthe import \"and fix it after\": every blocker is one whose failure is invisible\nonce the work is done, so discovering it late means redoing the expensive part.\n\n- **Media storage** — without it, every `<img>` keeps its WordPress URL. The\n new site looks perfect and is still served images by the old host. It breaks\n the day the customer cancels that hosting, months later, all at once.\n- **Hosting adapter** — without credentials, deploys return a job id and\n publish nothing, while reporting success.\n\nWarnings are worth relaying but don't stop you: no verification origin (the\npre-cutover parity check can't run), no AI reconstruction key, forms without a\nnotification address, or a target site with no design to rebuild INTO.\n\nThen the ordinary preconditions:\n\n- `@typeroll/mcp-server` configured with a valid `TYPEROLL_API_KEY`.\n- The source WP site has `/wp-json` reachable (Google for \"wordpress\n REST API disabled\" if not — common for hardened hosts).\n- The Typeroll target site exists **and already carries the design** —\n settings, header/footer, one or two example pages. The migration rebuilds\n old content in the NEW design; with nothing to imitate it inherits the old\n site's look.\n- If the target already has content, you must NOT clobber it — always\n `list_pages` first and only write to slugs that don't already exist.\n\n## Recipe\n\n### 1. Probe and inventory\n\n```\nfetch <wp-url>/wp-json # confirm REST is on\nfetch <wp-url>/wp-sitemap.xml or /sitemap.xml # URL inventory\n```\n\nBuild a list of every URL you intend to migrate. WP custom post types\nneed their REST endpoint (e.g. `/wp-json/wp/v2/news?per_page=100`),\nwalking `X-WP-TotalPages` to paginate.\n\n### 2. Learn the target's design\n\n```\nget_site\nread_site_settings # colors, fonts, voice cues\nlist_partials # header / footer / shared\nread_partial partial_id=\"header\" # nav structure\nlist_pages limit=5\nbatch_read_pages page_ids=[<2-3 representative ids>] # see actual conventions\n```\n\nDon't skip this. Imposing a stranger's design on a customer's site is\nthe biggest avoidable mistake.\n\n### 3. Migrate one page at a time, draft status\n\nFor each source URL:\n\na. Fetch from WP. Prefer the helper plugin's authenticated endpoint\n (`/wp-json/typeroll/v1/...`) if available — it bypasses\n `show_in_rest=false` and returns ACF + builder fields. Otherwise\n fall back to `/wp-json/wp/v2/<post-type>?slug=<slug>`.\n\nb. Clean the HTML. Strip Elementor / Gutenberg / Breakdance class\n soup. Drop empty `<div>` and `<span>` wrappers. Keep semantic tags,\n tables, iframes from known hosts (YouTube / Vimeo / Calendly).\n\nc. Migrate referenced images:\n - For each `<img src>` and CSS `background-image: url()`:\n 1. Download the source image locally.\n 2. `create_upload_url filename=... content_type=...` → returns\n `{ upload_url, cdn_url, media_id }`.\n 3. PUT the bytes to `upload_url` (curl or fetch with the same\n content type).\n 4. Replace the `src` with `cdn_url` in the rewritten HTML.\n - Use `update_media media_id=... alt_text=\"...\"` to set a real alt\n text (existing WP `alt` attribute or `aria-label`; fall back to\n filename only as a last resort).\n\nd. Reconstruct in the target's design with native blocks first. Read the\n available block types and map headings, prose, images, buttons and layout\n into their typed fields. Use HTML mode only for source-specific markup that\n has no native representation and has passed the composition preflight.\n\ne. Write the page as a draft:\n\n ```\n create_page title=\"...\" slug=\"<last-path-segment>\"\n path=\"/<preserved-wordpress/path/>\"\n blocks=[<native block tree>]\n status=\"draft\" kind=\"article\" author=\"...\"\n seo_title=\"...\" seo_description=\"...\"\n ```\n\n **Preserve the source URL.** WP post URLs like\n `/2024/01/foo-bar/` uses `slug: \"foo-bar\"` and\n `path: \"/2024/01/foo-bar/\"`. Slug is one segment; `path` preserves the\n complete nested URL.\n\n### 4. Redirects\n\nAfter migration, every URL the agent didn't preserve verbatim needs a\nredirect:\n\n```\ncreate_redirect from_path=\"/old-services\" to_path=\"/services\"\n```\n\nWalk the inventory; for each URL: did it become a page with the same\npath? If yes, no redirect. If renamed, `create_redirect`. If\nintentionally dropped, mark it `excluded` via `update_migration_url` (the\ncustomer should sign off on every dropped URL).\n\n**Use wildcards for WordPress's URL families.** A WP site's dead URLs come in\nshapes, not as individuals — and the inventory only knows the ones it found,\nwhile the old site had more (paginated archives, feeds, attachment pages). One\npattern rule retires the whole family:\n\n| WordPress shape | Rule |\n|---|---|\n| Category archives | `from_path=\"/category/*\"` → `to_path=\"/blogg/:splat\"` (or a single landing page) |\n| Tag archives | `from_path=\"/tag/*\"` → `to_path=\"/blogg\"` |\n| Author archives | `from_path=\"/author/*\"` → `to_path=\"/om-oss\"` |\n| Date-based permalinks | `from_path=\"/2019/*\"` → `to_path=\"/blogg/:splat\"` — one rule per year |\n| Old post prefix → new | `from_path=\"/blog/:slug\"` → `to_path=\"/artiklar/:slug\"` |\n| Feeds | `from_path=\"/feed/*\"` → `to_path=\"/blogg\"` |\n\nRules:\n\n- **Trailing `*` only.** A mid-path splat (`/blog/*/comments`) is dropped\n silently by Cloudflare — the platform refuses it at write time.\n- **`:splat`** carries the captured remainder; **`:name`** matches exactly one\n segment and can be replayed by name.\n- **A pattern that would hide a live page is refused**, naming the pages. That\n is the platform protecting you: redirects are applied before static files, so\n `/blogg/*` would make every real article under `/blogg/` unreachable. Narrow\n the prefix instead.\n- **Query-string URLs can't be matched.** WP's `/?p=123` has no path to key on;\n those need handling at the source (or accept the loss and mark them excluded).\n- Rules are emitted most-specific-first, so a narrow rule always beats a broad\n one — you can safely have `/blogg/recept/*` alongside `/blogg/*`.\n\nThen verify against reality before anything is cut over:\n\n```\nimport_sitemap url=\"https://old.example.com/sitemap.xml\"\n# Optional: direct Search Console query (platform service account must have property access)\nimport_gsc_performance property=\"https://old.example.com/\" months=6\n# Or paste a Search Console Pages CSV via csv=\"...\" and source_origin.\ncheck_internal_links # database preflight before deploy\nverify_migration_urls # after trigger_deploy; compact exceptions by default\nrecord_migration_seo_acceptance # after reviewing this exact deployment\nget_migration_launch_report # final fail-closed launch gate\n```\n\nThe inventory merges slash-equivalent URLs into one work item but preserves\ntheir source spellings in `observed_paths`. Verification requests every one of\nthose spellings; treat a failing variant as a real gap even when coverage is\notherwise complete. Fresh builds expand redirect sources to both slash forms.\n\nFor a site imported before Typeroll normalized WordPress plain-text fields,\naudit and repair the legacy records before visual review:\n\n```\nrepair_migration_plain_text # dry_run=true by default; returns exact field diffs\n```\n\nThis operation is deliberately limited to `title`, `seo_title`,\n`seo_description`, and schema-defined plain-text `excerpt` fields. It cannot\ntouch bodies, HTML, slugs, paths, or URLs. Show all returned diffs and\nconflicts to the user. Only after explicit approval, repeat the same scope and\nselectors with `dry_run=false save=true`. If `truncated=true`, narrow the\nselection or raise `diff_limit` and review the omitted diffs first. Never work\naround a `working_copy` conflict: that resource contains another edit which\nmust be resolved separately.\n\n### 5. Preview + review with the user\n\n```\nget_preview_link page_id=<id> # one URL the user can click\n```\n\nOpen in the user's browser. The preview navigates the whole site from\none mint. Iterate on feedback: pages, header, footer.\n\n### 6. Ship\n\nWhen the user signs off:\n\n```\n# Bulk-publish drafts that look right\nbatch_update_pages updates=[{page_id, patch:{status:\"published\"}}, ...]\n\n# Deploy\ntrigger_deploy\nget_deploy_status job_id=<id> # poll\n```\n\n## Pitfalls\n\n- **Don't publish during migration.** Always import as `draft`. Even\n if the agent is confident, the customer needs the chance to spot-check.\n- **WP slugs sometimes drift.** A post saved with slug `foo-bar` may\n have been served at `/2024/01/foo-bar/` due to the permalink\n structure. The full URL is what users see in Google; preserve that,\n not the bare slug.\n- **Image bandwidth.** R2 upload is metered. Use `find_pages_matching`\n contains=\"<old-domain>\" on already-imported content to spot images\n that weren't transferred.\n- **WP-specific JSON-LD** (Yoast, Rank Math) is usually wrong after a\n redesign because it references old URLs. Strip it; let Typeroll\n emit fresh Article/Page schemas via `kind: 'article'` + `author`.\n\n## When the source isn't WordPress\n\nThe same shape applies for any source — Squarespace export, custom\nCMS, scraped HTML, CSV. Replace step 1's \"WP REST\" probe with whatever\ndiscovery the source supports, and the rest of the recipe is unchanged.\n",
17
+ "tr-import-url": "---\nname: tr-import-url\ndescription: Use when the user wants to import or migrate content from a non-WordPress website — a Squarespace site, a Wix site, a static HTML site, a Webflow export, or any URL the user points at. Also triggers on \"copy content from\", \"rebuild this site\", \"import from Squarespace/Wix/Webflow\", or \"make it look like this site\". For WordPress sources use tr-migrate-wp instead.\n---\n\n# Import content from a non-WordPress site\n\n> **The buffer model (draft writes).** Every content write in this recipe\n> (pages, blocks, partials, collection items) lands in an unsaved per-doc\n> DRAFT — deploys and plain previews only see SAVED content. For recipe-style\n> build work, pass `save: true` on write calls (the work is pre-approved by\n> the task itself), or run `commit_working_copy` per doc before any\n> `trigger_deploy`. Preview your drafts with `include_working_copy: true`.\n\n\n## When to use this vs tr-migrate-wp\n\n| Source | Use |\n|---|---|\n| WordPress with `/wp-json` accessible | `tr-migrate-wp` |\n| WordPress with REST disabled | This skill (scrape HTML) |\n| Squarespace, Wix, Webflow, static HTML | This skill |\n| CSV / spreadsheet data | This skill (skip scraping, just parse) |\n| Any URL the user points at | This skill |\n\n## Preconditions\n\n- Target Typeroll site exists with working header/footer.\n- Source URL(s) accessible (check with a quick `fetch`; if blocked, mention\n it and ask the user for an HTML export or screenshot).\n\n## Recipe\n\n### 1. Inventory the source site\n\nFetch the homepage and build a URL list:\n\n```\nfetch <source-url> # root HTML\nfetch <source-url>/sitemap.xml # XML sitemap if it exists\n```\n\nParse `<a href>` links to discover internal pages. Build a list:\n- Homepage\n- Top-level pages (About, Services, Contact, etc.)\n- Any sub-pages that look important\n\nAvoid: pagination URLs, session URLs, `/wp-admin`, `/cdn-cgi/`, query strings.\n\n### 2. Learn the target's design\n\n```\nread_site_settings\nread_partial partial_id=\"header\"\nlist_pages limit=5\n```\n\nThe goal is to understand what CSS variables, class names, and structural\nconventions the target site uses so the imported content looks native.\n\n### 3. Fetch and clean each source page\n\nFor each URL:\n\n**a. Fetch the HTML.**\n```\nfetch <page-url>\n```\n\nIf the site returns a bot-block (Cloudflare, 403, or clearly JS-only\nSPA output), note it. Tell the user: \"This page blocked direct fetching.\nCan you provide the page source or an HTML export?\"\n\n**b. Extract the main content.**\n\nDiscard: nav, header, footer, cookie banners, chat widgets, scripts.\nKeep: `<main>`, `<article>`, the largest content region.\n\nClean the HTML:\n- Strip platform-specific classes: `sqsrte-*`, `wf-*`, `et_*`,\n `elementor-*`, `fl-*`, `divi-*`, `vc_*`\n- Remove empty `<div>`, `<span>`, `<section>` wrappers (no class, no content)\n- Unwrap redundant nesting: `<div><p>text</p></div>` → `<p>text</p>`\n- Keep: `<h1>`–`<h6>`, `<p>`, `<ul>`, `<ol>`, `<img>`, `<a>`, `<table>`,\n `<blockquote>`, `<figure>`, `<figcaption>`, `<strong>`, `<em>`\n- Fix headings: ensure exactly one `<h1>` per page (the page title)\n\n**c. Transfer images.** For each `<img src>`:\n```\nupload_media_from_url url=\"<source-img-url>\" alt=\"...\"\n```\nReplace the src with the returned CDN URL. Skip tracking pixels\n(1×1 images), decorative SVGs that are just icons, and anything\nthat 404s.\n\n**d. Adapt to the target's design.**\nReplace source-specific CSS classes with target conventions.\nUse `var(--color-*)` for colors, `var(--font-*)` for type.\n\n### 4. Create pages as drafts\n\n```\ncreate_page title=\"Om oss\" slug=\"om-oss\"\n html_content=\"<cleaned, adapted HTML>\"\n content_mode=\"html\" status=\"draft\"\n seo_title=\"Om oss — Acme\"\n seo_description=\"...\"\n```\n\nAlways draft first. The user signs off before publishing.\n\n### 5. Handle redirects\n\nIf the source URLs differ from the target slugs, create redirects:\n\n```\ncreate_redirect from_path=\"/about\" to_path=\"/om-oss\"\ncreate_redirect from_path=\"/services.html\" to_path=\"/tjanster\"\n```\n\n### 6. Preview with the user\n\n```\nget_preview_link\n```\n\nWalk through every imported page with the user. Common issues:\n- Heading hierarchy wrong (two H1s, or H3 used where H2 belongs)\n- Images missing alt text\n- Squarespace column layouts that don't work without their grid system\n- Embedded forms or maps that need re-setup\n\n### 7. Publish + deploy\n\nAfter approval:\n```\nbatch_update_pages updates=[\n {page_id: \"om-oss\", patch: {status: \"published\"}},\n {page_id: \"tjanster\", patch: {status: \"published\"}}\n]\ntrigger_deploy\nget_deploy_status job_id=<id>\n```\n\n## Platform-specific notes\n\n### Squarespace\n- Main content is inside `.content-wrapper` or `[data-section-theme]` blocks\n- Portfolio images are usually high-resolution originals in `/universal/images/`\n- JSON-LD is Squarespace's own schema — strip it\n- Gallery blocks → convert to CSS grid with inline `<img>` tags\n\n### Wix\n- Wix sites are React SPAs — `fetch` returns an empty shell\n- Ask the user for the Wix site's \"Export to HTML\" (available in some plans)\n or take screenshots for reference\n- Best path: get content from the user (text + image files), rebuild clean\n\n### Webflow\n- Usually fetchable; clean output\n- Classes like `w-container`, `w-row`, `w-col-*` can be stripped\n- Webflow CMS items are server-rendered — they appear in the HTML\n\n### Static HTML / old sites\n- Often the cleanest import. Fetch, strip nav/footer, keep body.\n- Watch for table-based layouts (pre-2010 sites) — convert to CSS grid\n\n## Pitfalls\n\n- **Don't import `<style>` blocks from the source site.** They reference\n external fonts, resets, and classes that don't exist in the target.\n Strip all `<style>` tags from source HTML and rewrite styles in the\n target's conventions.\n- **Don't break the single-H1 rule.** Many source sites have no H1 or\n several. Fix it.\n- **Squarespace/Wix forms.** They won't work after import — the backend\n is vendor-locked. Create a Typeroll form instead: `create_form`.\n- **Analytics/tracking code.** If the source has GA4 or similar, don't\n copy it into pages. Set it via `update_site_settings scripts_head=\"...\"`.\n- **Videos.** YouTube/Vimeo embeds are fine (`<iframe>` is allowed).\n Hosted MP4s need re-uploading if the source URL won't persist.\n\n## Storage prerequisite\n\nBefore reading or importing source content, call `get_import_readiness` (Core\n0.1.95 / MCP 0.44.68 or later). If `ready` is false, stop the import and show the\nreturned message and Publishing settings link. The organization must connect\nand verify its own storage first. Do not fall back to draft storage, hotlink\nsource images, or use ordinary page-write tools to bypass the import gate.\nUse `upload_media_from_url` for referenced source images; the customer’s\nCloudflare transfer Worker copies and verifies them directly in R2, independently\nof the GitHub/Cloudflare build-provider choice.\n",
18
+ "tr-migrate-astro": "---\nname: tr-migrate-astro\ndescription: Use when the user wants to migrate an Astro site — particularly an Astro Content Collections-backed site — to Typeroll. Walks `src/content/<collection>/*.md(x)`, lifts frontmatter into Typeroll collection schemas, converts markdown bodies into richtext fields, batch-imports items, then maps `src/pages/*` into Typeroll pages/partials. Triggers on \"migrate an Astro site\", \"import from src/content\", \"convert content collections\", or when the user names a local Astro repo as the source.\n---\n\n# Migrate an Astro site to Typeroll\n\n> **The buffer model (draft writes).** Every content write in this recipe\n> (pages, blocks, partials, collection items) lands in an unsaved per-doc\n> DRAFT — deploys and plain previews only see SAVED content. For recipe-style\n> build work, pass `save: true` on write calls (the work is pre-approved by\n> the task itself), or run `commit_working_copy` per doc before any\n> `trigger_deploy`. Preview your drafts with `include_working_copy: true`.\n\n\nAstro's [Content Collections](https://docs.astro.build/en/guides/content-collections/) and Typeroll's `Collection` + `CollectionItem` map one-to-one. A collection in Astro is a directory of frontmatter-bearing files under `src/content/<name>/`; in Typeroll it's a schema + items doc set under `organizations/{org}/sites/{site}/collections/{name}/`. The Astro schema (`zod.object(...)` in `src/content/config.ts`) is your field list. The frontmatter values are field values. The markdown bodies are richtext fields.\n\nThis skill walks the migration from a checked-out Astro repo on the user's machine to a target Typeroll site. You run it locally — the source repo is on disk, the MCP just receives the final shape.\n\n## Preconditions\n\n- The Astro repo is checked out locally and `npm install`d (so we can read `src/content/config.ts` to extract the schemas).\n- `@typeroll/mcp-server` configured with a valid `TYPEROLL_API_KEY` pointing at the target site (or org-scoped key + a `site_id` argument per call).\n- Target Typeroll site exists. **Empty starter site is best.** If non-empty, treat existing pages and collections as off-limits unless the user explicitly says otherwise.\n- The Astro design / theme is **not** being migrated — Typeroll has its own design layer. We migrate content; the user re-skins on the Typeroll side.\n\n## Recipe\n\n### 1. Map the source\n\nFrom the Astro repo root:\n\n```bash\nls src/content/ # which collections exist?\ncat src/content/config.ts # collection schemas (zod)\nls src/pages/ # standalone pages\nls public/ # static assets and images\n```\n\nBuild a working manifest:\n\n| Astro source | Typeroll target |\n|---|---|\n| `src/content/blog/*.md` | Collection `blog` with items |\n| `src/content/projects/*.mdx` | Collection `projects` with items |\n| `src/pages/about.astro` | Page with slug `about` |\n| `src/pages/services/[slug].astro` | If dynamic from a collection → that collection's `route_template`. If genuinely per-page → individual Typeroll pages. |\n| `public/og/*.png`, `public/images/*` | Upload to Typeroll media via `upload_media_from_url` (after staging them on a temporary public URL) or via local upload if the MCP supports it. |\n| `src/layouts/*.astro` | Header / footer / shared chunks → Typeroll partials. The rest of the layout is the site design, owned by the target site. |\n| `src/components/*.astro` | Either become partials (if reused across pages) or get inlined into the page that uses them. |\n\n### 2. Learn the target's design (don't skip)\n\n```\nget_site\nread_site_settings # colours, fonts, voice\nlist_partials\nread_partial partial_id=\"header\"\nlist_pages limit=5\n```\n\nSame rule as in `tr-migrate-wp`: you're moving content into the *target's* visual language, not preserving the source's. Note the existing fonts, colour vars, header structure.\n\n### 3. Translate one collection schema\n\nPick the most representative collection first (usually `blog`). Read its zod schema:\n\n```ts\n// src/content/config.ts\nconst blog = defineCollection({\n type: 'content',\n schema: z.object({\n title: z.string(),\n description: z.string(),\n pubDate: z.coerce.date(),\n updatedDate: z.coerce.date().optional(),\n heroImage: z.string().optional(),\n author: z.string().default('Editorial'),\n tags: z.array(z.string()).default([]),\n draft: z.boolean().default(false),\n }),\n});\n```\n\nMap zod types to Typeroll field types:\n\n| Astro zod | Typeroll field type |\n|---|---|\n| `z.string()` | `text` (or `textarea` if it's a description/excerpt — judge by typical length) |\n| `z.string().long()` / a description field | `textarea` |\n| `z.coerce.date()` / `z.date()` | `date` |\n| `z.number()` | `number` |\n| `z.boolean()` | `boolean` |\n| `z.string()` with image path / `image()` helper | `image` |\n| `z.array(z.string())` | `text` (comma-joined) or `tags` if you have a tags field type |\n| `z.enum([...])` | `text` with a comment about the allowed values; the model writes the listing logic |\n| `z.object({...})` (nested) | Flatten into prefixed fields, or pre-render into a `*_html` field (see `tr-collection-template`) |\n| Markdown body | `body: richtext` (the markdown content of the file, converted to HTML — see §4) |\n\nCreate the collection with the design template baked in (this is the new pattern — read `tr-blog` if you haven't yet):\n\n```\ncreate_collection {\n \"name\": \"blog\",\n \"label_singular\": \"Article\",\n \"label_plural\": \"Articles\",\n \"slug_field\": \"slug\",\n \"sort_field\": \"date\",\n \"sort_dir\": \"desc\",\n \"route_template\": \"/blog/{slug}\",\n \"fields\": [\n {\"name\":\"title\", \"type\":\"text\", \"required\":true},\n {\"name\":\"slug\", \"type\":\"text\", \"required\":true},\n {\"name\":\"description\", \"type\":\"textarea\"},\n {\"name\":\"date\", \"type\":\"date\", \"required\":true},\n {\"name\":\"updated_date\",\"type\":\"date\"},\n {\"name\":\"hero_image\", \"type\":\"image\"},\n {\"name\":\"author\", \"type\":\"text\"},\n {\"name\":\"tags\", \"type\":\"text\"},\n {\"name\":\"body\", \"type\":\"richtext\"}\n ],\n \"item_template_html\": \"<article class=\\\"post\\\">...</article>\"\n}\n```\n\nField-name rules: ASCII, lowercase, `[a-z][a-z0-9_-]*`. The Astro source `pubDate` → Typeroll `date`. `updatedDate` → `updated_date` (snake_case is fine; camelCase isn't).\n\n### 4. Convert markdown bodies → richtext\n\nAstro stores the markdown body as the file content after the `---` frontmatter fence. Typeroll's `body: richtext` wants HTML. Pick one of:\n\n**Option A — use Astro's own markdown renderer (preferred when the repo already builds):**\n\n```js\nimport { unified } from 'unified';\nimport remarkParse from 'remark-parse';\nimport remarkRehype from 'remark-rehype';\nimport rehypeStringify from 'rehype-stringify';\n\nconst md2html = async (md) => {\n const file = await unified()\n .use(remarkParse)\n .use(remarkRehype, { allowDangerousHtml: true })\n .use(rehypeStringify, { allowDangerousHtml: true })\n .process(md);\n return String(file);\n};\n```\n\nRun this on the body of every collection file. Image references inside the markdown (`![alt](./hero.png)`) need their `src` rewritten to the uploaded Typeroll CDN URLs after step 6.\n\n**Option B — convert ad-hoc:** `marked`, `markdown-it`, or any other parser. Just stay consistent across files so the HTML output looks uniform.\n\nTyperoll's sanitiser will strip `<script>` and event handlers from the output regardless. If the markdown carried embedded raw HTML you want to keep (iframes for video, etc.), check the sanitiser config in `packages/site-template/src/lib/sanitize.ts` for the whitelist.\n\n### 5. Resolve and upload images\n\nFor every image referenced by an item (`heroImage`, images inside the markdown body):\n\n1. Stage the local file at a temporary public URL (or use a local-upload MCP tool if one is configured).\n2. `upload_media_from_url url=<staged-url> alt=<from frontmatter or filename>` — record the returned CDN URL.\n3. Substitute the CDN URL into both the `hero_image` field value AND the markdown-converted HTML body (regex replace `src=` references).\n\nKeep a local map (`./astro-migration-state.json`):\n\n```json\n{\n \"media\": {\n \"./hero.png\": \"https://cdn.typeroll.com/<orgId>/<siteId>/abc123.png\"\n }\n}\n```\n\nSo a partial run is resumable and you don't re-upload the same image twice.\n\n### 6. Batch-import items\n\nFor each file in `src/content/<collection>/`:\n\n```js\nimport fs from 'node:fs';\nimport path from 'node:path';\nimport matter from 'gray-matter';\n\nconst files = fs.readdirSync('src/content/blog').filter(f => /\\.mdx?$/.test(f));\nfor (const file of files) {\n const raw = fs.readFileSync(`src/content/blog/${file}`, 'utf8');\n const { data, content } = matter(raw);\n const slug = file.replace(/\\.mdx?$/, '');\n const body = await md2html(content);\n\n // create_collection_item via the MCP\n await mcp.callTool('create_collection_item', {\n collection: 'blog',\n status: data.draft ? 'draft' : 'published',\n fields: {\n title: data.title,\n slug,\n description: data.description,\n date: data.pubDate ? new Date(data.pubDate).toISOString().slice(0, 10) : null,\n updated_date: data.updatedDate ? new Date(data.updatedDate).toISOString().slice(0, 10) : null,\n hero_image: mediaMap[data.heroImage] || data.heroImage,\n author: data.author,\n tags: (data.tags || []).join(', '),\n body,\n },\n });\n}\n```\n\nRate-limit awareness: spawn at most 5 parallel `create_collection_item` calls; the API caps at ~60 writes/minute and responds with 429 + `Retry-After` if exceeded.\n\nSet `status: 'draft'` (or honour Astro's `draft: true` frontmatter) for items the user should review before publishing. Only published items get static pages.\n\n### 7. Build the listing page\n\n```\ncreate_page title=\"Blog\" slug=\"blog\" status=\"published\" content_mode=\"html\"\n html_content=\"<section><h1>Blog</h1>\n <!-- typeroll:listing:blog -->\n <!-- /typeroll:listing:blog -->\n</section>\"\n\nregenerate_collection_listing\n collection=\"blog\"\n page_id=\"blog\"\n item_template=\"<article class=\\\"blog-card\\\"><a href=\\\"{{url}}\\\"><h2>{{title}}</h2><p>{{description}}</p><time>{{date}}</time></a></article>\"\n wrap_open=\"<div class=\\\"blog-grid\\\">\"\n wrap_close=\"</div>\"\n```\n\nSee `tr-blog` for full styling and post-import update flow.\n\n### 8. Translate standalone pages\n\nFor each non-dynamic `.astro` page under `src/pages/`:\n\n```\nread_partial partial_id=\"header\" # learn target's nav style\n# Re-skin the page content using target site's CSS variables and partials.\ncreate_page title=\"About\" slug=\"about\" status=\"draft\" content_mode=\"html\"\n html_content=\"...\"\n```\n\nDefault to **draft** — the user reviews each page before publishing.\n\nFor dynamic Astro pages (`[slug].astro` that consume a collection), you're already done: the matching Typeroll collection's `route_template` produces the same URLs at build time.\n\n### 9. Add redirects for URL changes\n\nIf Astro's slugs differed from what you derived for Typeroll (e.g. Astro had `/posts/my-article` but you want `/blog/my-article`), bulk-add redirects:\n\n```\nadd_redirect from=\"/posts/my-article\" to=\"/blog/my-article\" status=301\n```\n\nOr build a redirect map from the `astro-migration-state.json` and apply it in one pass.\n\n### 10. Deploy\n\n```\ntrigger_deploy\nget_deploy_status job_id=<id>\n```\n\nBrowse the resulting site, compare against the Astro source, surface anything that drifted.\n\n## Astro-specific gotchas\n\n- **`.mdx` files with custom components.** Components inside MDX (`<MyCustom prop=\"...\" />`) will not render in Typeroll because the component definitions don't migrate. Two options: (1) replace each component with its rendered HTML output (run the Astro build, scrape the rendered HTML, use that as `body`); (2) if the component is a reusable visual element used across many items, factor it into a Typeroll partial and replace MDX usages with `<x-include name=\"...\" />` calls. See `tr-page-template` for the partial-include pattern.\n- **`src/content/config.ts` typed `image()` helper.** Astro resolves `image()` fields at build time to optimised assets. After migration the Typeroll `hero_image` field carries the original source URL — re-upload via step 5 to get it onto the Typeroll CDN.\n- **`getCollection` filters at runtime.** If `src/pages/blog/index.astro` does `getCollection('blog', ({ data }) => !data.draft)`, Typeroll handles this via `status` on the item. Don't translate the filter — set `status: 'draft'` on items where `data.draft === true`.\n- **Per-tag pages (`/blog/tag/[tag].astro`).** Typeroll doesn't auto-generate these. Either (a) drop tag pages and use a client-side filter in the blog listing JS, (b) generate them by enumerating unique tags and calling `create_page` per tag with a server-side-pre-filtered listing. (a) is preferable for ≤dozens of tags.\n- **`rehype-pretty-code` / shiki / fenced code with syntax highlighting.** The HTML output of these contains inline styles that the Typeroll sanitiser preserves. The colours match the Astro site's theme at import time; redoing the target design later means re-running the highlighter against the same markdown source. Keep a copy of the raw markdown if you might.\n- **`og:image` per page from `astro-og-image` style plugins.** Typeroll has its own SEO surface (`seo_title`, `seo_description`, `seo_og_image`). Set them explicitly on each page during step 8.\n- **i18n via `src/content/<lang>/<collection>/`.** Typeroll's site-level `default_language` + per-page `language` field cover this (capabilities: `supports_language_per_page: true`). Map each language directory to per-item `language: 'sv-SE' | 'en-US' | …` instead of separate collections.\n\n## Mixing imported + new content\n\nHalf-migrate, leave drafts, let the user inspect, iterate. You can:\n\n- Import only collections, skip standalone pages, let the user rebuild those from scratch.\n- Import everything as `status: 'draft'`, treat publishing as a per-item human review pass.\n- Mix sources: pull article bodies from Astro markdown, use Claude to generate fresh excerpts/SEO metadata before writing the item.\n\nKeep the local `astro-migration-state.json` honest — it's the only way to make a partial run resumable when the API rate-limits or a markdown parser trips on an edge case file.\n\n## Storage prerequisite\n\nBefore reading or importing source content, call `get_import_readiness` (Core\n0.1.95 / MCP 0.44.68 or later). If `ready` is false, stop the import and show the\nreturned message and Publishing settings link. The organization must connect\nand verify its own storage first. Do not fall back to draft storage, hotlink\nsource images, or use ordinary page-write tools to bypass the import gate.\nUse `upload_media_from_url` for referenced source images; the customer’s\nCloudflare transfer Worker copies and verifies them directly in R2, independently\nof the GitHub/Cloudflare build-provider choice.\n",
19
+ "tr-migrate-multisite": "---\nname: tr-migrate-multisite\ndescription: 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.\n---\n\n# Migrate a multisite / multi-domain family to Typeroll\n\n> **The buffer model (draft writes).** Every content write in this recipe\n> (pages, blocks, partials, collection items) lands in an unsaved per-doc\n> DRAFT — deploys and plain previews only see SAVED content. For recipe-style\n> build work, pass `save: true` on write calls (the work is pre-approved by\n> the task itself), or run `commit_working_copy` per doc before any\n> `trigger_deploy`. Preview your drafts with `include_working_copy: true`.\n\n## The first decision: one site or many?\n\n**One Typeroll site owns exactly one domain** (plus its apex/www sibling).\nSo:\n\n| The old family looks like | Build it as |\n|---|---|\n| `example.se`, `example.de`, `example.co.uk` — separate domains | **N Typeroll sites**, one per domain |\n| `example.com/se/`, `example.com/de/` — one domain, language folders | **One site**, using `path` on each page (`/se/om-oss`) |\n| A WP multisite on subdomains that the customer wants to consolidate onto one domain | **One site** + redirects from every old subdomain |\n\nConfirm this with the user before creating anything — it's the one decision\nthat is expensive to reverse (domains, deploys, and analytics all hang off\nit). The rest of this recipe assumes the common case: **N sites, one per\ndomain**, sharing a design.\n\nRequires an **org-scoped API key** (`create_site` needs it, and one key\nreaching every site is the whole point here). With MCP you pass `site_id`\nper call; over stdio you'll be re-pointing `TYPEROLL_SITE_ID` per site.\n\n## Phase 0 — Plan the batch (do this once, in writing)\n\nProduce a table and get the user to confirm it before touching the platform:\n\n| Old URL | Language | New Typeroll site | Domain | Notes |\n|---|---|---|---|---|\n\n**Check platform readiness before the plan is even agreed:**\n\n```\nget_migration_readiness site_id=<any existing site>\n```\n\nThen once per market, with that market's own source:\n\n```\nget_migration_readiness site_id=<de-site> source_url=\"https://example.de\"\n```\n\nThe blockers (media storage, hosting credentials) are **platform-level, not\nper-site** — if they fail for one site they fail for all ten, and finding out\nafter building three sites means redoing three sites' worth of image work.\nGet them fixed before Phase 1, and re-run the check per site once the sites\nexist (the warnings — verification URL, form email, design reference — are\nper-site).\n\nAsk explicitly:\n- Which site is the **design reference**? Build that one properly first.\n- Are the sites **translations of each other** (same page structure) or\n independent? This decides whether hreflang clusters are mechanical or\n hand-mapped.\n- Any domains being **retired or merged**? Those need redirects at the DNS\n level, not just inside a site.\n\n## Phase 1 — Inventory EVERY old site, before building anything\n\nDo this for all sites up front. It's cheap, it's the only artefact that tells\nyou when you're done, and it stops you discovering an untouched 400-URL blog\nin week three.\n\nFor each source site, create the target site first (the inventory lives on\nit):\n\n```\ncreate_site name=\"Example DE\" domain=\"example.de\"\n```\n\nThen walk the source and post what you find:\n\n```\nfetch https://example.de/sitemap.xml # follow sitemap-index children\nfetch https://example.de/wp-json/wp/v2/pages?per_page=100 # walk X-WP-TotalPages\n```\n\n```\nadd_migration_urls site_id=<de-site> source_origin=\"https://example.de\" source=\"sitemap\" urls=[\n { url: \"https://example.de/ueber-uns\" },\n { url: \"https://example.de/kontakt\" },\n …\n]\n```\n\n**`source_origin` is not optional in a multisite job.** It rejects URLs from\nanother origin, which is the guard that stops domain B's `/kontakt` landing\nin domain A's inventory — where it would silently read as \"covered\" because\ndomain A happens to have a `/kontakt` too.\n\nAdd every source you have, each with its own label — they merge per URL:\n- `source=\"sitemap\"` — the sitemap(s)\n- `source=\"rest\"` — WP REST pages/posts/custom types\n- `source=\"gsc\"` — a Search Console export, **with `gsc_clicks`**. This is\n what makes the coverage report prioritise itself: the 12 URLs carrying all\n the traffic sort to the top.\n- `source=\"crawl\"` — anything you found by following internal links\n\nMark the obvious throwaways immediately, so the work list is real:\n\n```\nadd_migration_urls urls=[{ url: \"/wp-admin\", excluded: true }, { url: \"/tag/nyheter\", excluded: true }]\n```\n\nRead it back and note the starting number:\n\n```\nlist_migration_urls site_id=<de-site> status=\"unhandled\" limit=50\n```\n\n## Phase 2 — Build the reference site\n\nFollow `tr-migrate-wp` (or `tr-import-url`) for the ONE reference site:\ndesign, header/footer partials, page templates, block types. Get the user to\napprove it before replicating — every fix you make after this point costs\nN times as much.\n\n## Phase 3 — Replicate the design to the other sites\n\nDesign travels as a block-type package, not by hand:\n\n```\nexport_block_types site_id=<reference> # → .tcblocks JSON\nimport_block_types site_id=<other> package=<that JSON>\n```\n\nThen per site:\n- `read_site_settings` on the reference → `update_site_settings` on the\n target with the same colors/fonts (translate `site_name`, `tagline`,\n contact details — those are per-market, not shared).\n- Recreate header/footer partials with translated nav labels.\n- Page templates: rebuild with `add_block target={kind:'template', id:…}`.\n\nSet the language per site — it drives `<html lang>`, `og:locale` and\nalt-text generation:\n\n```\nupdate_site site_id=<de-site> language=\"de\"\n```\n\n## Phase 4 — Migrate content, preserving paths\n\nPer site, per URL, follow `tr-migrate-wp` §3. Two rules that matter more here\nthan in a single-site migration:\n\n1. **Preserve the path verbatim** unless there's a reason not to. Use\n `path` for anything nested: `create_page title=\"Über uns\" slug=\"ueber-uns\"\n path=\"/ueber-uns\"`. A preserved path needs no redirect and loses nothing.\n2. **Rewrite internal links to the NEW paths.** Imported HTML is full of\n absolute links to the old domain. Sweep them per site:\n\n```\nbulk_replace_text site_id=<de-site> find=\"https://example.de/\" replace=\"/\" dry_run=true\n```\n\nCheck the dry-run count against what you expect before running it for real.\nCross-domain links between sister sites stay absolute — only the site's own\ndomain becomes relative.\n\n### Media: per site, not shared\n\nEvery Typeroll site has its own media library, so a shared asset (the group\nlogo, a product shot used in all markets) is uploaded once **per site** and\ngets a different CDN URL in each. That's correct — the sites are independent\nand one market's deploy must not depend on another's assets — but it means:\n\n- Don't try to reuse a `cdn_url` from site A inside site B's HTML. It will\n render, and it will break the day site A is deleted or moved.\n- Do write alt text per market, in that market's language:\n `update_media media_id=… alt_text=\"…\"`. The alt text is content, not\n metadata, and a Swedish alt on a German page is a real accessibility defect.\n\nImages referenced only from a stylesheet or from unrendered page-builder JSON\nare NOT found by an HTML scan. Spot-check the hero/background images of the\ntop pages in the preview before you call a site done.\n\n### Forms are NOT migrated — plan to rebuild them\n\nThe HTML cleaner strips `<form>`, `<input>`, `<select>` and `<button>`\nentirely, on purpose: a Contact Form 7 / Gravity / Elementor form posts to\nWordPress endpoints that no longer exist, so importing the markup would give\nyou a form that looks alive and silently drops every submission.\n\nSo, per site:\n\n```\ncreate_form name=\"Kontakt\" fields=[…] # or steps=[…] for a funnel\nadd_block target={kind:'page', id:'kontakt'} block={type:'core/form', data:{form_id:'<id>'}}\n```\n\nThen, still per site:\n\n- **Recipient address per market** — the German enquiries rarely go to the\n Swedish inbox. Check this explicitly; it is the single most common thing\n to get wrong in a batch of ten.\n- **Email delivery is configured per site** by an admin in the portal\n (Settings → Integrations), not through this API. Flag it to the user as a\n manual step — a form that saves submissions but sends no notification looks\n fine in testing and loses leads in production.\n- **Submit a real test through every form** after deploy, and confirm both the\n stored submission and the notification email.\n\nCount the old site's forms during Phase 1 and put them in the plan table.\nTen sites × three forms is thirty forms, and it is the part of the job that\nnever shows up in a URL inventory.\n\n## Phase 5 — Redirects for everything you didn't preserve\n\nWork the coverage report, not your memory:\n\n```\nlist_migration_urls site_id=<de-site> status=\"unhandled\"\n```\n\nFor each entry, one of three outcomes — no fourth option:\n\n- It moved → `create_redirect from_path=\"/alte-seite\" to_path=\"/neue-seite\"`\n- It's gone on purpose → `update_migration_url url_id=… excluded=true notes=\"Old campaign LP, signed off by <name>\"`\n- It should exist and doesn't → go back and migrate it\n\nRe-read the list. `unhandled` reaching zero is the exit condition for this\nphase.\n\n**Clear the URL families with one rule each**, per site — a WP network\nmultiplies the same dead shapes across every market:\n\n```\ncreate_redirect site_id=<de-site> from_path=\"/category/*\" to_path=\"/blogg/:splat\"\ncreate_redirect site_id=<de-site> from_path=\"/tag/*\" to_path=\"/blogg\"\ncreate_redirect site_id=<de-site> from_path=\"/2019/*\" to_path=\"/blogg/:splat\"\n```\n\nOnly a TRAILING `*` is supported (`:splat` replays the remainder); `:name`\nmatches one segment. Pattern-covered inventory URLs count as `redirected`, so\nthe work list actually empties. A pattern that would hide a live page is\nrefused, naming the pages — narrow the prefix rather than working around it.\n\nWatch the per-market prefixes: the German site's archive base is `/kategorie/`,\nnot `/category/`. Write the rules from each site's own inventory, never by\ncopying the reference site's.\n\n## Phase 6 — Wire the hreflang cluster\n\nThis is the step that only exists because the family is multi-domain, and the\none most likely to be skipped. Each page declares its siblings on the other\ndomains; the renderer adds the page's own self-reference.\n\n```\nupdate_page site_id=<se-site> page_id=\"om-oss\" patch={ alternates: [\n { hreflang: \"de\", href: \"https://example.de/ueber-uns\" },\n { hreflang: \"en-GB\", href: \"https://example.co.uk/about-us\" },\n { hreflang: \"x-default\", href: \"https://example.com/about-us\" }\n]}\n```\n\nRules the search engines actually enforce:\n- **Reciprocal.** Every page in a cluster must list every other one. Write\n all N sides or the cluster is ignored. `batch_update_pages` is the sane way\n to do this once you have the mapping table.\n- **One `x-default`** per cluster, pointing at the language selector or the\n fallback market. Optional, but useful when the family doesn't cover a\n visitor's language.\n- **Absolute URLs on the final domain** — not the `*.typeroll` fallback\n subdomain. The cluster is what you want live after cutover, and a fallback\n URL in there is a leak you'll be cleaning up for months.\n- Invalid entries are **rejected at write time** with the reason. If a write\n fails, fix the tag/href — don't strip the field to make it pass.\n\nPages that have no equivalent on the other domains get no alternates at all.\nA cluster of one is meaningless markup.\n\n## Phase 7 — Verify BEFORE touching DNS\n\nDeploy each site (`trigger_deploy`), then check what it actually serves:\n\n```\nverify_migration_urls site_id=<de-site> source_origin=\"https://example.de\" check_source=true\n```\n\nThis requests every inventory URL against the site's fallback subdomain —\nthe real domain still points at the old host, which is exactly why the check\nis possible at all. Verdicts:\n\n| Verdict | Meaning | Action |\n|---|---|---|\n| `ok` | 200 at the same path | none |\n| `ok_redirect` | redirects to a 200 | none; flatten if `hops` > 1 |\n| `missing` | 404/410 | **the gap** — redirect it or migrate it |\n| `broken_redirect` | loop, or chain ending on an error | fix the rule |\n| `error` | 5xx / timeout | inconclusive, re-run |\n\n`check_source=true` also requests the OLD site, so a URL that already 404s\nupstream shows up as noise in the inventory rather than as a migration\nfailure — mark those `excluded`.\n\nSlash-equivalent source URLs are one inventory decision but remain separate\nverification requests through `observed_paths`. Therefore `checked` can exceed\nthe normalized inventory size, and a missing slash variant remains a launch\nblocker. Redirect builds cover both source spellings and send internal targets\nto the site's configured trailing-slash style.\n\nNote what the check does NOT catch: it verifies that a URL *resolves*, not\nthat the page at the other end is the right content. Review content and SEO\nparity, then record the exact dataset and result with\n`record_migration_seo_acceptance`.\n\nIterate until `missing` and `broken_redirect` are both zero **on every site**.\nThen, per site:\n\n1. `add_domain` with the intended apex/www canonical; leave `auto_deploy` on\n2. Wait for that canonical/sitemap deploy to succeed, then point DNS\n3. `poll_domain` until verified — there is no separate activation step\n4. Re-run `verify_migration_urls target_origin=\"https://example.de\"` against\n the real domain, to confirm the cutover kept what the pre-check proved\n5. Submit the new sitemap in Search Console; keep the old property open for\n a few weeks and watch the 404 report\n6. Rerun the complete, unfiltered URL check and SEO review, then require\n `get_migration_launch_report` to return `launch_ready: true`\n\n## Definition of done (per site)\n\n- [ ] `get_migration_readiness source_url=<this market's old site>` → `ready: true`, warnings reviewed\n- [ ] `list_migration_urls status=\"unhandled\"` → 0\n- [ ] Complete `verify_migration_urls` → 0 `missing`, 0 `broken_redirect`, 0 `error`, 0 untested variants\n- [ ] `record_migration_seo_acceptance` is accepted for the latest deploy\n- [ ] `get_migration_launch_report` → `launch_ready: true`\n- [ ] hreflang cluster written on both/all sides, absolute, final domains\n- [ ] `language` set on the site; `<html lang>` correct in the deployed HTML\n- [ ] Internal links rewritten (no lingering absolute links to the old domain)\n- [ ] Forms rebuilt, recipient address correct for THIS market, test submission sent and received\n- [ ] Media uploaded to this site's own library (no cross-site `cdn_url`), alt text in this market's language\n- [ ] Domain declared, canonical deploy succeeded, DNS verified; sitemap submitted\n\n## Pitfalls specific to this job\n\n- **Don't share one inventory across domains.** Inventory entries key on\n path; two markets both have `/kontakt`. One site, one inventory.\n- **Don't build all N sites in parallel from scratch.** Build one, approve,\n replicate. Parallel building multiplies every design mistake by N.\n- **Don't skip the parity check because coverage says 100%.** Coverage is a\n claim about the data; parity is a measurement of the server. They disagree\n exactly when it matters — an unpublished target page, a typo'd path, a\n redirect chain.\n- **Don't point DNS site-by-site on a whim.** Cutting over one market at a\n time is fine and usually wise, but the hreflang cluster spans markets: a\n page pointing at a domain that still serves the old site is pointing at\n content that doesn't match. Either cut over close together, or write the\n cluster after the last market lands.\n\n## Storage prerequisite\n\nBefore reading or importing source content, call `get_import_readiness` (Core\n0.1.95 / MCP 0.44.68 or later). If `ready` is false, stop the import and show the\nreturned message and Publishing settings link. The organization must connect\nand verify its own storage first. Do not fall back to draft storage, hotlink\nsource images, or use ordinary page-write tools to bypass the import gate.\nUse `upload_media_from_url` for referenced source images; the customer’s\nCloudflare transfer Worker copies and verifies them directly in R2, independently\nof the GitHub/Cloudflare build-provider choice.\n",
20
+ "tr-migrate-wp": "---\nname: tr-migrate-wp\ndescription: Use when the user asks to migrate a WordPress site to Typeroll, mentions wp-json, or names a WP source URL. Walks the WP REST API, rebuilds pages in the target site's design, transfers media, sets redirects, leaves everything as drafts for human review.\n---\n\n# Migrate from WordPress to Typeroll\n\n> **The buffer model (draft writes).** Every content write in this recipe\n> (pages, blocks, partials, collection items) lands in an unsaved per-doc\n> DRAFT — deploys and plain previews only see SAVED content. For recipe-style\n> build work, pass `save: true` on write calls (the work is pre-approved by\n> the task itself), or run `commit_working_copy` per doc before any\n> `trigger_deploy`. Preview your drafts with `include_working_copy: true`.\n\n\nThe platform's in-portal migration workflow is the \"managed\" path for\ncustomers who want one-click. This skill is the \"power-user\" path: you\ndo it locally, mix data sources freely, and the user (consultant /\nagency) reviews each step in their terminal.\n\n## Preconditions\n\n**Run the readiness check FIRST — before touching any content:**\n\n```\nget_migration_readiness source_url=\"https://oldsite.com\"\n```\n\nWhen the migration includes reusable page/item layouts, include each proposed\nblock composition and its collection fields in this same call. A\n`waiting_for_native_support` result means leave that template intact and do\nnot replace the gap with generic custom blocks, raw HTML, or corrective site\nCSS. Independent content and SEO work may continue. Rerun the review after the\nrequired Core version is deployed, then verify preview and a fresh hosted\nbuild.\n\nPass `source_url` — that adds the checks on the site you're migrating FROM.\nAn old host that answers 403/429 to server-side requests is a **blocker**: the\nimport would produce empty pages, or pages containing the host's block page,\nwhich reads as real content and is worse. Whether `/wp-json` answers is a\nwarning, since scraping is a real fallback (it just loses ACF/custom fields).\n\nIf `ready` is false, STOP and report the blockers to the user. Do not start\nthe import \"and fix it after\": every blocker is one whose failure is invisible\nonce the work is done, so discovering it late means redoing the expensive part.\n\n- **Media storage** — without it, every `<img>` keeps its WordPress URL. The\n new site looks perfect and is still served images by the old host. It breaks\n the day the customer cancels that hosting, months later, all at once.\n- **Hosting adapter** — without credentials, deploys return a job id and\n publish nothing, while reporting success.\n\nWarnings are worth relaying but don't stop you: no verification origin (the\npre-cutover parity check can't run), no AI reconstruction key, forms without a\nnotification address, or a target site with no design to rebuild INTO.\n\nThen the ordinary preconditions:\n\n- `@typeroll/mcp-server` configured with a valid `TYPEROLL_API_KEY`.\n- The source WP site has `/wp-json` reachable (Google for \"wordpress\n REST API disabled\" if not — common for hardened hosts).\n- The Typeroll target site exists **and already carries the design** —\n settings, header/footer, one or two example pages. The migration rebuilds\n old content in the NEW design; with nothing to imitate it inherits the old\n site's look.\n- If the target already has content, you must NOT clobber it — always\n `list_pages` first and only write to slugs that don't already exist.\n\n## Recipe\n\n### 1. Probe and inventory\n\n```\nfetch <wp-url>/wp-json # confirm REST is on\nfetch <wp-url>/wp-sitemap.xml or /sitemap.xml # URL inventory\n```\n\nBuild a list of every URL you intend to migrate. WP custom post types\nneed their REST endpoint (e.g. `/wp-json/wp/v2/news?per_page=100`),\nwalking `X-WP-TotalPages` to paginate.\n\n### 2. Learn the target's design\n\n```\nget_site\nread_site_settings # colors, fonts, voice cues\nlist_partials # header / footer / shared\nread_partial partial_id=\"header\" # nav structure\nlist_pages limit=5\nbatch_read_pages page_ids=[<2-3 representative ids>] # see actual conventions\n```\n\nDon't skip this. Imposing a stranger's design on a customer's site is\nthe biggest avoidable mistake.\n\n### 3. Migrate one page at a time, draft status\n\nFor each source URL:\n\na. Fetch from WP. Prefer the helper plugin's authenticated endpoint\n (`/wp-json/typeroll/v1/...`) if available — it bypasses\n `show_in_rest=false` and returns ACF + builder fields. Otherwise\n fall back to `/wp-json/wp/v2/<post-type>?slug=<slug>`.\n\nb. Clean the HTML. Strip Elementor / Gutenberg / Breakdance class\n soup. Drop empty `<div>` and `<span>` wrappers. Keep semantic tags,\n tables, iframes from known hosts (YouTube / Vimeo / Calendly).\n\nc. Migrate referenced images:\n - For each `<img src>` and CSS `background-image: url()`:\n 1. Download the source image locally.\n 2. `create_upload_url filename=... content_type=...` → returns\n `{ upload_url, cdn_url, media_id }`.\n 3. PUT the bytes to `upload_url` (curl or fetch with the same\n content type).\n 4. Replace the `src` with `cdn_url` in the rewritten HTML.\n - Use `update_media media_id=... alt_text=\"...\"` to set a real alt\n text (existing WP `alt` attribute or `aria-label`; fall back to\n filename only as a last resort).\n\nd. Reconstruct in the target's design with native blocks first. Read the\n available block types and map headings, prose, images, buttons and layout\n into their typed fields. Use HTML mode only for source-specific markup that\n has no native representation and has passed the composition preflight.\n\ne. Write the page as a draft:\n\n ```\n create_page title=\"...\" slug=\"<last-path-segment>\"\n path=\"/<preserved-wordpress/path/>\"\n blocks=[<native block tree>]\n status=\"draft\" kind=\"article\" author=\"...\"\n seo_title=\"...\" seo_description=\"...\"\n ```\n\n **Preserve the source URL.** WP post URLs like\n `/2024/01/foo-bar/` uses `slug: \"foo-bar\"` and\n `path: \"/2024/01/foo-bar/\"`. Slug is one segment; `path` preserves the\n complete nested URL.\n\n### 4. Redirects\n\nAfter migration, every URL the agent didn't preserve verbatim needs a\nredirect:\n\n```\ncreate_redirect from_path=\"/old-services\" to_path=\"/services\"\n```\n\nWalk the inventory; for each URL: did it become a page with the same\npath? If yes, no redirect. If renamed, `create_redirect`. If\nintentionally dropped, mark it `excluded` via `update_migration_url` (the\ncustomer should sign off on every dropped URL).\n\n**Use wildcards for WordPress's URL families.** A WP site's dead URLs come in\nshapes, not as individuals — and the inventory only knows the ones it found,\nwhile the old site had more (paginated archives, feeds, attachment pages). One\npattern rule retires the whole family:\n\n| WordPress shape | Rule |\n|---|---|\n| Category archives | `from_path=\"/category/*\"` → `to_path=\"/blogg/:splat\"` (or a single landing page) |\n| Tag archives | `from_path=\"/tag/*\"` → `to_path=\"/blogg\"` |\n| Author archives | `from_path=\"/author/*\"` → `to_path=\"/om-oss\"` |\n| Date-based permalinks | `from_path=\"/2019/*\"` → `to_path=\"/blogg/:splat\"` — one rule per year |\n| Old post prefix → new | `from_path=\"/blog/:slug\"` → `to_path=\"/artiklar/:slug\"` |\n| Feeds | `from_path=\"/feed/*\"` → `to_path=\"/blogg\"` |\n\nRules:\n\n- **Trailing `*` only.** A mid-path splat (`/blog/*/comments`) is dropped\n silently by Cloudflare — the platform refuses it at write time.\n- **`:splat`** carries the captured remainder; **`:name`** matches exactly one\n segment and can be replayed by name.\n- **A pattern that would hide a live page is refused**, naming the pages. That\n is the platform protecting you: redirects are applied before static files, so\n `/blogg/*` would make every real article under `/blogg/` unreachable. Narrow\n the prefix instead.\n- **Query-string URLs can't be matched.** WP's `/?p=123` has no path to key on;\n those need handling at the source (or accept the loss and mark them excluded).\n- Rules are emitted most-specific-first, so a narrow rule always beats a broad\n one — you can safely have `/blogg/recept/*` alongside `/blogg/*`.\n\nThen verify against reality before anything is cut over:\n\n```\nimport_sitemap url=\"https://old.example.com/sitemap.xml\"\n# Optional: direct Search Console query (platform service account must have property access)\nimport_gsc_performance property=\"https://old.example.com/\" months=6\n# Or paste a Search Console Pages CSV via csv=\"...\" and source_origin.\ncheck_internal_links # database preflight before deploy\nverify_migration_urls # after trigger_deploy; compact exceptions by default\nrecord_migration_seo_acceptance # after reviewing this exact deployment\nget_migration_launch_report # final fail-closed launch gate\n```\n\nThe inventory merges slash-equivalent URLs into one work item but preserves\ntheir source spellings in `observed_paths`. Verification requests every one of\nthose spellings; treat a failing variant as a real gap even when coverage is\notherwise complete. Fresh builds expand redirect sources to both slash forms.\n\nFor a site imported before Typeroll normalized WordPress plain-text fields,\naudit and repair the legacy records before visual review:\n\n```\nrepair_migration_plain_text # dry_run=true by default; returns exact field diffs\n```\n\nThis operation is deliberately limited to `title`, `seo_title`,\n`seo_description`, and schema-defined plain-text `excerpt` fields. It cannot\ntouch bodies, HTML, slugs, paths, or URLs. Show all returned diffs and\nconflicts to the user. Only after explicit approval, repeat the same scope and\nselectors with `dry_run=false save=true`. If `truncated=true`, narrow the\nselection or raise `diff_limit` and review the omitted diffs first. Never work\naround a `working_copy` conflict: that resource contains another edit which\nmust be resolved separately.\n\n### 5. Preview + review with the user\n\n```\nget_preview_link page_id=<id> # one URL the user can click\n```\n\nOpen in the user's browser. The preview navigates the whole site from\none mint. Iterate on feedback: pages, header, footer.\n\n### 6. Ship\n\nWhen the user signs off:\n\n```\n# Bulk-publish drafts that look right\nbatch_update_pages updates=[{page_id, patch:{status:\"published\"}}, ...]\n\n# Deploy\ntrigger_deploy\nget_deploy_status job_id=<id> # poll\n```\n\n## Pitfalls\n\n- **Don't publish during migration.** Always import as `draft`. Even\n if the agent is confident, the customer needs the chance to spot-check.\n- **WP slugs sometimes drift.** A post saved with slug `foo-bar` may\n have been served at `/2024/01/foo-bar/` due to the permalink\n structure. The full URL is what users see in Google; preserve that,\n not the bare slug.\n- **Image bandwidth.** R2 upload is metered. Use `find_pages_matching`\n contains=\"<old-domain>\" on already-imported content to spot images\n that weren't transferred.\n- **WP-specific JSON-LD** (Yoast, Rank Math) is usually wrong after a\n redesign because it references old URLs. Strip it; let Typeroll\n emit fresh Article/Page schemas via `kind: 'article'` + `author`.\n\n## When the source isn't WordPress\n\nThe same shape applies for any source — Squarespace export, custom\nCMS, scraped HTML, CSV. Replace step 1's \"WP REST\" probe with whatever\ndiscovery the source supports, and the rest of the recipe is unchanged.\n\n## Storage prerequisite\n\nBefore reading or importing source content, call `get_import_readiness` (Core\n0.1.95 / MCP 0.44.68 or later). If `ready` is false, stop the import and show the\nreturned message and Publishing settings link. The organization must connect\nand verify its own storage first. Do not fall back to draft storage, hotlink\nsource images, or use ordinary page-write tools to bypass the import gate.\nUse `upload_media_from_url` for referenced source images; the customer’s\nCloudflare transfer Worker copies and verifies them directly in R2, independently\nof the GitHub/Cloudflare build-provider choice.\n",
21
21
  "tr-new-site": "---\nname: tr-new-site\ndescription: Use when the user wants to create a new Typeroll site from scratch, set up the initial design, or bootstrap a blank site with working header/footer, brand colors, and a homepage. Also triggers on \"start a new site\", \"set up a site for\", or \"build a website for [company]\".\n---\n\n# Bootstrap a new Typeroll site\n\n> **The buffer model (draft writes).** Every content write in this recipe\n> (pages, blocks, partials, collection items) lands in an unsaved per-doc\n> DRAFT — deploys and plain previews only see SAVED content. For recipe-style\n> build work, pass `save: true` on write calls (the work is pre-approved by\n> the task itself), or run `commit_working_copy` per doc before any\n> `trigger_deploy`. Preview your drafts with `include_working_copy: true`.\n\n\nStart here when the site already exists as a database record (created via\nthe portal UI or API) but has no design, no header/footer, and no pages.\nThe goal is to go from blank to a working 4-page site with correct brand\nidentity in a single session.\n\n**Pages are built in block mode** — the platform default. Blocks give\nstructured, per-field editing, native full-bleed sections, responsive\nbreakpoints, and templates. HTML mode is the secondary path for\nhand-crafted one-offs and migrated content (section at the end).\n\n## Preconditions\n\n- `@typeroll/mcp-server` configured with a valid `TYPEROLL_API_KEY`.\n- The site exists (confirm with `get_site`).\n- You have the customer brief: company name, industry, 2–3 key brand colors,\n tone of voice, and a list of initial pages.\n\n## Recipe\n\n### 1. Audit current state\n\n```\nget_site\nget_site_capabilities # template_capabilities_version — what this deployment supports\nread_site_settings # see what (if anything) is already configured\nlist_pages # don't overwrite pages that already exist\nlist_partials # check if header/footer already have content\nlist_block_types # the per-site block palette — NEVER assume, always list\n```\n\n**Do not choose a content mode or file a platform gap before these calls\nreturn.** For every required visual/behavioral element, map it to an existing\nblock or composition first. If the summary suggests a match, use\n`read_block_type` to inspect the exact schema. Only call something “missing”\nafter checking capabilities, the complete palette, and custom block types.\n\nCommon requirements that are easy to misclassify:\n\n| Requirement | Existing Typeroll primitive |\n|---|---|\n| Full-bleed hero flush below the header | `core/section` + `core/hero`; block-mode sections already own the full width and have no page-shell padding |\n| Responsive icon/card grid | `core/grid` + `core/icon_box`, or `core/feature_grid`; set responsive fields with `set_block_responsive` |\n| Custom cards backed by a collection | `core/repeater` / `core/collection_list` with a site-authored `item_compatible` block type as `item_block` |\n| Grouped collection listing | `core/repeater` with `group_by`; array-valued fields place an item in every matching group |\n| Breadcrumbs in a page template | `template/page_breadcrumbs`; page and item routes supply a server-rendered trail |\n| Generated heading index | `core/table_of_contents`; choose heading levels and set `source_field` for collection content |\n| Previous/next collection item links | `template/item_navigation`; defaults to collection order and can bind explicit neighbor fields |\n| Download CTA that disappears without a file | `template/show_if` around a context-bound `core/button`; a dedicated download block is only editor convenience |\n| Sticky/custom header and multi-column footer | Block-mode header/footer partials plus layout blocks, or one reusable custom block type |\n| Cookie notice | `settings.cookie_consent`, not a page block |\n| Consent copy owned by an installed Extension | `list_extension_installations` → `read_extension_installation` → `update_extension_installation_config`, using exact manifest schema keys; the update queues a production deploy by default |\n| One-off HTML + JavaScript embed | `core/embed`; reusable widgets use a custom block type with `script` |\n| CTA/button variants | `core/cta` and `core/button`, styled from site tokens or a narrowly scoped class |\n| Two-column image/text | `core/media_card`, `core/feature_row`, or `core/columns` |\n| Figures, captions and tables | `core/prose` / richtext; the sanitizer preserves these semantic tags |\n| Full-width block page without a content shell | Native block mode; top-level `core/section` is unconstrained |\n| Brand colors and typography | Site `colors`/`fonts` tokens consumed by core blocks |\n\nReal gaps should say what was checked and why the nearest primitive is not\nenough. “I did not see a dedicated block name” is not evidence by itself.\n\n### 2. Brand + settings\n\nOne `update_site_settings` call with every field you know:\n\n```json\n{\n \"site_name\": \"Acme Studio\",\n \"tagline\": \"Short, punchy tagline\",\n \"language\": \"sv\",\n \"trailing_slash\": \"always\",\n \"colors\": {\n \"primary\": \"#1a1a2e\",\n \"secondary\": \"#16213e\",\n \"accent\": \"#e94560\",\n \"background\": \"#f5f5f5\",\n \"surface\": \"#ffffff\",\n \"text\": \"#1a1a2e\",\n \"text_light\": \"#6b7280\"\n },\n \"fonts\": { \"heading\": \"Playfair Display\", \"body\": \"Inter\", \"size_base\": 16 },\n \"contact\": { \"email\": \"hej@acme.se\", \"phone\": \"+46 8 123 456\" },\n \"social\": { \"instagram\": \"https://instagram.com/acme\" }\n}\n```\n\nRead it back with `read_site_settings`. Google Fonts names are\ncase-sensitive display names (\"Plus Jakarta Sans\", not \"plus jakarta\").\n\n**Site icons are part of brand setup** — upload favicon (32–64px), a\n180×180 apple touch icon, and a 192×192 app icon; set `favicon`,\n`apple_touch_icon`, and `icon_192`. No icon assets? Derive a proposal (see\n`tr-brand`). Also set `settings.logo` to the uploaded brand mark — it feeds\nOG/schema even if the header uses a different lockup.\n\nSet `iframe_allowed_hosts` when customer content embeds a provider outside\nthe built-in YouTube/Vimeo/Google Maps/Calendly set. Values are exact domain\nhostnames, never URLs or wildcards. Read the setting back before deciding that\nan iframe cannot be represented.\n\n### 3. Header + footer partials\n\n**Start from the native preset — don't hand-roll navigation.** Read\n`tr-header-footer` and use its `template/site_logo` + `core/navigation`\ncomposition. The server-rendered landmark, current-page state, no-JS links,\nmobile disclosure, focus treatment, and responsive behavior are Core\ncontracts rather than tenant CSS/JavaScript.\n\nStage the complete tree with `update_partial partial_id=\"header\"\npatch={blocks:[...]} save=true`, then call `set_partial_mode partial_id=\"header\"\nto=\"blocks\"`. Repeat for the footer and read both partials back. The inactive\nHTML representation is retained for rollback; changing `content_mode` through\nordinary PATCH/PUT is rejected intentionally.\n\nUse HTML mode only when preserving legacy authored markup that cannot yet be\nrepresented natively. Partials receive the same `{{site.*}}` render context as\npage blocks. Keep the first section and header backgrounds intentional; do not\nadd a decorative border merely to compensate for mismatched spacing.\n\n### 4. Homepage — block tree\n\n`create_page` with the whole tree in one call. Omit block `id`s — the\nplatform assigns them (`blk_…`); you read them back for later\n`update_block` calls.\n\n```\ncreate_page title=\"Start\" slug=\"\" status=\"draft\" content_mode=\"blocks\" blocks=[...]\n```\n\nA proven landing-page skeleton (every top-level block is a `core/section`\n— sections are **natively full-bleed**: the background runs edge-to-edge,\ncontent is constrained by the section's own inner container via the\n`width` field. NEVER use 100vw negative-margin hacks. Anchor ids and\ncustom classes via `style_overrides` are safe on sections from\ntemplate_capabilities_version ≥ 0.15.3; on 0.14.x–0.15.2 they wrap the\nsection in a div and silently break full-bleed — there, put the anchor\non a block *inside* the section instead):\n\n```json\n[\n { \"type\": \"core/section\", \"data\": { \"background\": \"#ffffff\", \"padding_y\": \"lg\" }, \"children\": [\n { \"type\": \"core/media_card\", \"data\": {\n \"image\": \"MEDIA_URL\", \"image_alt\": \"…\", \"image_side\": \"right\",\n \"heading\": \"Huvudrubriken\", \"heading_level\": \"h2\",\n \"text\": \"<p>Ingress …</p>\",\n \"button_label\": \"Kontakta oss\", \"button_url\": \"/#kontakt\"\n } }\n ] },\n { \"type\": \"core/section\", \"data\": { \"padding_y\": \"lg\" }, \"children\": [\n { \"type\": \"core/heading\", \"data\": { \"text\": \"Så funkar det\", \"level\": \"h2\", \"align\": \"center\" } },\n { \"type\": \"core/grid\", \"data\": { \"cols\": { \"mobile\": 1, \"tablet\": 2, \"desktop\": 3 }, \"gap\": \"lg\" }, \"children\": [\n { \"type\": \"core/step_card\", \"data\": { \"number\": \"1\", \"title\": \"…\", \"text\": \"<p>…</p>\" } },\n { \"type\": \"core/step_card\", \"data\": { \"number\": \"2\", \"title\": \"…\", \"text\": \"<p>…</p>\" } },\n { \"type\": \"core/step_card\", \"data\": { \"number\": \"3\", \"title\": \"…\", \"text\": \"<p>…</p>\" } }\n ] }\n ] },\n { \"type\": \"core/cta\", \"data\": {\n \"heading\": \"Redo att börja?\",\n \"primary_label\": \"Kontakta oss\", \"primary_url\": \"/kontakt\"\n } }\n]\n```\n\nBlock-palette guidance (verify against `list_block_types` — the source of\ntruth):\n\n- **Hero:** `core/media_card` inside a white section (image beside copy),\n or `core/hero` (eyebrow/heading/subheading + `primary_*`/`secondary_*`\n buttons — rendered server-side; `layout: split-right` puts the image\n beside the text). For a plain text hero: section + heading + prose +\n button.\n- **Alternating image+text rows (\"zig-zag\" feature/step lists):**\n `core/feature_row` (template_capabilities_version ≥ 0.29.0) — balanced\n halves that hug the center gutter so wide screens never open dead air,\n natural-aspect image, `image_side: left|right` per row, mobile stack\n order as a field. Prefer it over hand-building `core/columns` with an\n unbalanced ratio + width-capped text (the classic dead-air trap). Use\n `core/media_card` instead when you want a *boxed card* with a\n filled/cropped image.\n- **`core/heading`** decouples `level` (h1–h6, semantics) from `size`\n (visual) — exactly one `level: h1` per page.\n- **Images:** `core/image` with an uploaded media URL. The build pipeline\n automatically emits responsive `<picture>` with AVIF/WebP variants\n (run `generate_image_variants` after upload) — the in-portal preview\n shows a plain `<img>`, the deployed site gets the upgrade. Use the\n `radius` field for rounded corners.\n- **Repeaters/listings:** `core/collection_list`, `gallery`,\n `feature_grid` etc. — alias blocks over `core/repeater`. Use these for\n collection-driven content instead of hand-writing listing markup. The base\n repeater also supports `group_by`, group ordering/headings, multi-valued\n membership, filters, custom `item_block`, and `item_overrides`.\n- **Long-form navigation:** `core/table_of_contents` builds an anchor list\n from page headings. Collection block templates use\n `template/item_navigation` for deterministic previous/next links.\n- **Forms:** `create_form`, then place `core/form` with its `form_id`.\n HTML-mode pages use `<x-form id=\"…\" />`; both paths render the same signed\n shell and runtime. See `tr-forms`.\n- **`core/html`** is the escape hatch for the genuinely unique thing —\n not a default. If you reach for it more than once or twice per page,\n note why (that's block-library feedback).\n\nSlot containers (`core/columns`, `core/tabs`): populate them either by\npassing the whole tree inline (`block={ type: 'core/columns', slots:\n[[…],[…]] }`) or incrementally with `add_block parent_id=<columns-id>\nslot_index=0|1`. Requires template_capabilities_version ≥ 0.15.2 — on\nolder sites use `core/grid` (children flow into columns) instead.\n\nPartial last rows (template_capabilities_version ≥ 0.16.5): when N equal\ncards don't divide by the grid's column count (5 cards, 3 cols), set\n`last_row: 'center'` on the `core/grid` — the orphan row auto-centers.\nNEVER invent a \"wide\" variant of one peer card to fill the hole, and\ndon't hand-roll 6-column CSS tricks; both distort content to patch\nlayout. On older sites, pick a column count that divides N.\n\nIcons (template_capabilities_version ≥ 0.16.0): `type: 'icon'` fields on\n`core/icon`, `core/icon_box`, and `core/step_card` render inline SVG when\nthe value is a name from `get_site_capabilities → core_icon_names` (a\ncurated Lucide subset — `check`, `star`, `shield-check`, `mail`,\n`arrow-right`, `truck`, `chart-line`, …). Any other value (emoji, plain\ntext) renders as text, so emoji stand-ins keep working. Icons inherit\nsize from font-size and color from `currentColor`/the block's color\nfield. On older sites icons don't render — use emoji or CSS markers.\n\nEditor schemas (template_capabilities_version ≥ 0.39.0): labelled enum\noptions, newline-edited string lists, nested object/repeating-array fields,\nand internal-page pickers for URL fields are first-class. Do not flatten an\nExtension's `props_schema` merely to make it editable.\n\nFor an Extension placed in an HTML header or footer, require\n`supports_extension_html_partial_directive: true`. The broader\n`supports_extension_html_directive` flag covers HTML page bodies and is not\nproof that an older static build expands partial directives.\n\nFor a cross-page Extension flow, require both\n`supports_extension_site_navigation` and `supports_extension_storage`. Use\n`context.site.navigate(\"/path/\")` and installation-scoped\n`context.storage.session` rather than direct root-path navigation, Web Storage,\nor personal data in query parameters. This keeps navigation inside the current\npreview and preserves state without exposing it through URLs or referrers.\n\nKnown limitations (honest list — don't fight them):\n\n- **`core/tabs` label icons don't render** (the tab strip is built\n client-side without the icon pipeline). Text labels only.\n\nTheming: block primitives render neutral. Brand color/typography comes\nfrom settings (step 2). For page-specific polish (e.g. a colored card\ntreatment), a single `core/html` block with a small `<style>` scoped to\n`[data-bid]`/section selectors is acceptable — keep it minimal and note\nit in your log.\n\n### 5. Inner pages\n\nSame pattern: `create_page` with `content_mode: \"blocks\"` and a section\ntree. Standard set: Om oss, Tjänster, Kontakt — or what the brief says.\nDefault new pages to `status: \"draft\"`; publish after review.\n\n### 5b. If the legacy site is still live, scrape canonical content\n\nFor pages with canonical text (privacy policy, terms, about), `WebFetch`\nthe live page and carry the text verbatim — don't rewrite legal copy\nfrom memory. Convert to prose blocks (or one `core/html` for complex\nlegacy markup).\n\n### 6. Preview + iterate\n\n```\nget_preview_link # signed URL for browser review\nget_page_preview page_id=\"home\" # rendered HTML for structural checks\n```\n\n**Self-review the visuals before you call it done — appearance AND\nreadability, not just structure.** Screenshot the deployed/preview site at\ndesktop (~1440px) and mobile (~390px) and look: logo FULLY VISIBLE (not clipped by\na header's overflow:hidden) + legible + brand-compliant against its actual\nbackground — screenshot the header IN CONTEXT, not the logo element in isolation\n(an element shot hides layout clipping); a light wordmark must not sit bare on a\nlight surface. Text contrast everywhere, no horizontal scroll or mid-word\nbreaks, every image rendered, mobile layout actually collapsed. \"No overflow +\ncopy present\" is not a design review — never report a build as done/perfect off\nstructural metrics alone.\n\nShare the preview link. Iterate on feedback with `update_block` /\n`add_block` / `move_block` — that's the point of block mode: surgical\nedits, not full-page rewrites.\n\n### 7. Deploy\n\nWhen the user approves:\n```\ntrigger_deploy\nget_deploy_status job_id=<id>\n```\n\n## Secondary path: HTML mode\n\nFor migrated legacy pages or a hand-crafted one-off, `content_mode:\n\"html\"` still works. Rules that apply there (and only there):\n\n- Wrap the body in a page-scope `<article class=\"my-page\">` and prefix\n selectors with it — the `.page-content` shell sets width/padding at\n normal specificity.\n- HTML-mode bodies are container-constrained; full-bleed requires the\n negative-margin escape (`margin-left: calc(50% - 50vw); width: 100vw`)\n — never combine with `overflow-x: clip` on a wrapper.\n- `set_page_mode` flips a page between modes;\n `convert_page_to_blocks` does a heuristic HTML→blocks conversion.\n\n## Pitfalls\n\n- **Don't create pages that already exist** — `list_pages` first; use\n `update_page` if the slug is taken.\n- **`update_page` takes a `patch` object**, not flat fields.\n- **Don't hardcode block field names from memory** — schemas are\n per-site (`list_block_types`/`read_block_type`).\n- **One `level: h1` per page** (core/heading) — SEO + screen readers.\n- **Don't simplify data during import** — preserve Swedish characters in\n labels (`affärsutveckling`, not the ASCII-folded slug), keep titles\n verbatim; slugs are derived for URLs only.\n- **Minimal JS.** Inline `<script>` in page content is stripped by the\n sanitizer. One-off interactivity uses `core/embed`; reusable interactivity\n uses a block-type `script`; site-wide code belongs in `scripts_body_end`.\n",
22
22
  "tr-page-template": "---\nname: tr-page-template\ndescription: Use when several pages on a Typeroll site share the same outer structure — category pages, service-detail pages, landing-page variants — and the user wants to edit the shared bits in one place. Covers two flows: the HTML-mode pattern with partials + `<x-include>` (works everywhere, recommended for Phase 1), and the formal block-mode PageTemplate (when the site is in block mode).\n---\n\n# Share structure across pages\n\nWhen you have N pages that follow the same skeleton — say 7 category landing pages, each with `Hero → Intro → Features grid → CTA banner` — you don't want to edit 7 HTML bodies whenever the design changes. There are two ways to share structure in Typeroll, and the right pick depends on the site's content mode.\n\n## Decide which pattern fits\n\n| Site is mostly in… | Use |\n|---|---|\n| **HTML mode** (the Phase 1 default — most sites) | Partials + `<x-include>` — pattern A below |\n| **Block mode** (`supports_blocks_mode=true` and the page's `content_mode='blocks'`) | PageTemplate via `set_page_template` — pattern B below |\n\nYou can check the site's mode by reading any existing page — `content_mode` is a top-level field on the page doc.\n\nIf unsure, default to **pattern A**. It works on every Typeroll site and the migration to block-mode templates later is mechanical.\n\n## Pattern A — Partials + `<x-include>` (HTML mode)\n\nA partial is a named HTML fragment. Putting `<x-include name=\"my-partial\" />` anywhere in a page body inlines that fragment at build time. Editing the partial updates every page that references it on the next deploy.\n\n### Recipe for \"7 category pages with shared structure\"\n\n#### 1. Identify the shared chunks\n\nWalk through one category page and mark which sections are the same across all 7:\n\n```\n[Hero: title + tagline + image] ← varies per category\n[Intro paragraph] ← varies per category\n[Common: \"Why choose us\" three-tile band] ← identical across 7\n[Common: CTA banner with newsletter form] ← identical across 7\n[Common: footer testimonials] ← identical across 7\n```\n\nThree reusable bits: `why-choose-us`, `cta-newsletter`, `footer-testimonials`.\n\n#### 2. Create the partials\n\n```\ncreate_partial partial_id=\"why-choose-us\" html_content=\"<section class=\\\"why\\\">\n <div class=\\\"container\\\">\n <h2>Varför oss</h2>\n <div class=\\\"why__grid\\\">\n <div class=\\\"why__tile\\\"><h3>Erfarenhet</h3><p>20 år i branschen.</p></div>\n <div class=\\\"why__tile\\\"><h3>Kvalitet</h3><p>Vi mäter på allt.</p></div>\n <div class=\\\"why__tile\\\"><h3>Närhet</h3><p>Lokala kontor i tre städer.</p></div>\n </div>\n </div>\n</section>\n<style>\n.why{padding:4rem 0;background:var(--color-surface)}\n.why__grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:2rem;margin-top:2rem}\n.why__tile h3{font-family:var(--font-heading);margin-bottom:0.5rem}\n</style>\"\n```\n\nSame for `cta-newsletter` and `footer-testimonials`.\n\nThe partial-id is what `<x-include>` references — keep it kebab-case and descriptive.\n\n#### 3. Build each category page using the partials\n\n```\ncreate_page title=\"Tjänster för bostadsrätter\" slug=\"brf\" content_mode=\"html\"\n html_content=\"<section class=\\\"hero hero--brf\\\">\n <div class=\\\"container\\\">\n <h1>Tjänster för bostadsrättsföreningar</h1>\n <p class=\\\"hero__tagline\\\">Trygg förvaltning, helt utan överraskningar.</p>\n </div>\n</section>\n\n<section class=\\\"intro\\\">\n <div class=\\\"container\\\">\n <p>Vi har förvaltat över 200 BRF:er i Stockholmsområdet sedan 2005.</p>\n </div>\n</section>\n\n<x-include name=\\\"why-choose-us\\\" />\n<x-include name=\\\"cta-newsletter\\\" />\n<x-include name=\\\"footer-testimonials\\\" />\"\n```\n\nRepeat for the other 6 categories, varying only the `hero` + `intro` sections.\n\n#### 4. Edit once, propagate everywhere\n\nAdding a fourth \"Why us\" tile? Edit `why-choose-us` once:\n\n```\nreplace_partial partial_id=\"why-choose-us\" html_content=\"<section class=\\\"why\\\">\n ...four tiles instead of three...\n</section>\"\n```\n\nAll 7 pages pick up the change on the next deploy. No multi-page diff.\n\n### Edge cases\n\n- **`<x-include>` self-closes or has an explicit close.** Both forms work: `<x-include name=\"x\" />` and `<x-include name=\"x\"></x-include>`.\n- **Nested includes are NOT supported.** If `partial-a` references `<x-include name=\"partial-b\" />`, the inner reference is not expanded. Flatten the hierarchy at design time.\n- **Unknown partial → silently empty.** A typo in the `name` attribute removes the tag at expand time with no warning. Verify the partial id matches one returned by `list_partials`.\n- **Partial content is sanitised on save.** `<script>` tags get stripped at partial creation, then the inlined HTML is sanitised again when the page renders. Two passes; both intentional.\n\n## Pattern B — Formal PageTemplate (block mode)\n\nAvailable when the site is in block mode (`supports_blocks_mode=true` and the page's `content_mode='blocks'`). A PageTemplate is a `Block[]` tree with one or more `template_content_slot` blocks marking where the page's own blocks go.\n\n### Recipe\n\n#### 1. List existing templates\n\n```\nlist_page_templates\n```\n\nReturns templates already defined on the site.\n\n#### 2. Create / pick a template\n\nTemplates live as `Block[]` trees under `paths.pageTemplate(orgId, siteId, templateId)`. They're created via the portal UI for now (no dedicated MCP `create_page_template` tool yet — the chat-tool surface in `anthropic.ts` exposes list + set, not create).\n\nIf the template you want doesn't exist, ask the user to create it via **/app/sites/{id}/templates** in the portal, or use the block-mode-aware AI chat in the portal which knows how to write template trees.\n\n#### 3. Assign it to a page\n\n```\nset_page_template page_id=\"brf\" template_id=\"category-landing\"\n```\n\nThe renderer composes the template's blocks with the page's blocks at build time via `composePageWithTemplate` (replacing each `template_content_slot` with the page's own block tree). The page's CSS / theme / SEO settings are unchanged.\n\n#### 4. Editing the template propagates to every page\n\nThe same template id can be set on multiple pages. Editing the template updates them all on next deploy.\n\n### When B beats A\n\n- Block-mode editor surfaces template assignment as a dropdown in the page settings.\n- Templates compose with the block tree, so the page author can drop content blocks into named slots rather than writing HTML.\n- The renderer is aware of block CSS / JS bundling — assets per block type are aggregated automatically.\n\n### When A still beats B even in block mode\n\n- Need a small reusable HTML chunk in the middle of a block-mode page → still use `<x-include>` (block-mode bodies can contain free HTML blocks that include partials).\n- One-page change wanted without affecting siblings → just edit the page's blocks; templates are all-or-nothing.\n\n## Refactor: 7 already-existing pages into a shared partial\n\nIf the 7 pages already exist as separate HTML bodies with duplicated chunks:\n\n1. `read_page page_id=<one of them>` and identify the shared HTML literally — character-for-character chunks that repeat across all 7.\n2. `create_partial partial_id=<descriptive-name> html_content=\"<the shared chunk>\"`.\n3. For each of the 7 pages, `update_page` with the shared chunk replaced by `<x-include name=\"<name>\" />`.\n4. Deploy. The rendered output should be byte-identical to before; only the source pages got shorter.\n\nDon't try to abstract first time and hand-write the partials. Refactor from real, working duplication.\n\n## Pitfalls\n\n- **Don't put hero / page-specific content into a shared partial.** A partial is for things that are *truly identical* across uses. The moment you want it to vary by page, lift the differing parts back into the page body.\n- **Header and footer already use partials.** Don't recreate them inside a category-page shared partial — they're injected by the layout, not by the page body.\n- **CSS scoping.** Partials carrying `<style>` blocks merge into every page that includes them. If two partials define `.tile` differently, the last one wins. Either namespace classes per partial (`.why__tile`, `.feature__tile`) or move shared styles into the site's global CSS via `update_site_settings` → `custom_css`.\n- **Don't migrate to block-mode templates just because you can.** Phase 1 sites are HTML-mode by design; partials + includes give you 90% of the value with zero risk.\n- **`<x-include>` in a partial body referencing another partial.** Won't expand (see edge cases). If you find yourself wanting this, you're building a layout system inside the partial system — at that point the site probably wants block mode.\n",
23
23
  "tr-redesign-branch": "---\nname: tr-redesign-branch\ndescription: Use when the user asks to redesign, modernize, or restructure a Typeroll site (or a section of it). Forces branch-isolated work so the live site stays untouched until the redesign is approved.\n---\n\n# Redesign a site without breaking the live one\n\n> **The buffer model (draft writes).** Every content write in this recipe\n> (pages, blocks, partials, collection items) lands in an unsaved per-doc\n> DRAFT — deploys and plain previews only see SAVED content. For recipe-style\n> build work, pass `save: true` on write calls (the work is pre-approved by\n> the task itself), or run `commit_working_copy` per doc before any\n> `trigger_deploy`. Preview your drafts with `include_working_copy: true`.\n\n\nSite-wide changes are exactly where copy-on-write branches earn their\nkeep. This skill enforces the discipline: every redesign happens on a\nbranch, preview-checked end-to-end, merged only after user sign-off.\n\n**Copy comes from the LIVE page, not a local draft.** A redesign changes\nthe design, not the words. Read the existing copy from the live page\n(`read_page`/`batch_read_pages`) and carry it over verbatim. Local\n`sources/*.md` files are drafts — use them only if the user explicitly\nsays \"apply the copy in `<file>`\". Don't invent new headlines, drop\nsections, or \"restore\" text from an old draft; a draft that had drifted\nfrom the live page once sent a whole redesign off the approved wording.\nWhen you must change a word, change it on the live page too and keep the\ndraft file in sync.\n\n**Restraint beats decoration.** Default to clean, purposeful design. Don't reach\nfor decorative motifs (suns, blobs, glows, mascots, confetti) to look \"graphic\" —\nunless a motif *means something for this brand/page*, it reads as random, and it's\nusually the exact thing that clips, seams, and crops. These fragile patterns broke\na real build — avoid them:\n- **A shape divider (wave/curve) between two sections** → don't hand-roll it in\n `core/html`; a separate stacked shape seams against the next section (a Chrome\n sub-pixel hairline). Use `core/section`'s **`divider_top` / `divider_bottom`**\n (`wave | curve | tilt`) — the platform paints it in the section's own colour and\n overlaps the neighbour by 1px, so it's seam-free by construction. Put the divider\n on the section whose colour should rise/dip into the neighbour.\n- **A glow/decoration inside an `overflow:hidden` box** → clipped to a hard edge.\n Put it in a non-clipped layer, or size it to fade out before the box edge.\n- **`object-fit:cover` on a portrait inside a circle/frame** → crops heads and\n faces. Use `contain`, reframe the source art, or size the frame to the art.\n- **A gradient \"fade\" at a section join** → reads as the design being cut off.\n Make transitions deliberate (a clean shape or a solid edge), never a fade.\nThese are invisible in a small full-page thumbnail and only show at real size in\nthe actual browser — see the review gate in step 6.\n\n## Recipe\n\n### 1. Discover (always)\n\n```\nget_site\nread_site_settings\nread_partial partial_id=\"header\"\nread_partial partial_id=\"footer\"\nlist_pages limit=20\nbatch_read_pages page_ids=[<top 3-5 pages>] # see actual conventions\nlist_partials # what free blocks exist\nlist_collections # any data we need to consider\n```\n\nWrite the user a short read-back: *\"This is a 12-page agency site\nusing CSS variables, primary color #1e40af, Inter heading + Source\nSans body. Existing pages are content-dense, single-column. Main nav\nhas 5 items including a CTA. I'd suggest...\"*\n\nConfirm direction before touching anything.\n\n### 2. Create a branch\n\n```\ncreate_branch name=\"<descriptive name>\"\n```\n\nSave the response's `id` — pass it as `version=<id>` on every\nsubsequent call. Branches default `robots_blocked: true` so a\nhalf-finished redesign won't be indexed.\n\n### 2b. Write the design spec (REQUIRED — before you build)\n\nEvery redesign branch MUST carry a written design spec. Without it the\ndesign choices live only in the agent's head, so a later \"just tweak the\nillustration style / palette\" means re-deriving everything by hand (this\ngap cost a real project a full reverse-engineering pass). Write it BEFORE\nbuilding so it guides the work, and keep it in sync as the design evolves.\n\nSave it as a markdown doc with the project (e.g. `design-spec.md`, or\n`prompts/design-system.md`) — or, if there's no local working dir, as an\nunlisted page on the branch. It must capture:\n\n- **Palette** — every role + hex (background, surface, primary, accent,\n text, borders), and where the variant *diverges* from the brand and why.\n- **Typography** — fonts + weights/sizes per role.\n- **Illustration / imagery style** — the exact image-gen prompt prefix\n (tone, palette, formspråk, framing rules), so the imagery can be\n regenerated or restyled on its own without touching layout. State the\n business/concept constraints the imagery must respect (a wrong-concept\n image is worse than none).\n- **Section structure** — the page's sections in order + each one's\n treatment (band colour, layout).\n- **Rationale** — one line per major choice: *why* this direction.\n\nWhen the user later says \"adjust just the illustrations\" or \"change the\npalette\", you edit the spec first, then apply — the spec is the source of\ntruth for the design intent.\n\n### 3. Iterate on the branch\n\nFor each redesign step:\n\na. Make the change with `?version=<branch-id>`. Updates here don't\n touch main:\n\n ```\n update_partial partial_id=\"header\" patch={...} version=\"<branch>\"\n update_page page_id=home patch={...} version=\"<branch>\"\n ```\n\nb. Preview after every meaningful change:\n\n ```\n get_preview_link page_id=home version=\"<branch>\"\n ```\n\n Send the URL to the user. The preview navigates the whole branch\n from one mint.\n\nc. Iterate on feedback. Common rounds: headline tightening, color\n tweaks, swapping hero images.\n\n### 4. Site-wide changes through partials, not pages\n\nIf the redesign touches every page (e.g. new global header, new\nfooter, new CTA bar) — edit a partial, not 23 pages. Before editing a\nshared block, check the blast radius:\n\n```\nfind_pages_using_block partial_id=\"header\" version=\"<branch>\"\n```\n\nThis returns every page that would show the change. Communicate that\nto the user before the save.\n\n### 5. Bulk content cleanups via dry-run first\n\nIf the redesign requires content rewrites (e.g. \"remove every mention\nof the old company name\"), use the bulk tool with dry-run:\n\n```\nsearch_pages contains=\"OldCo\" version=\"<branch>\"\nbulk_replace_text pattern=\"OldCo\" replacement=\"NewCo\" dry_run=true version=\"<branch>\"\n# Show the user the sample_diffs\nbulk_replace_text pattern=\"OldCo\" replacement=\"NewCo\" dry_run=false version=\"<branch>\"\n```\n\n### 6. Approval round\n\n**Self-review is a multi-DIMENSION pass, not a glance — and most of it you\nMEASURE, not eyeball.** Structural checks (copy present, images return 200) are\nNOT a design review; never report \"approved\" off them. Don't just list the bugs\nyou happened to notice — walk every dimension below on the DB-live preview\n(a reused `get_preview_link`; browser tool + DOM reads), fix what you find,\nreload, re-check. No re-deploy between fixes — the preview renders from the DB.\n\n**Use `tr-design-review` (`read_skill tr-design-review`) for the HOW** — it has\nthe per-dimension measurement snippets (overflow ladder, computed contrast, touch\ntargets, the anti-lazy-load broken-image check) and the scorecard + verdict\nformat. The dimension summary below is the \"what\"; that skill is the runnable\nroutine. In particular: scroll-and-settle to trigger lazy images BEFORE any\nfull-page screenshot, or you'll report blank boxes that aren't real.\n\n1. **Responsive** — screenshot across a width ladder (mobile / tablet / laptop /\n desktop / wide ≈390 / 768 / 1024 / 1440 / 1920px) AND sweep the page's own\n `@media` breakpoints (read the page-scoped `<style>`; resize a few px below +\n above each). At EVERY width: `document.documentElement.scrollWidth <=\n clientWidth` (no horizontal scroll), grids flip cleanly, nothing squished /\n orphaned / overlapping, no mid-word breaks. Two sizes is not enough — bugs hide\n in between. Also check a short/landscape viewport and 200% browser zoom.\n2. **Visual & brand** — logo FULLY VISIBLE (not clipped by a header\n `overflow:hidden` + overlap margin) and brand-compliant; screenshot the header\n IN CONTEXT, never the logo element in isolation (that hides clipping).\n Decoration robust at real size in the real browser: no hairline seam at a\n divider (use `core/section` `divider_top`/`divider_bottom` — don't hand-roll a\n band), no glow clipped to a hard edge, no `object-fit:cover` cropping faces, no\n gradient fade-cutoff. Typography: body line-length ~45–75ch, consistent scale,\n no awkward widows on headings. Palette adherence (no off-brand colours);\n consistent spacing / alignment / radius / shadow.\n3. **Accessibility — MEASURE, don't eyeball** — compute actual contrast ratios\n (WCAG AA: body ≥4.5:1, large/UI ≥3:1) and fix failures by deepening the\n offending colour token; meaningful `alt` on every image; exactly one `<h1>` +\n no skipped heading levels; visible `:focus-visible` on every interactive\n element; every input has an associated `<label>`; touch targets ≥44px on\n mobile; semantic landmarks (header/nav/main/footer) + nav `aria-label`; honour\n `prefers-reduced-motion`.\n4. **Functional** — the form actually works (POST action correct, hidden token\n non-empty, honeypot present + hidden; a long name/email doesn't break layout);\n every link + in-page anchor resolves (each `#anchor` has a matching `id`; no\n `href=\"#\"`/`\"\"`); ZERO console errors/warnings; interactions (menu toggle,\n hover/focus/active) work.\n5. **Content** — no unrendered `{{…}}` tokens in the DOM; no placeholder/lorem;\n copy still matches the source of truth (the live page) verbatim.\n6. **Findable (SEO/meta)** — `<title>` + meta description present + sensible;\n `og:title`/`og:description`/`og:image`; canonical; favicon + apple-touch-icon;\n `<html lang>`; `noindex` correct (branches must be noindex).\n7. **Fast (performance)** — images at sane sizes (not a 2048px file shown at\n 380px without a responsive variant), modern format (avif/webp), `width`/`height`\n or aspect-ratio set (no layout shift), below-fold lazy / above-fold eager.\n8. **Cross-browser** — the same CSS renders differently per engine (the divider\n seam was Chrome-only; WebKit/Firefox have their own). Re-check in another engine\n if you can; if only Chromium is available, statically flag risky props\n (`backdrop-filter` without fallback, `-webkit-`-only masks, `100vh` on mobile →\n prefer `100svh`, `sticky` inside `overflow`).\n\nFix what you find and re-check before involving the user. \"Looks structurally\nfine\" ≠ \"looks good\", and \"looks good in Chrome at 1440\" ≠ \"works for everyone,\neverywhere\" — never report a design as approved/perfect off a glance or a partial\npass.\n\nThen give the user the **DB-live preview link** to review — and use the SAME\nlink for your own verification:\n\n- **While iterating (default):** a reused `get_preview_link` (mint once,\n reuse — defaults to a 24h TTL). It renders from the database with NO build, so every\n edit shows on reload, and one link navigates the whole branch (internal\n links keep the token). The token URL is stable across edits — re-mint only\n when the 24h lapses, never per edit. Do NOT deploy just to let the user (or\n yourself) see a change.\n- **When they want the COMPILED static site** (a permanent bookmark, a\n stakeholder link to the built output, or a final pre-merge check): deploy\n the branch once (`trigger_deploy version=\"<branch>\"`) and share the stable\n alias `https://<branch>.<project>.pages.dev` (the `<project>` is the part\n after the hash in the returned `deploy_url`). Branch deploys are\n `robots_blocked`, so it won't be indexed. The immutable per-deploy\n `<hash>.pages.dev` is for your own one-off checks (a new hash each deploy).\n\nDefault: review/iterate on the reused DB-live preview; deploy only for the\ncompiled output or merge. Wait for an explicit \"looks good, ship it.\"\n\n### 7. Merge + deploy\n\n```\nmerge_branch version_id=\"<branch>\" # branch's diffs land on main\ntrigger_deploy\nget_deploy_status job_id=<id> # poll until succeeded\n```\n\nOptionally, after a successful deploy:\n\n```\ndelete_branch version_id=\"<branch>\" # tidy up\n```\n\n(You can also leave the branch around as a record of the redesign;\ndisk cost is tiny.)\n\n## Pitfalls\n\n- **Forgetting `version=` on writes.** Every call you make on the\n branch must include `version=<branch-id>`. A missing one writes\n straight to main — silent and bad.\n- **Skipping discovery.** \"Modernize\" without first reading the site\n produces a confidently-out-of-place result. Always sample existing\n pages.\n- **Deploying to preview.** Don't `trigger_deploy` after every edit just to\n see the change — that builds static pages and the URL is only as fresh as\n the last build. Iterate on a reused DB-live `get_preview_link` (renders from\n the DB, reflects edits on reload); deploy only for the compiled static\n output or merge (steps 6–7).\n- **Auto-merge.** Don't `merge_branch` without explicit user sign-off.\n Once merged, the only undo is another branch + reverse edits.\n- **Header rewrites that drop the brand block.** Even when the\n redesign is dramatic, preserve the brand mark + the nav skeleton\n unless the user said to redo them.\n\n## When to NOT use a branch\n\nTiny edits — \"fix the typo on the About page\" — don't need a branch.\nThe in-portal chat handles those directly on main. This skill is for\nwork where:\n\n- The user might want to walk away mid-redesign and come back later\n- Multiple changes need to ship together\n- The site is high-traffic and \"broken for an hour\" is unacceptable\n- Stakeholder review across multiple pages is expected\n\nIf none of those apply, edit main directly and move on.\n",
@@ -127,9 +127,9 @@ export const domainTools = [
127
127
  {
128
128
  name: 'retry_organization_media_migration',
129
129
  noSite: true,
130
- description: 'Resume verified media migration after fixing account, R2 or domain setup. Source files remain readable; pointer changes require matching hashes. Requires an organization API key.',
131
- inputSchema: {},
132
- handler: withErrorBoundary(async (_args, { client }) => ok(await client.rootPost('publishing/media-migration', {}))),
130
+ description: 'Resume verified media migration after fixing account or R2 setup. Source files remain readable; pointer changes require matching hashes. Set prepare_transfer to set up or recheck the transfer worker in the organization Cloudflare account. Requires an organization API key.',
131
+ inputSchema: { prepare_transfer: z.boolean().optional() },
132
+ handler: withErrorBoundary(async (args, { client }) => ok(await client.rootPost('publishing/media-migration', args.prepare_transfer ? { action: 'prepare_transfer', recheck: true } : {}))),
133
133
  },
134
134
  {
135
135
  name: 'read_publishing_readiness',
@@ -1,105 +1,23 @@
1
1
  // Media tools (list + signed upload URL + metadata patch).
2
2
  import { z } from 'zod';
3
- import { fetchPublicSource } from '../public-http.js';
4
3
  import { ok, withErrorBoundary } from './helpers.js';
5
- const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
6
- // Best-effort content-type inference from the URL extension. The server's
7
- // upload-URL endpoint requires content_type, so we have to pick *something*
8
- // before the actual fetch — and a bad guess gets corrected by the HEAD
9
- // response if the agent supplies an override.
10
- function inferContentType(urlOrFilename) {
11
- const lower = urlOrFilename.toLowerCase().split('?')[0];
12
- if (lower.endsWith('.jpg') || lower.endsWith('.jpeg'))
13
- return 'image/jpeg';
14
- if (lower.endsWith('.png'))
15
- return 'image/png';
16
- if (lower.endsWith('.webp'))
17
- return 'image/webp';
18
- if (lower.endsWith('.gif'))
19
- return 'image/gif';
20
- if (lower.endsWith('.avif'))
21
- return 'image/avif';
22
- if (lower.endsWith('.svg'))
23
- return 'image/svg+xml';
24
- if (lower.endsWith('.pdf'))
25
- return 'application/pdf';
26
- return 'application/octet-stream';
27
- }
28
- function filenameFromUrl(url, fallback) {
29
- if (fallback)
30
- return fallback;
31
- try {
32
- const u = new URL(url);
33
- const last = u.pathname.split('/').filter(Boolean).pop();
34
- if (last)
35
- return decodeURIComponent(last);
36
- }
37
- catch { /* fall through */ }
38
- return `import-${Date.now()}`;
39
- }
40
- async function readSourceBytes(response) {
41
- const declared = Number(response.headers.get('content-length'));
42
- if (Number.isFinite(declared) && declared > MAX_UPLOAD_BYTES) {
43
- await response.body?.cancel();
44
- throw new Error(`Source file too large (max ${MAX_UPLOAD_BYTES} bytes)`);
45
- }
46
- if (!response.body)
47
- return new Uint8Array();
48
- const reader = response.body.getReader();
49
- const chunks = [];
50
- let size = 0;
51
- while (true) {
52
- const { done, value } = await reader.read();
53
- if (done)
54
- break;
55
- size += value.byteLength;
56
- if (size > MAX_UPLOAD_BYTES) {
57
- await reader.cancel();
58
- throw new Error(`Source file too large (max ${MAX_UPLOAD_BYTES} bytes)`);
59
- }
60
- chunks.push(value);
61
- }
62
- const bytes = new Uint8Array(size);
63
- let offset = 0;
64
- for (const chunk of chunks) {
65
- bytes.set(chunk, offset);
66
- offset += chunk.byteLength;
67
- }
68
- return bytes;
69
- }
70
4
  async function uploadFromUrl(args, deps) {
71
- const filename = filenameFromUrl(args.source_url, args.filename);
72
- const sourceRes = await fetchPublicSource(args.source_url);
73
- if (!sourceRes.ok) {
74
- await sourceRes.body?.cancel();
75
- throw new Error(`Failed to fetch source URL: ${sourceRes.status} ${sourceRes.statusText}`);
76
- }
77
- const buf = await readSourceBytes(sourceRes);
78
- const sourceCt = sourceRes.headers.get('content-type')?.split(';')[0]?.trim();
79
- const contentType = args.content_type ?? sourceCt ?? inferContentType(filename);
80
- const mint = await deps.client.post(deps.siteId, 'media/upload-url', {
81
- filename, content_type: contentType, size: buf.byteLength, alt_text: args.alt_text,
82
- });
83
- const putRes = await fetch(mint.upload_url, {
84
- method: 'PUT', headers: { 'Content-Type': contentType }, body: buf,
85
- });
86
- if (!putRes.ok)
87
- throw new Error(`R2 upload failed: ${putRes.status} ${putRes.statusText}`);
88
- let finalizeResult = null;
89
- let finalizeError = null;
90
- try {
91
- finalizeResult = await deps.client.post(deps.siteId, `media/${encodeURIComponent(mint.media_id)}/finalize`);
92
- }
93
- catch (error) {
94
- finalizeError = error instanceof Error ? error.message : String(error);
5
+ // The API checks organization storage before fetching or creating anything.
6
+ // File bytes go from the source to the customer's transfer Worker and R2.
7
+ const source = new URL(args.source_url);
8
+ if (!['https:', 'http:'].includes(source.protocol) || source.username || source.password || source.port ||
9
+ !/^[a-z0-9.-]+\.[a-z]{2,}$/i.test(source.hostname) || /(?:^|\.)(?:localhost|local|internal|test|invalid)$/i.test(source.hostname)) {
10
+ throw new Error('Source URL must use a public HTTP(S) hostname without credentials.');
95
11
  }
96
- return {
97
- media_id: mint.media_id, cdn_url: mint.cdn_url, filename,
98
- content_type: contentType, size_bytes: buf.byteLength,
99
- finalize: finalizeResult, finalize_error: finalizeError,
100
- };
12
+ return deps.client.post(deps.siteId, 'media/import', args);
101
13
  }
102
14
  export const mediaTools = [
15
+ {
16
+ name: 'get_import_readiness',
17
+ description: 'Check whether the organization’s own storage is connected and verified before importing content or media. Returns a Publishing settings link when setup is required.',
18
+ inputSchema: {},
19
+ handler: withErrorBoundary(async (_args, { client, siteId }) => ok(await client.get(siteId, 'media/import'))),
20
+ },
103
21
  {
104
22
  name: 'read_private_media_url',
105
23
  description: 'Get a 60-second read URL for a private media original. Use it for authorized image inspection; never store it in page content or generated source. Use the stable media URL or ID in content.',
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.44.66';
11
+ export const VERSION = '0.44.68';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@typeroll/mcp-server",
3
- "version": "0.44.66",
3
+ "version": "0.44.68",
4
4
  "description": "Typeroll CMS MCP server – connect AI agents to the REST API to manage content and publish static websites.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -179,3 +179,14 @@ get_deploy_status job_id=<id>
179
179
  copy it into pages. Set it via `update_site_settings scripts_head="..."`.
180
180
  - **Videos.** YouTube/Vimeo embeds are fine (`<iframe>` is allowed).
181
181
  Hosted MP4s need re-uploading if the source URL won't persist.
182
+
183
+ ## Storage prerequisite
184
+
185
+ Before reading or importing source content, call `get_import_readiness` (Core
186
+ 0.1.95 / MCP 0.44.68 or later). If `ready` is false, stop the import and show the
187
+ returned message and Publishing settings link. The organization must connect
188
+ and verify its own storage first. Do not fall back to draft storage, hotlink
189
+ source images, or use ordinary page-write tools to bypass the import gate.
190
+ Use `upload_media_from_url` for referenced source images; the customer’s
191
+ Cloudflare transfer Worker copies and verifies them directly in R2, independently
192
+ of the GitHub/Cloudflare build-provider choice.
@@ -284,3 +284,14 @@ Half-migrate, leave drafts, let the user inspect, iterate. You can:
284
284
  - Mix sources: pull article bodies from Astro markdown, use Claude to generate fresh excerpts/SEO metadata before writing the item.
285
285
 
286
286
  Keep the local `astro-migration-state.json` honest — it's the only way to make a partial run resumable when the API rate-limits or a markdown parser trips on an edge case file.
287
+
288
+ ## Storage prerequisite
289
+
290
+ Before reading or importing source content, call `get_import_readiness` (Core
291
+ 0.1.95 / MCP 0.44.68 or later). If `ready` is false, stop the import and show the
292
+ returned message and Publishing settings link. The organization must connect
293
+ and verify its own storage first. Do not fall back to draft storage, hotlink
294
+ source images, or use ordinary page-write tools to bypass the import gate.
295
+ Use `upload_media_from_url` for referenced source images; the customer’s
296
+ Cloudflare transfer Worker copies and verifies them directly in R2, independently
297
+ of the GitHub/Cloudflare build-provider choice.
@@ -357,3 +357,14 @@ Then, per site:
357
357
  page pointing at a domain that still serves the old site is pointing at
358
358
  content that doesn't match. Either cut over close together, or write the
359
359
  cluster after the last market lands.
360
+
361
+ ## Storage prerequisite
362
+
363
+ Before reading or importing source content, call `get_import_readiness` (Core
364
+ 0.1.95 / MCP 0.44.68 or later). If `ready` is false, stop the import and show the
365
+ returned message and Publishing settings link. The organization must connect
366
+ and verify its own storage first. Do not fall back to draft storage, hotlink
367
+ source images, or use ordinary page-write tools to bypass the import gate.
368
+ Use `upload_media_from_url` for referenced source images; the customer’s
369
+ Cloudflare transfer Worker copies and verifies them directly in R2, independently
370
+ of the GitHub/Cloudflare build-provider choice.
@@ -257,3 +257,14 @@ get_deploy_status job_id=<id> # poll
257
257
  The same shape applies for any source — Squarespace export, custom
258
258
  CMS, scraped HTML, CSV. Replace step 1's "WP REST" probe with whatever
259
259
  discovery the source supports, and the rest of the recipe is unchanged.
260
+
261
+ ## Storage prerequisite
262
+
263
+ Before reading or importing source content, call `get_import_readiness` (Core
264
+ 0.1.95 / MCP 0.44.68 or later). If `ready` is false, stop the import and show the
265
+ returned message and Publishing settings link. The organization must connect
266
+ and verify its own storage first. Do not fall back to draft storage, hotlink
267
+ source images, or use ordinary page-write tools to bypass the import gate.
268
+ Use `upload_media_from_url` for referenced source images; the customer’s
269
+ Cloudflare transfer Worker copies and verifies them directly in R2, independently
270
+ of the GitHub/Cloudflare build-provider choice.