@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.
- package/AGENTS.md +319 -0
- package/README.md +135 -0
- package/dist/client.js +103 -0
- package/dist/index.js +112 -0
- package/dist/tools/block-types.js +42 -0
- package/dist/tools/bulk.js +24 -0
- package/dist/tools/collections.js +197 -0
- package/dist/tools/deploy.js +37 -0
- package/dist/tools/forms.js +98 -0
- package/dist/tools/helpers.js +59 -0
- package/dist/tools/media.js +202 -0
- package/dist/tools/pages.js +206 -0
- package/dist/tools/partials.js +108 -0
- package/dist/tools/preview.js +24 -0
- package/dist/tools/redirects.js +40 -0
- package/dist/tools/search.js +22 -0
- package/dist/tools/settings.js +56 -0
- package/dist/tools/sites.js +27 -0
- package/dist/tools/versions.js +52 -0
- package/package.json +43 -0
- package/skills/README.md +63 -0
- package/skills/tcms-content-write.md +105 -0
- package/skills/tcms-directory.md +214 -0
- package/skills/tcms-images.md +152 -0
- package/skills/tcms-migrate-wp.md +151 -0
- package/skills/tcms-redesign-branch.md +149 -0
|
@@ -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.
|