@timelesscms-com/mcp-server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,105 @@
1
+ ---
2
+ name: tcms-content-write
3
+ description: Use when the user asks to write, draft, or rewrite a page on a TimelessCMS site. Loads the site's design conventions before writing so the new content matches the existing voice and style.
4
+ ---
5
+
6
+ # Write a page that fits the site
7
+
8
+ The default failure mode for an AI writing a page is "good generic
9
+ HTML in the wrong voice." This skill makes the discovery step
10
+ non-optional.
11
+
12
+ ## Recipe
13
+
14
+ ### 1. Always discover first
15
+
16
+ ```
17
+ get_site # site name (use it in copy)
18
+ read_site_settings # tagline, contact info, brand colors
19
+ read_partial partial_id="header" # what other pages exist in the nav
20
+ list_pages limit=5
21
+ batch_read_pages page_ids=[<2-3 representative pages>]
22
+ ```
23
+
24
+ Read the actual HTML of an existing page. Note:
25
+ - Heading structure (single `<h1>` per page? subtitle pattern?)
26
+ - Whether the site uses CSS variables (`var(--color-primary)`) or
27
+ hardcoded values
28
+ - Tone (sober, playful, technical, marketing-y)
29
+ - Length conventions (do existing pages run 200 words or 2000?)
30
+ - Whether internal links use absolute or relative URLs
31
+
32
+ ### 2. Ask for the brief
33
+
34
+ If the user hasn't told you, ask:
35
+
36
+ - **Topic + purpose**: what's the page for, who's it for?
37
+ - **Key points**: must-include facts, calls to action
38
+ - **Target length**: short landing vs. long-form
39
+ - **Audience**: anything specific (existing customers, agencies,
40
+ developers)
41
+ - **Reference page**: is there an existing page to match in tone or
42
+ structure?
43
+
44
+ ### 3. Draft
45
+
46
+ Write in semantic HTML, matching the site's conventions you observed
47
+ in step 1:
48
+
49
+ - Use `<section>`, `<article>`, `<h1>`/`<h2>`, `<p>`, `<ul>` — avoid
50
+ div soup.
51
+ - Match the existing site's class naming or CSS variable usage. Don't
52
+ introduce a new design system mid-page.
53
+ - Insert images via `<img src="https://cdn..." alt="...">` — use
54
+ `list_media` to find existing images first; only generate new ones
55
+ if necessary (see `tcms-images` skill).
56
+ - Default status: `draft`. Don't auto-publish unless the user said so.
57
+
58
+ ### 4. Create or update
59
+
60
+ ```
61
+ # New page:
62
+ create_page title="..." slug="..." html_content="<full body>"
63
+ status="draft" kind="page"
64
+ seo_title="..." seo_description="..."
65
+
66
+ # Or update an existing one:
67
+ update_page page_id=<id> patch={ html_content: "..." }
68
+ ```
69
+
70
+ For an existing page, `read_page` first and preserve the existing
71
+ structure — replace one section at a time rather than rewriting the
72
+ whole body, unless the user explicitly asked for a full redo.
73
+
74
+ ### 5. Preview + iterate
75
+
76
+ ```
77
+ get_preview_link page_id=<id>
78
+ ```
79
+
80
+ Show the URL to the user. Iterate on feedback. Common rounds:
81
+ shortening, adding a CTA, tweaking SEO description.
82
+
83
+ ### 6. Status change is the user's call
84
+
85
+ Don't `update_page status:"published"` without an explicit "looks
86
+ good, publish it" from the user. Same for `trigger_deploy`.
87
+
88
+ ## SEO conventions worth knowing
89
+
90
+ - **`kind: "article"`** for blog posts and news. Switches to
91
+ `og:type=article` + emits Article JSON-LD. Set `author` too — empty
92
+ author = no Person schema = no author rich-result eligibility.
93
+ - **SEO title** target 50-60 chars. Past 60 Google truncates.
94
+ - **Meta description** target 150-160 chars. Don't write fluff to
95
+ fill it; Google rewrites descriptions when they go off-topic.
96
+ - **OG image** per page matters for shareable content. For articles
97
+ especially.
98
+
99
+ ## Pitfalls
100
+
101
+ - Reading 0 pages and just inventing a design is the most common
102
+ failure. Always sample at least one existing page first.
103
+ - Skipping the brief and producing 1000 words of plausible filler when
104
+ the user wanted a 200-word landing. Ask up front.
105
+ - Auto-publishing. Don't.
@@ -0,0 +1,214 @@
1
+ ---
2
+ name: tcms-directory
3
+ description: Use when the user wants to build a directory site or import a structured dataset (restaurants, products, events, agencies, etc.) where each item should have its own URL. Covers collection schema creation, per-item URLs via route_template, listing page, deploy.
4
+ ---
5
+
6
+ # Build a directory site from external data
7
+
8
+ TimelessCMS collections support per-item URLs: every published item
9
+ in a collection with a `route_template` materialises as its own static
10
+ page at build time. This is the right pattern when you have hundreds
11
+ of similar entities (restaurants, products, listings, profiles).
12
+
13
+ ## Big-picture flow
14
+
15
+ 1. **Data source** → 2. **Collection schema** → 3. **Items** → 4. **Listing page**
16
+ → 5. **Preview** → 6. **Deploy**
17
+
18
+ You drive everything from Claude Code locally — the scrape, the data
19
+ shaping, the writes. The MCP just receives the final shape.
20
+
21
+ ## Recipe
22
+
23
+ ### 1. Get the data
24
+
25
+ Whatever source the user has — scraped CSV, public API, vendor feed,
26
+ manual research, another LLM's output. Normalise to a flat shape:
27
+ one object per item with stable, kebab-case field names.
28
+
29
+ ```jsonc
30
+ [
31
+ {
32
+ "title": "Joe's Pizza",
33
+ "slug": "joes-pizza",
34
+ "address": "123 Main St, Anytown",
35
+ "phone": "+1-555-0100",
36
+ "cuisine": "italian",
37
+ "rating": 4.5,
38
+ "image": "https://...", // optional: a hosted image URL
39
+ "excerpt": "Family-run since 1987...",
40
+ "body": "<p>Long-form description with HTML.</p>"
41
+ }
42
+ ]
43
+ ```
44
+
45
+ The `slug` field is what populates `route_template`. Make it
46
+ kebab-case, unique within the dataset. If the source doesn't have one,
47
+ derive from `title`: lowercase, replace non-alphanumeric with `-`,
48
+ collapse consecutive dashes.
49
+
50
+ ### 2. Decide the URL structure with the user
51
+
52
+ Common patterns:
53
+
54
+ - `/restaurants/{slug}` (default — simple, predictable)
55
+ - `/r/{slug}` (compact)
56
+ - `/{cuisine}/{slug}` (categorised)
57
+ - `/dir/{slug}` (short prefix to avoid collisions with page slugs)
58
+
59
+ Pick one before creating the collection — changing `route_template`
60
+ later renames every URL and requires redirect rules.
61
+
62
+ ### 3. Create the collection schema
63
+
64
+ ```
65
+ create_collection
66
+ name="restaurants"
67
+ label_singular="Restaurant"
68
+ label_plural="Restaurants"
69
+ icon="🍕"
70
+ fields=[
71
+ {"name":"title","label":"Name","type":"text","required":true},
72
+ {"name":"slug","label":"Slug","type":"text","required":true},
73
+ {"name":"address","label":"Address","type":"text"},
74
+ {"name":"phone","label":"Phone","type":"text"},
75
+ {"name":"cuisine","label":"Cuisine","type":"text"},
76
+ {"name":"rating","label":"Rating","type":"number"},
77
+ {"name":"image","label":"Image URL","type":"text"},
78
+ {"name":"excerpt","label":"Excerpt","type":"textarea"},
79
+ {"name":"body","label":"Body","type":"richtext"}
80
+ ]
81
+ slug_field="slug"
82
+ sort_field="title"
83
+ sort_dir="asc"
84
+ route_template="/restaurants/{slug}"
85
+ item_template_html="<article class=\"directory-item\">
86
+ <header>
87
+ <h1>{{title}}</h1>
88
+ {{cuisine}} · ⭐ {{rating}}
89
+ </header>
90
+ <img src=\"{{image}}\" alt=\"{{title}}\" />
91
+ <address>{{address}} · <a href=\"tel:{{phone}}\">{{phone}}</a></address>
92
+ <section class=\"description\">{{{body}}}</section>
93
+ </article>"
94
+ ```
95
+
96
+ The `item_template_html` is what renders for each item. `{{field}}`
97
+ HTML-escapes; `{{{field}}}` leaves raw (use for richtext bodies that
98
+ intentionally carry HTML).
99
+
100
+ ### 4. Bulk-import items
101
+
102
+ Loop over your data array. For each item:
103
+
104
+ ```
105
+ create_collection_item
106
+ collection="restaurants"
107
+ fields={ title:"Joe's Pizza", slug:"joes-pizza", ... }
108
+ status="published"
109
+ ```
110
+
111
+ For larger datasets (1000+), batch outside the MCP — spawn 5 parallel
112
+ `create_collection_item` calls at a time, watch the 60-writes/min rate
113
+ limit (you'll hit it on big imports, the API returns 429 with
114
+ `Retry-After`).
115
+
116
+ Set `status: "draft"` for items the user still needs to review; only
117
+ published items get static pages.
118
+
119
+ ### 5. Build a listing page
120
+
121
+ Items have per-item URLs but not a default index. Create one with a
122
+ marker pair that `regenerate_collection_listing` will keep up to date:
123
+
124
+ ```
125
+ create_page
126
+ title="Restaurants"
127
+ slug="restaurants"
128
+ status="published"
129
+ html_content="<h1>All restaurants</h1>
130
+ <!-- tcms:listing:restaurants -->
131
+ <!-- /tcms:listing:restaurants -->
132
+ "
133
+ ```
134
+
135
+ Then populate (and refresh whenever items change) with one call:
136
+
137
+ ```
138
+ regenerate_collection_listing
139
+ collection="restaurants"
140
+ page_id="restaurants"
141
+ item_template="<article class=\"directory-card\">
142
+ <h2><a href=\"{{url}}\">{{title}}</a></h2>
143
+ <p>{{cuisine}} · ⭐ {{rating}}</p>
144
+ <p>{{address}}</p>
145
+ </article>"
146
+ wrap_open="<div class=\"directory-grid\">"
147
+ wrap_close="</div>"
148
+ ```
149
+
150
+ `{{field}}` substitutes HTML-escaped, `{{{field}}}` raw (for richtext
151
+ fields), `{{url}}` resolves through the collection's `route_template`.
152
+ The tool replaces only what's between the marker pair — anything before
153
+ or after the markers stays put.
154
+
155
+ When the customer adds a new restaurant later, the agent re-runs the
156
+ same `regenerate_collection_listing` call and the index updates. No
157
+ diff-the-HTML-by-hand, no stale listings.
158
+
159
+ (When the block editor lands, you'll be able to drop in a "collection
160
+ listing" block instead of hand-writing this. For now, raw HTML.)
161
+
162
+ ### 6. Preview an item
163
+
164
+ ```
165
+ get_preview_link collection_name="restaurants" item_id="<id>"
166
+ ```
167
+
168
+ Returns a URL the user can open. Internal links inside the preview
169
+ stay inside the preview surface, so navigating to another item works.
170
+
171
+ ### 7. Deploy
172
+
173
+ ```
174
+ trigger_deploy
175
+ get_deploy_status job_id=<id>
176
+ ```
177
+
178
+ Each published item gets its own URL in the static build, with
179
+ `sitemap.xml` automatically including them all.
180
+
181
+ ## Pitfalls
182
+
183
+ - **Slugs must be unique within the collection** — duplicates cause
184
+ build failures (two pages claiming the same URL). De-dupe before
185
+ importing.
186
+ - **Don't reuse `slug` across collections without thinking.**
187
+ `/restaurants/joes` and `/products/joes` are fine; just avoid
188
+ `/joes` for both (collection items vs. pages don't collide because
189
+ pages always win, but two collections sharing a `slug_field=slug`
190
+ with the same `route_template` is a foot-gun).
191
+ - **Required fields.** `route_template="/restaurants/{slug}"` will
192
+ silently skip items where `slug` is missing. Check
193
+ `list_collection_items` after import — if you imported 500 and the
194
+ listing only shows 480, look at the dropped 20's source data.
195
+ - **Template too clever.** Substitution is plain `{{field}}` — no
196
+ loops, no conditionals. If your design needs more, prefer flat
197
+ fields (`star_html`, `rating_label`) prebuilt in the data step.
198
+ - **Field type changes drift data.** Adding a new field after import
199
+ is fine; renaming one orphans the old data on every item. Plan the
200
+ schema before import.
201
+
202
+ ## Mixing scraped + generated content
203
+
204
+ The whole point of the local-agent model: you can blend sources.
205
+
206
+ - Scrape addresses + phone from a yellow-pages site.
207
+ - Generate excerpt + body from a local Claude pass over the raw
208
+ scraped HTML.
209
+ - Generate hero images per item via `tcms-images`.
210
+ - All three merged into one `create_collection_item` per record.
211
+
212
+ Keep a local manifest (`./directory-state.json`) of what's been
213
+ imported so a partial run is resumable. The MCP doesn't track that
214
+ state — your local script does.
@@ -0,0 +1,152 @@
1
+ ---
2
+ name: tcms-images
3
+ description: Use when the user asks for an image, hero, illustration, or logo to be created and embedded in a TimelessCMS 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).
4
+ ---
5
+
6
+ # Add images to a TimelessCMS site
7
+
8
+ The TimelessCMS API does NOT accept image bytes directly. Uploads go
9
+ through a signed PUT URL straight to Cloudflare R2, and the API only
10
+ sees the metadata. Two-step flow:
11
+
12
+ ## Recipe
13
+
14
+ ### 1. Get an image
15
+
16
+ Options, in order of preference:
17
+
18
+ a. **Reuse an existing one.** `list_media` returns CDN URLs for every
19
+ image already on this site. Search the list before generating
20
+ anything — saves bandwidth and keeps the visual catalog tight.
21
+
22
+ b. **Generate locally.** The user's Claude Code installation has
23
+ access to whatever image-gen tools they've configured (DALL-E,
24
+ Midjourney, Stable Diffusion, Replicate, etc.). Generate and save
25
+ to a tempfile.
26
+
27
+ c. **Source from the web** with appropriate licensing (the user is
28
+ responsible for clearing rights). Save locally before upload.
29
+
30
+ ### 2. Mint a signed upload URL
31
+
32
+ ```
33
+ create_upload_url filename="hero-services.png"
34
+ content_type="image/png"
35
+ size=<bytes>
36
+ alt_text="Office worker reviewing documents at a desk"
37
+ ```
38
+
39
+ Returns:
40
+
41
+ ```json
42
+ {
43
+ "upload_url": "https://...r2.cloudflarestorage.com/.../signed-...",
44
+ "cdn_url": "https://cdn.example.com/orgs/.../images/...png",
45
+ "key": "orgs/.../images/...png",
46
+ "media_id": "abc123",
47
+ "expires_in": 300
48
+ }
49
+ ```
50
+
51
+ The signed URL is valid for 5 minutes. The media doc is already
52
+ registered — even before the upload completes — so it'll show in
53
+ `list_media` immediately.
54
+
55
+ ### 3. PUT the bytes
56
+
57
+ Outside the MCP, hit the signed URL directly:
58
+
59
+ ```
60
+ PUT <upload_url>
61
+ Content-Type: <same content_type as in step 2>
62
+ Body: <file bytes>
63
+ ```
64
+
65
+ In a shell:
66
+
67
+ ```bash
68
+ curl -X PUT "<upload_url>" \
69
+ -H "Content-Type: image/png" \
70
+ --data-binary @hero-services.png
71
+ ```
72
+
73
+ Or from JS (Claude Code can run a one-line script):
74
+
75
+ ```js
76
+ await fetch(uploadUrl, {
77
+ method: 'PUT',
78
+ headers: { 'Content-Type': contentType },
79
+ body: await fs.readFile(path),
80
+ });
81
+ ```
82
+
83
+ A 200 OK from R2 means the image is now live at `cdn_url`.
84
+
85
+ ### 4. Patch metadata (alt text, etc.)
86
+
87
+ You set `alt_text` at create time, but if you generate the image first
88
+ and only THEN realize what to caption it as, patch later:
89
+
90
+ ```
91
+ update_media media_id=<id> alt_text="..." filename="hero-services-v2.png"
92
+ ```
93
+
94
+ ### 4b. Fill missing alt-text on existing media
95
+
96
+ When a customer has uploaded a bunch of images without alt-text (very
97
+ common after a WP migration), don't make it up — use vision:
98
+
99
+ ```
100
+ list_media → find items with empty alt_text
101
+ suggest_alt_text_context media_id=<id> → returns { image_url, suggested_prompt,
102
+ language, used_on_pages, current_alt_text }
103
+ # Pass image_url + the returned suggested_prompt to YOUR OWN vision
104
+ # capability (you can fetch the URL and pass bytes to vision).
105
+ update_media media_id=<id> alt_text="<what vision returned>"
106
+ ```
107
+
108
+ The prompt is tuned for SEO-grade output: short (5-15 words), no "image
109
+ of / picture of" filler, written in the site's content language,
110
+ decorative images return empty string. Run it sequentially on a
111
+ list_media batch and you can fix alt-text gaps across a whole site
112
+ without burning your context on prompt design. The platform does NOT
113
+ run vision on your behalf — your model does, your usage.
114
+
115
+ ### 5. Embed in a page
116
+
117
+ `read_page` the target, insert `<img>` in the right spot:
118
+
119
+ ```html
120
+ <img src="<cdn_url>"
121
+ alt="<alt_text>"
122
+ style="width: 100%; height: auto; display: block; margin: 2rem 0;" />
123
+ ```
124
+
125
+ Then `update_page` with the new HTML. Or, if you're generating a hero
126
+ for a brand-new page, include the `<img>` directly in `create_page`'s
127
+ `html_content`.
128
+
129
+ ## Pitfalls
130
+
131
+ - **Always set `alt_text`.** Empty alt is bad for SEO + accessibility.
132
+ Default to a one-sentence description of what's in the image.
133
+ - **`<script>` etc. in SVGs.** The page sanitizer drops `<script>`
134
+ inside SVG, so an icon set that includes script-based animations
135
+ won't render correctly. Use static SVG or a JPG/PNG export.
136
+ - **CSS background-image references aren't dedup'd.** If you set the
137
+ same image as a CSS background on multiple pages, the alt-text +
138
+ metadata are page-irrelevant. The sanitizer allows
139
+ `background-image: url(...)` in inline styles, but think about
140
+ whether an `<img>` is actually better.
141
+ - **Source URL leakage.** If you generated the image from a prompt
142
+ that contains internal info, don't bake that prompt into the
143
+ filename. Use a descriptive but generic filename.
144
+
145
+ ## Format choice
146
+
147
+ - **PNG** for logos, icons with hard edges, anything with text.
148
+ - **JPG** for photos. Smaller file, better for big hero images.
149
+ - **WebP** if the target audience runs modern browsers (95%+ in 2026).
150
+ - **SVG** for icons + simple illustrations. Vector scales perfectly.
151
+ - **PDF** is supported by `create_upload_url` for document downloads;
152
+ link with `<a href>`, not `<img>`.
@@ -0,0 +1,151 @@
1
+ ---
2
+ name: tcms-migrate-wp
3
+ description: Use when the user asks to migrate a WordPress site to TimelessCMS, 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.
4
+ ---
5
+
6
+ # Migrate from WordPress to TimelessCMS
7
+
8
+ The platform's in-portal migration workflow is the "managed" path for
9
+ customers who want one-click. This skill is the "power-user" path: you
10
+ do it locally, mix data sources freely, and the user (consultant /
11
+ agency) reviews each step in their terminal.
12
+
13
+ ## Preconditions
14
+
15
+ - `@timelesscms-com/mcp-server` configured with a valid `TCMS_API_KEY`.
16
+ - The source WP site has `/wp-json` reachable (Google for "wordpress
17
+ REST API disabled" if not — common for hardened hosts).
18
+ - The TimelessCMS target site exists. New, blank sites with the
19
+ starter design work best. If the target already has content, you
20
+ must NOT clobber it — always `list_pages` first and only write to
21
+ slugs that don't already exist.
22
+
23
+ ## Recipe
24
+
25
+ ### 1. Probe and inventory
26
+
27
+ ```
28
+ fetch <wp-url>/wp-json # confirm REST is on
29
+ fetch <wp-url>/wp-sitemap.xml or /sitemap.xml # URL inventory
30
+ ```
31
+
32
+ Build a list of every URL you intend to migrate. WP custom post types
33
+ need their REST endpoint (e.g. `/wp-json/wp/v2/news?per_page=100`),
34
+ walking `X-WP-TotalPages` to paginate.
35
+
36
+ ### 2. Learn the target's design
37
+
38
+ ```
39
+ get_site
40
+ read_site_settings # colors, fonts, voice cues
41
+ list_partials # header / footer / shared
42
+ read_partial partial_id="header" # nav structure
43
+ list_pages limit=5
44
+ batch_read_pages page_ids=[<2-3 representative ids>] # see actual conventions
45
+ ```
46
+
47
+ Don't skip this. Imposing a stranger's design on a customer's site is
48
+ the biggest avoidable mistake.
49
+
50
+ ### 3. Migrate one page at a time, draft status
51
+
52
+ For each source URL:
53
+
54
+ a. Fetch from WP. Prefer the helper plugin's authenticated endpoint
55
+ (`/wp-json/timelesscms/v1/...`) if available — it bypasses
56
+ `show_in_rest=false` and returns ACF + builder fields. Otherwise
57
+ fall back to `/wp-json/wp/v2/<post-type>?slug=<slug>`.
58
+
59
+ b. Clean the HTML. Strip Elementor / Gutenberg / Breakdance class
60
+ soup. Drop empty `<div>` and `<span>` wrappers. Keep semantic tags,
61
+ tables, iframes from known hosts (YouTube / Vimeo / Calendly).
62
+
63
+ c. Migrate referenced images:
64
+ - For each `<img src>` and CSS `background-image: url()`:
65
+ 1. Download the source image locally.
66
+ 2. `create_upload_url filename=... content_type=...` → returns
67
+ `{ upload_url, cdn_url, media_id }`.
68
+ 3. PUT the bytes to `upload_url` (curl or fetch with the same
69
+ content type).
70
+ 4. Replace the `src` with `cdn_url` in the rewritten HTML.
71
+ - Use `update_media media_id=... alt_text="..."` to set a real alt
72
+ text (existing WP `alt` attribute or `aria-label`; fall back to
73
+ filename only as a last resort).
74
+
75
+ d. Reconstruct in the target's design. The cleaned HTML is rarely
76
+ ready to ship — typical fixes: replace WP `wp-block-*` classes
77
+ with the target's CSS variables; turn Elementor sections into
78
+ plain `<section>` with the target's spacing; fix headings so the
79
+ page has exactly one `<h1>`. If you're confident, batch these
80
+ through `bulk_replace_text` with `dry_run: true` first.
81
+
82
+ e. Write the page as a draft:
83
+
84
+ ```
85
+ create_page title="..." slug="<preserved-from-wp>"
86
+ html_content="<reconstructed>"
87
+ status="draft" kind="article" author="..."
88
+ seo_title="..." seo_description="..."
89
+ ```
90
+
91
+ **Preserve the source URL.** WP post URLs like
92
+ `/2024/01/foo-bar/` go in as `slug: "2024/01/foo-bar"`. The
93
+ slug supports slashes; encode the WP permalink structure verbatim
94
+ when the customer wants existing links to keep working.
95
+
96
+ ### 4. Redirects
97
+
98
+ After migration, every URL the agent didn't preserve verbatim needs a
99
+ redirect:
100
+
101
+ ```
102
+ create_redirect from_path="/old-services" to_path="/services"
103
+ ```
104
+
105
+ Walk the inventory; for each URL: did it become a page with the same
106
+ path? If yes, no redirect. If renamed, `create_redirect`. If
107
+ intentionally dropped, mark it excluded in your notes (the customer
108
+ should sign off on every dropped URL).
109
+
110
+ ### 5. Preview + review with the user
111
+
112
+ ```
113
+ get_preview_link page_id=<id> # one URL the user can click
114
+ ```
115
+
116
+ Open in the user's browser. The preview navigates the whole site from
117
+ one mint. Iterate on feedback: pages, header, footer.
118
+
119
+ ### 6. Ship
120
+
121
+ When the user signs off:
122
+
123
+ ```
124
+ # Bulk-publish drafts that look right
125
+ batch_update_pages updates=[{page_id, patch:{status:"published"}}, ...]
126
+
127
+ # Deploy
128
+ trigger_deploy
129
+ get_deploy_status job_id=<id> # poll
130
+ ```
131
+
132
+ ## Pitfalls
133
+
134
+ - **Don't publish during migration.** Always import as `draft`. Even
135
+ if the agent is confident, the customer needs the chance to spot-check.
136
+ - **WP slugs sometimes drift.** A post saved with slug `foo-bar` may
137
+ have been served at `/2024/01/foo-bar/` due to the permalink
138
+ structure. The full URL is what users see in Google; preserve that,
139
+ not the bare slug.
140
+ - **Image bandwidth.** R2 upload is metered. Use `find_pages_matching`
141
+ contains="<old-domain>" on already-imported content to spot images
142
+ that weren't transferred.
143
+ - **WP-specific JSON-LD** (Yoast, Rank Math) is usually wrong after a
144
+ redesign because it references old URLs. Strip it; let TimelessCMS
145
+ emit fresh Article/Page schemas via `kind: 'article'` + `author`.
146
+
147
+ ## When the source isn't WordPress
148
+
149
+ The same shape applies for any source — Squarespace export, custom
150
+ CMS, scraped HTML, CSV. Replace step 1's "WP REST" probe with whatever
151
+ discovery the source supports, and the rest of the recipe is unchanged.