@craftware/crafty-sdk 0.5.2 → 0.8.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/README.md +70 -3
- package/dist/client.d.ts +7 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +7 -0
- package/dist/client.js.map +1 -1
- package/dist/crafty-visual-editing.global.js +1 -1
- package/dist/crafty-visual-editing.global.js.map +1 -1
- package/dist/http.d.ts +8 -1
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +50 -11
- package/dist/http.js.map +1 -1
- package/dist/image.d.ts +27 -0
- package/dist/image.d.ts.map +1 -0
- package/dist/image.js +110 -0
- package/dist/image.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/resources/analytics.d.ts +30 -0
- package/dist/resources/analytics.d.ts.map +1 -0
- package/dist/resources/analytics.js +76 -0
- package/dist/resources/analytics.js.map +1 -0
- package/dist/resources/assistant.d.ts +30 -0
- package/dist/resources/assistant.d.ts.map +1 -0
- package/dist/resources/assistant.js +51 -0
- package/dist/resources/assistant.js.map +1 -0
- package/dist/resources/entries.js +2 -2
- package/dist/resources/entries.js.map +1 -1
- package/dist/resources/forms.d.ts.map +1 -1
- package/dist/resources/forms.js +4 -4
- package/dist/resources/forms.js.map +1 -1
- package/dist/resources/pages.js +2 -2
- package/dist/resources/pages.js.map +1 -1
- package/dist/types/public.d.ts +146 -2
- package/dist/types/public.d.ts.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
- package/skills/crafty-cms/SKILL.md +66 -11
- package/skills/crafty-cms/references/mcp-configuration.md +33 -14
- package/skills/crafty-cms/references/migrate-to-crafty.md +85 -0
- package/skills/crafty-cms/references/sdk-development.md +80 -6
|
@@ -31,6 +31,8 @@ When you create the client manually in your profile you are shown the `client_id
|
|
|
31
31
|
|
|
32
32
|
OAuth clients that support MCP auto-discovery (Claude.ai, Claude Code, Codex, Cursor, ChatGPT) can find everything from the server URL alone: discovery is published at `/.well-known/oauth-protected-resource` and `/.well-known/oauth-authorization-server`, with `authorization_endpoint` `/oauth/authorize`, `token_endpoint` `/oauth/token`, dynamic `registration_endpoint` `/oauth/register`, PKCE (`S256`), and grants `authorization_code` + `refresh_token`. Registered redirect URIs must be `https`, a custom app scheme, or `http` on a loopback address (so local CLI callbacks work). These OAuth endpoints live at the tenant **root**, not under `/mcp`.
|
|
33
33
|
|
|
34
|
+
Clients that implement the MCP 2026-07-28 authorization spec can skip registration and use a **Client ID Metadata Document** (CIMD): the `client_id` is an `https` URL serving the client's metadata as JSON (`client_id` equal to that URL, `redirect_uris`, optional `client_name`). Crafty advertises `client_id_metadata_document_supported: true`, fetches the document on `/oauth/authorize` (public addresses only, no redirects, 5 KB max, cached per its `Cache-Control` max-age), and requires the `redirect_uri` to be listed in it. Only public clients (`token_endpoint_auth_method: "none"`) are accepted this way. Dynamic registration keeps working for clients that still use it.
|
|
35
|
+
|
|
34
36
|
## Scopes
|
|
35
37
|
|
|
36
38
|
When you create a token (PAT or OAuth client) you grant one or more scopes. Scopes are **enforced per tool**: a tool is only advertised when your token carries its required scope, and invoking one without the scope fails with an MCP error (`Insufficient OAuth scope`). Grant the scopes the agent needs:
|
|
@@ -39,7 +41,7 @@ When you create a token (PAT or OAuth client) you grant one or more scopes. Scop
|
|
|
39
41
|
|---|---|---|
|
|
40
42
|
| `crafty:read` | Read sites, pages, block types, collections, entries, media, and form definitions | all `list_*` / `get_*` except form submissions |
|
|
41
43
|
| `crafty:write` | Create, update and delete sites, pages, collections, entries, media, and forms; configure the visual editor, `llms.txt`, and the API cache | all `create_*` / `update_*` / `delete_*`, `upload_media`, `configure_visual_editor`, `configure_llms`, `flush_api_cache`, `set_api_cache_status` |
|
|
42
|
-
| `crafty:publish` | Publish and
|
|
44
|
+
| `crafty:publish` | Publish, unpublish and schedule pages, entries and forms | `publish_page`, `unpublish_page`, `publish_entry`, `unpublish_entry`, `schedule_publication`, `publish_form`, `unpublish_form` |
|
|
43
45
|
| `crafty:submissions` | Read form submissions (may contain personal data) | `list_form_submissions`, `get_form_submission` |
|
|
44
46
|
|
|
45
47
|
Request all four for full authoring plus submission access. Scopes are also advertised in OAuth discovery; the `/mcp` endpoint additionally requires a valid authenticated token on every request.
|
|
@@ -104,11 +106,13 @@ All tools return a JSON text response. IDs are integers; slugs are strings. Pagi
|
|
|
104
106
|
| `list_block_types` | — | — | block types and their schemas (not filtered by site) |
|
|
105
107
|
| `get_block_type` | `site_id`, and `block_type_id` or `slug` | — | one block type of the site with its full schema (`field_definitions`) |
|
|
106
108
|
| `list_collections` | — | `site_id` | active collections (with their `id`, used by `update_collection` / `delete_collection`) |
|
|
107
|
-
| `list_entries` | `collection_slug` | `site_id`, `per_page` | paginated entries (newest id first) |
|
|
109
|
+
| `list_entries` | `collection_slug` | `site_id`, `status`, `per_page` | paginated entries (newest id first), each with `status` and `published_at` |
|
|
108
110
|
| `get_entry` | `entry_id` | — | one entry with its collection |
|
|
109
|
-
| `list_media` | `site_id` | `collection`, `per_page` | paginated media for the site |
|
|
111
|
+
| `list_media` | `site_id` | `collection`, `per_page` | paginated media for the site; each item has `alt` (`null` when missing) |
|
|
110
112
|
| `list_forms` | — | `site_id`, `status`, `per_page` | paginated forms, ordered by name |
|
|
111
113
|
| `get_form` | `form_id` | — | one form with its field definitions |
|
|
114
|
+
| `list_redirects` | `site_id` | `environment` (default `production`) | the environment's redirects `{ source, destination, status_code }` |
|
|
115
|
+
| `list_webhooks` | `site_id` | `environment` (default `production`) | content webhooks (admins only) with their events and last deliveries; `upsert_webhook` / `delete_webhook` manage them (`crafty:write`) |
|
|
112
116
|
|
|
113
117
|
### Submissions (`crafty:submissions`)
|
|
114
118
|
|
|
@@ -126,31 +130,42 @@ Form submissions may contain personal data, so they require their own scope.
|
|
|
126
130
|
| `flush_api_cache` | `site_id` | `resource` | flushes cached public API responses for the site; `resource` = `pages`, `entries`, `collections`, `media`, or `all` (default `all`) |
|
|
127
131
|
| `set_api_cache_status` | `site_id`, `enabled` | — | enables (`true`) or disables (`false`) the public API response cache for the site; per-site override, preserves other cache config (TTLs, bypass rules) |
|
|
128
132
|
| `create_site` | `name` | `slug` | slug auto-generated from name; returns the site (with a generated `api_token`) |
|
|
129
|
-
| `create_block_type` | `site_id`, `name`, `fields` | `slug`, `description` | `fields: [{ key, type, label?, required? }]`; types `string`, `text`, `longText`, `url`, `number` (integer), `boolean`, `datetime`, `reference`, `multiReference`, `stringList`, `repeater`. A `repeater` takes `fields: [...]` (subtypes `string`, `text`, `longText`, `url`, `number`, `boolean`, `reference`, `stringList`; no nested repeater) and optional `min`/`max
|
|
133
|
+
| `create_block_type` | `site_id`, `name`, `fields` | `slug`, `description` | `fields: [{ key, type, label?, required?, model?, collection? }]`; types `string`, `text`, `longText`, `url`, `number` (integer), `decimal` (any number; optional `min`, `max`, `precision` = max decimal places), `boolean`, `datetime`, `select` (`options: ["a", { value: "b", label: "B" }]`, `multiple?: true` for an array of values), `reference`, `multiReference`, `stringList`, `repeater`. `number` also takes optional `min`/`max`. A `repeater` takes `fields: [...]` (subtypes `string`, `text`, `longText`, `url`, `number`, `decimal`, `boolean`, `select`, `reference`, `stringList`; no nested repeater) and optional `min`/`max` item counts. `url` fields accept http(s) URLs, `mailto:`, `tel:`, `#anchors` and site-relative paths starting with `/` (`mailto:` allows one recipient, `tel:` needs at least 3 digits, IDN hosts are converted to punycode when validated). Images and files: `reference` / `multiReference` with `model: "Media"` (value: media id(s)); entries of a collection: `collection: "<slug>"`. Slug auto-generated from name, unique per site |
|
|
130
134
|
| `update_block_type` | `site_id`, `block_type_id` | `name`, `slug`, `description`, `fields`, `force` | `fields` **replaces all fields**. Removing or retyping a field (or repeater subfield) that existing blocks fill is rejected unless `force: true`; block data is never rewritten |
|
|
131
135
|
| `delete_block_type` | `site_id`, `block_type_id`, `confirm: true` | — | refused while blocks use the type |
|
|
132
136
|
| `create_page` | `site_id`, `title` | `slug`, `blocks` | starts as `draft`; `blocks: [{ block_type_id, data }]` created in order; `data` is validated against the block type (errors like `blocks.2.data.items.1.title`), undeclared keys are dropped and listed in `warnings` |
|
|
133
137
|
| `update_page` | `page_id` | `title`, `slug`, `blocks` | **if `blocks` is supplied, ALL existing blocks are replaced** (send each block with its `id` to keep it); same validation and `warnings` as `create_page` |
|
|
134
|
-
| `create_collection` | `site_id`, `name` | `slug`, `description`, `fields` | slug auto-generated from name; `fields: [{ key, type, label, required? }]` |
|
|
135
|
-
| `update_collection` | `collection_id` | `name`, `description`, `is_active`, `fields` | `fields` is a **full replacement** list in display order; each field `{ key, type, label?, required?, model?, repository?, collection?, relation?, slug_source?, title_source? }` (media reference: `{ key: "image", type: "reference", model: "Media", repository: "library" }`) |
|
|
138
|
+
| `create_collection` | `site_id`, `name` | `slug`, `description`, `fields` | slug auto-generated from name; `fields: [{ key, type, label, required?, model?, repository?, collection?, options?, multiple?, min?, max?, precision?, fields? }]`; types `string`, `text`, `longText`, `url`, `number` (integer), `decimal`, `boolean`, `datetime`, `select`, `json`, `reference`, `multiReference`, `stringList`, `repeater` — `decimal`, `select` and `repeater` take the same settings as in `create_block_type`; invalid settings (e.g. a `select` without options or with duplicate values) are rejected. Images: `{ key: "cover", type: "reference", model: "Media", repository: "library" }`, galleries: `multiReference`; avoid `json` for content editors maintain |
|
|
139
|
+
| `update_collection` | `collection_id` | `name`, `description`, `is_active`, `fields` | `fields` is a **full replacement** list in display order; each field `{ key, type, label?, required?, model?, repository?, collection?, relation?, slug_source?, title_source?, options?, multiple?, min?, max?, precision?, fields? }` (media reference: `{ key: "image", type: "reference", model: "Media", repository: "library" }`) |
|
|
136
140
|
| `delete_collection` | `collection_id`, `confirm: true` | — | **permanently** deletes the collection and all its entries |
|
|
137
|
-
| `create_entry` | `collection_slug`, `site_id`, `slug` | `data` | `data` = field key/value pairs, validated against the collection schema (invalid data is rejected as `data.<field>: <message>`; undeclared keys are dropped and listed in `warnings`) |
|
|
141
|
+
| `create_entry` | `collection_slug`, `site_id`, `slug` | `data`, `status` | `status` = `draft` (default, hidden from the public API until `publish_entry`) or `published`; `data` = field key/value pairs, validated against the collection schema (`number` integer, `decimal` within `min`/`max`/`precision`, `select` one of its option values or an array of distinct values when `multiple`, `repeater` items against their subfields; invalid data is rejected as `data.<field>: <message>`; undeclared keys are dropped and listed in `warnings`) |
|
|
138
142
|
| `update_entry` | `entry_id` | `data`, `slug` | snapshots the prior data into a new revision; `data` is validated like `create_entry` (required fields are not enforced, only provided ones are checked) and rejected data creates no revision |
|
|
139
|
-
| `
|
|
140
|
-
| `
|
|
143
|
+
| `bulk_upsert_entries` | `site_id`, `collection_slug`, `entries` | `dry_run`, `publish` | up to 100 `{ slug, external_id?, data }` per call; matches an existing entry by `external_id` first, then by `slug`, else creates it; `data` is validated like `create_entry` and **replaces** the stored data on update (with a revision); new entries are drafts unless `publish: true` (needs `crafty:publish`); items fail individually; `dry_run` reports `created` / `updated` / `failed` per item without saving |
|
|
144
|
+
| `upload_media` | `url`, `site_id` | `collection_name` (default `library`), `file_name`, `sha256`, `alt` | `alt` = alt text for images (max 500 chars); http(s) URL or base64 data URI (max 256 KB decoded); `sha256` rejects and discards the upload on mismatch; the response returns the stored `sha256`, `visibility` and a `warning` when the file is private |
|
|
145
|
+
| `import_media_from_urls` | `site_id`, `urls` | `collection_name` (default `library`) | up to 50 public http(s) URLs per call, with the same SSRF, size and type checks as `upload_media`; a URL already imported to the site (or repeated in the batch) is not downloaded again; per URL: `status` `imported` / `existing` / `duplicate` / `failed`, `media_id`, `original_url` |
|
|
146
|
+
| `request_media_upload` | `site_id`, `file_name`, `sha256` | `collection_name` (default `library`), `size`, `alt` | `alt` is stored with the file once uploaded; returns a 15-minute single-use signed URL and a ready `curl -T` command for uploading a **local binary file** without sending its bytes through the model; max 25 MB; verified against `sha256` |
|
|
141
147
|
| `manage_media_collection` | `site_id`, `key` | `name`, `description`, `is_public` | creates or updates a media repository; `is_public: true` gives permanent public URLs (existing files are moved in the background); new repositories are private |
|
|
148
|
+
| `update_media` | `site_id`, `media_id`, `alt` | — | sets the alt text (max 500 chars) of one media file; `null` or `""` clears it |
|
|
142
149
|
| `delete_media` | `site_id`, `media_id`, `confirm: true` | — | **permanently** deletes one media file (ids from `list_media`) |
|
|
143
150
|
| `create_form` | `site_id`, `name`, `slug` | `description`, `fields` | `fields: [{ key, type, label, required? }]`; starts as `draft` |
|
|
144
151
|
| `update_form` | `form_id` | `name`, `slug`, `description`, `fields`, `notification_emails` | only supplied fields change; `fields`/`notification_emails` replace |
|
|
145
152
|
| `configure_visual_editor` | `site_id` | `environment` (default `production`), `frontend_url`, `routes` (`page_pattern`, `home_slug`, `collections`, `fixed`), `render_mode` (`ssr`/`hybrid`/`static`/`unknown`), `upload_media_collection` | visual editor settings of one environment; only sent keys change, `null` resets to the fallback; with only `site_id` it just reads; returns the effective settings plus `llms_warnings` (the `llms.txt` generator's warnings, since routes affect it too) |
|
|
146
153
|
| `configure_llms` | `site_id` | `environment` (default `production`), `title`, `summary`, `language`, `excluded_collections` (routed collections to drop), `included_collections` (routeless collections to opt in), `collection_languages` | admins only; same read/partial-update semantics as `configure_visual_editor`; only sent keys change, `null` resets to the fallback; with only `site_id` it just reads; returns the effective settings, the configuration warnings, and the first 40 lines of the generated `llms.txt` |
|
|
147
154
|
|
|
155
|
+
| `upsert_redirects` | `site_id`, `redirects` | `environment` (default `production`), `dry_run` | up to 500 `{ source, destination, status_code? }` per call, matched by `source`; `source` is a path starting with `/` (no spaces, `?` or `#`; Cloudflare Pages `*` splats and `:placeholders` allowed), `destination` a path or absolute http(s) URL, `status_code` 301 (default) / 302 / 307 / 308; invalid items are listed in `failed` and skipped. Crafty hosting writes them to `_redirects` on the next deploy (after the site's own rules; limit 2000 static + 100 dynamic); custom frontends read `GET /api/v1/redirects` |
|
|
156
|
+
| `delete_redirect` | `site_id`, `source` | `environment` (default `production`) | deletes one redirect |
|
|
157
|
+
|
|
158
|
+
Redirect tools follow the **Pages** permission: reading needs access to pages, changing needs write access.
|
|
159
|
+
|
|
148
160
|
### Publish (`crafty:publish`)
|
|
149
161
|
|
|
150
162
|
| Tool | Required | Effect |
|
|
151
163
|
|---|---|---|
|
|
152
164
|
| `publish_page` | `page_id` | status → `published`, sets `published_at` |
|
|
153
165
|
| `unpublish_page` | `page_id` | status → `draft`, clears `published_at` |
|
|
166
|
+
| `publish_entry` | `entry_id` | status → `published`, sets `published_at` |
|
|
167
|
+
| `unpublish_entry` | `entry_id` | status → `draft`, clears `published_at`; the entry disappears from the public API and from references |
|
|
168
|
+
| `schedule_publication` | `type` (`page`/`entry`), `id`, and `publish_at` and/or `unpublish_at` | sets when a draft publishes itself and/or when a published item returns to draft; ISO 8601 dates (no offset → the user's regional timezone), `null` clears one, `unpublish_at` must be after `publish_at`; applied every minute |
|
|
154
169
|
| `publish_form` | `form_id` | status → `published` (now accepts public submissions) |
|
|
155
170
|
| `unpublish_form` | `form_id` | status → `draft` (stops accepting submissions) |
|
|
156
171
|
|
|
@@ -159,8 +174,8 @@ Form submissions may contain personal data, so they require their own scope.
|
|
|
159
174
|
**Model content with block types (preferred)**
|
|
160
175
|
|
|
161
176
|
1. `list_sites` → choose a `site_id` (or `create_site`).
|
|
162
|
-
2. For each section of the design, `create_block_type` (or `get_block_type` if it exists). Use a `repeater` for lists of items
|
|
163
|
-
3. `create_page`
|
|
177
|
+
2. For each section of the design, `create_block_type` (or `get_block_type` if it exists). Use a `repeater` for lists of items, `stringList` for lists of plain strings, and `reference` / `multiReference` with `model: "Media"` for images (upload them first); only use a collection for large independent record sets.
|
|
178
|
+
3. `create_page` in the default locale with `blocks: [{ block_type_id, data }, …]`; fix any `blocks.N.data.…` errors and check `warnings`. For other languages, `configure_locales` once and then `upsert_translation` per page (blocks by id from `get_page`), or `translate_content` to let AI translate it once AI translation is enabled for the workspace and the site (`configure_translation`).
|
|
164
179
|
4. `publish_page`.
|
|
165
180
|
5. To change a schema later, `update_block_type` with the FULL field list (use `force: true` only if you accept orphaning data that blocks already hold).
|
|
166
181
|
|
|
@@ -186,9 +201,13 @@ Form submissions may contain personal data, so they require their own scope.
|
|
|
186
201
|
**Media**
|
|
187
202
|
|
|
188
203
|
1. Need permanent public URLs? `manage_media_collection` with `site_id`, `key`, `is_public: true` first (otherwise files get expiring signed URLs).
|
|
189
|
-
2. From a URL or a tiny file: `upload_media` with `url` and `
|
|
190
|
-
From a local file: compute `shasum -a 256 <file>`, call `request_media_upload
|
|
191
|
-
3. `list_media` to confirm and retrieve URLs
|
|
204
|
+
2. From a URL or a tiny file: `upload_media` with `url`, `site_id` and `alt` (optionally `collection_name`, `sha256`).
|
|
205
|
+
From a local file: compute `shasum -a 256 <file>`, call `request_media_upload` (with `alt`), then run the returned `curl -T` command.
|
|
206
|
+
3. `list_media` to confirm and retrieve URLs; give every content image without `alt` one with `update_media`.
|
|
207
|
+
|
|
208
|
+
**Migrate an existing site**
|
|
209
|
+
|
|
210
|
+
Follow `migrate-to-crafty.md`: crawl → classify → model → `import_media_from_urls` → `bulk_upsert_entries` (drafts, `dry_run` first) → `upsert_redirects` → human review → publish.
|
|
192
211
|
|
|
193
212
|
**Visual editor**
|
|
194
213
|
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Migrating an existing site to Crafty
|
|
2
|
+
|
|
3
|
+
Runbook for moving a site that already exists (WordPress, Webflow, Wix, a static site, another headless CMS) into Crafty with an agent and the MCP server, without breaking its URLs. Work in this order; each phase has a checkpoint where a human confirms before the next one.
|
|
4
|
+
|
|
5
|
+
Tools used: `bulk_upsert_entries`, `import_media_from_urls`, `upsert_redirects` (plus the usual `create_block_type`, `create_collection`, `create_page`, `publish_*`). Token scopes: `crafty:read` + `crafty:write`; add `crafty:publish` only for the final phase.
|
|
6
|
+
|
|
7
|
+
## Golden rules
|
|
8
|
+
|
|
9
|
+
- **Everything lands as a draft.** Never pass `publish: true` until a human has reviewed the content in the panel.
|
|
10
|
+
- **`dry_run: true` first** on every `bulk_upsert_entries` and `upsert_redirects` batch; fix the `failed` items, then run it for real.
|
|
11
|
+
- **Use `external_id`** (the old CMS post/page ID, or the old URL when there is no ID) on every entry. Re-running an import then updates instead of duplicating.
|
|
12
|
+
- **Batches:** up to 100 entries, 50 media URLs, 500 redirects per call. Keep a local manifest (JSON/CSV) of what each batch sent and what came back (`entry_id`, `media_id`), so a failed run can resume.
|
|
13
|
+
- **Media quota:** imports count against the tenant's media quota. Import only what the pages actually use, skip thumbnails and resized copies (WordPress `-300x200.jpg` variants), and ask before importing more than a few hundred files.
|
|
14
|
+
- Work against the `production` environment of the target site unless the user asks for another (tools take `environment`).
|
|
15
|
+
|
|
16
|
+
## Phase 1 — Crawl
|
|
17
|
+
|
|
18
|
+
Build an inventory of every URL of the old site:
|
|
19
|
+
|
|
20
|
+
1. Sources, best first: the CMS export or API (WordPress REST `/wp-json/wp/v2/posts?per_page=100&page=N`, `/pages`, `/media`; or the WXR export), `sitemap.xml` (and sitemap indexes), then a crawl of internal links from the home page.
|
|
21
|
+
2. For each URL record: old path, title, type guess, language, last modified, the main content (HTML), the images and files it uses, and the old ID if known.
|
|
22
|
+
3. Also collect the URLs that already redirect (HTTP 301/302) and anything in the old site's redirect plugin or `.htaccess`: those must be carried over too.
|
|
23
|
+
|
|
24
|
+
**Checkpoint:** show the user the counts per type and any URL you could not fetch.
|
|
25
|
+
|
|
26
|
+
## Phase 2 — Classify
|
|
27
|
+
|
|
28
|
+
Group the URLs into:
|
|
29
|
+
|
|
30
|
+
- **Singleton pages** (home, about, contact, landing pages) → Crafty **pages** built from block types.
|
|
31
|
+
- **Repeating content** (posts, news, products, team members, case studies, events) → one **collection** per type, one entry per item.
|
|
32
|
+
- **Archives and taxonomy listings** (`/category/x`, `/tag/y`, `/page/2`, author pages, date archives) → usually not migrated as content: the new frontend generates them; they only need redirects.
|
|
33
|
+
- **Junk** (search results, feeds, attachment pages, `?replytocom=` …) → drop; redirect to the closest useful page when they had traffic.
|
|
34
|
+
|
|
35
|
+
Decide the new URL scheme with the user (e.g. `/blog/2019/05/hello-world` → `/blog/hello-world`), and write the old → new mapping into the inventory now.
|
|
36
|
+
|
|
37
|
+
**Checkpoint:** the user approves the content types and the URL scheme.
|
|
38
|
+
|
|
39
|
+
## Phase 3 — Model
|
|
40
|
+
|
|
41
|
+
Follow "Model content with block types first" in `SKILL.md`:
|
|
42
|
+
|
|
43
|
+
1. Inspect the current schemas: `list_block_types`, `list_collections`.
|
|
44
|
+
2. For singleton pages, `create_block_type` per recurring section (hero, text + image, cards → `repeater`, FAQ → `repeater`, CTA). Reuse existing block types when they fit.
|
|
45
|
+
3. For each repeating type, `create_collection` with the fields the content really has: `title` (`string`), `excerpt` (`text`), `body` (`longText`, HTML), `cover` (`reference`, `model: "Media"`), `gallery` (`multiReference`), `published_on` (`datetime`), `tags` (`stringList`), `category` (`select`), SEO fields (`seo_title`, `seo_description`). No `json` fields for content editors maintain.
|
|
46
|
+
4. Mark the repository the images will go to as public first if the site needs permanent URLs: `manage_media_collection` with `is_public: true`.
|
|
47
|
+
|
|
48
|
+
**Checkpoint:** the user reviews the schemas in the panel before any import.
|
|
49
|
+
|
|
50
|
+
## Phase 4 — Media
|
|
51
|
+
|
|
52
|
+
1. From the inventory, list the distinct image and file URLs the migrated content uses (full-size originals only).
|
|
53
|
+
2. `import_media_from_urls` in batches of up to 50: `{ site_id, urls, collection_name }`. Each result has `status` (`imported`, `existing` when the URL was already imported, `duplicate` within the batch, `failed`) and `media_id` / `original_url`.
|
|
54
|
+
3. Save the old URL → `media_id` / `original_url` map. Retry `failed` items once; report the rest (private hosts, redirects, too large or unsupported types are refused on purpose — download those locally and use `request_media_upload`).
|
|
55
|
+
|
|
56
|
+
## Phase 5 — Content (as drafts)
|
|
57
|
+
|
|
58
|
+
1. Transform each item to the collection schema: clean the HTML (drop builder shortcodes, inline styles, tracking pixels), rewrite internal links to the **new** paths, and replace image URLs in the HTML with the `original_url` values from phase 4. Put media references (`cover`, `gallery`) as `media_id` values.
|
|
59
|
+
2. `bulk_upsert_entries` with `{ site_id, collection_slug, entries: [{ slug, external_id, data }], dry_run: true }`. Fix every `failed` item (the errors name the field, e.g. `data.published_on: …`) and check `warnings` (dropped keys = a field missing from the schema).
|
|
60
|
+
3. Run the same batch without `dry_run`. Entries are created as **drafts**; existing ones (same `external_id` or `slug`) are updated with a revision.
|
|
61
|
+
4. Singleton pages: `create_page` with `blocks` in the default locale; they start as drafts too. Other languages of the old site go in with `configure_locales` and `upsert_translation`, not as extra pages.
|
|
62
|
+
|
|
63
|
+
## Phase 6 — Redirects
|
|
64
|
+
|
|
65
|
+
1. From the inventory, one redirect per old URL whose path changes: `{ source: "/old/path", destination: "/new/path", status_code: 301 }`. Use 301 for permanent moves; 302/307 only for temporary ones.
|
|
66
|
+
2. Collapse patterns into dynamic rules when possible (`/category/*` → `/blog`, `/blog/:year/:month/:slug` → `/blog/:slug`): Crafty hosting allows 2000 static and 100 dynamic rules per deploy, including the site's own `_redirects`.
|
|
67
|
+
3. `source` must be a path (no domain, no `?query`, no `#fragment`); `destination` a path or an absolute http(s) URL. Don't redirect a URL to itself or create chains (A → B → C: point A straight at C).
|
|
68
|
+
4. `upsert_redirects` with `dry_run: true`, fix `failed`, then for real (500 per call). Check with `list_redirects`.
|
|
69
|
+
5. Hosted on Crafty: they apply on the next deploy. Custom frontend: read `GET /api/v1/redirects` in the middleware (Next.js `middleware.ts`, Nuxt server middleware) and answer with the `status_code` and `Location` header.
|
|
70
|
+
|
|
71
|
+
## Phase 7 — Human review
|
|
72
|
+
|
|
73
|
+
Give the user a short report: counts created/updated/failed per type, media imported/skipped, redirects created, and the list of items that need a human (failed transformations, missing images, pages that did not map to a block type). Ask them to review the drafts in the panel (and in the visual editor if it is configured). Apply their corrections with `update_entry` / `update_page`, or re-run the import batch (it updates by `external_id`).
|
|
74
|
+
|
|
75
|
+
## Phase 8 — Publish and switch over
|
|
76
|
+
|
|
77
|
+
Only after the user's explicit go-ahead:
|
|
78
|
+
|
|
79
|
+
1. Publish: `bulk_upsert_entries` again with `publish: true` (token with `crafty:publish`) or `publish_entry` / `publish_page` per item; or `schedule_publication` for a set launch time.
|
|
80
|
+
2. Deploy the new frontend, point the domain at it, then spot-check a sample of old URLs (they must answer 301 to the new ones) and the sitemap.
|
|
81
|
+
3. Keep the redirects: removing them later breaks links and search rankings.
|
|
82
|
+
|
|
83
|
+
## Not covered yet
|
|
84
|
+
|
|
85
|
+
There is no WordPress WXR importer, sitemap importer or broken-link report in the panel: the agent does those steps itself with the tools above.
|
|
@@ -23,7 +23,7 @@ const crafty = createCraftyClient({
|
|
|
23
23
|
});
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
-
`createCraftyClient(config)` returns a plain object with six resources (`collections`, `entries`, `pages`, `forms`, `mediaCollections`, `mediaFiles`) and the visual editor helpers `attrs`, `attrsHtml` and `previewFromRequest`.
|
|
26
|
+
`createCraftyClient(config)` returns a plain object with six resources (`collections`, `entries`, `pages`, `forms`, `mediaCollections`, `mediaFiles`), the image helpers `image` and `srcset`, and the visual editor helpers `attrs`, `attrsHtml` and `previewFromRequest`.
|
|
27
27
|
|
|
28
28
|
### Config options
|
|
29
29
|
|
|
@@ -70,7 +70,7 @@ crafty.entries.list(collectionSlug, query?, options?) // => { data: CraftyEntry
|
|
|
70
70
|
crafty.entries.get(collectionSlug, id, options?) // => { data: CraftyEntry }
|
|
71
71
|
```
|
|
72
72
|
|
|
73
|
-
`query`: `sort_by`, `sort_order` (`asc`/`desc`), `per_page`, `page`, plus any extra key/value filters passed through to the API.
|
|
73
|
+
`query`: `q` (text search over the collection's text fields), `sort_by`, `sort_order` (`asc`/`desc`), `per_page`, `page`, plus any extra key/value filters passed through to the API.
|
|
74
74
|
|
|
75
75
|
### `pages`
|
|
76
76
|
|
|
@@ -113,25 +113,83 @@ const cta = landing.requireBlock("cta"); // throws plain Error if abs
|
|
|
113
113
|
| `boolean` | boolean |
|
|
114
114
|
| `stringList` | `string[]` (e.g. `["AI", "Cloud"]`) |
|
|
115
115
|
| `repeater` | array of objects; every declared subfield is present, `null` when empty (e.g. `[{ icon, title, text, tags: string[] }]`) |
|
|
116
|
-
| `reference` | collection reference: entry summary `{ id, slug, label }` or `null`; media reference: the media id
|
|
116
|
+
| `reference` | collection reference: entry summary `{ id, slug, label }` or `null`; media reference (`model: "Media"`): the media id, plus `<key>_media` with `{ id, url, file_name, mime_type, size, is_public, image_transform_base }` or `null` |
|
|
117
|
+
| `multiReference` | collection: array of entries; media (`model: "Media"`): array of media ids, plus `<key>_media` with the files in the same order (missing files are skipped) |
|
|
118
|
+
|
|
119
|
+
Entries expose media references the same way (`entry.image` is the id, `entry.image_media.url` the file).
|
|
117
120
|
|
|
118
121
|
```ts
|
|
119
122
|
const solutions = home.block("solutions"); // block type slug "solutions"
|
|
120
123
|
const items = solutions?.data.items ?? [];
|
|
121
124
|
items.map((item) => `${item.title}: ${item.tags.join(", ")}`);
|
|
125
|
+
|
|
126
|
+
const hero = home.block("hero");
|
|
127
|
+
const heroUrl = crafty.image(hero?.data.image_media, { width: 1600, format: "auto" }); // reference, model "Media"
|
|
128
|
+
const slides = hero?.data.gallery_media ?? []; // multiReference, model "Media"
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Images: always go through `crafty.image()`
|
|
132
|
+
|
|
133
|
+
Render every Crafty image with `crafty.image(media, { width, format: "auto" })` (and `crafty.srcset(media, widths, { format: "auto" })` for responsive images) instead of the raw `url`. Sites that ship the original upload fail Core Web Vitals (LCP).
|
|
134
|
+
|
|
135
|
+
```tsx
|
|
136
|
+
const photo = block.data.image_media;
|
|
137
|
+
|
|
138
|
+
<img
|
|
139
|
+
src={crafty.image(photo, { width: 800, format: "auto" })}
|
|
140
|
+
srcSet={crafty.srcset(photo, [400, 800, 1200, 1600], { format: "auto" })}
|
|
141
|
+
sizes="(min-width: 768px) 50vw, 100vw"
|
|
142
|
+
width={800}
|
|
143
|
+
height={450}
|
|
144
|
+
loading="lazy"
|
|
145
|
+
alt={block.data.title ?? ""}
|
|
146
|
+
/>
|
|
122
147
|
```
|
|
123
148
|
|
|
149
|
+
- `crafty.image(media, { width?, height?, fit?, quality?, format? })` → string. `media` is a `<key>_media` object, a `CraftyMediaFile`, or a URL string. `width`/`height` 1–4096, `quality` 1–100, `fit`: `scale-down` | `contain` | `cover` | `crop` | `pad`, `format`: `auto` | `webp` | `avif` | `jpeg` | `png`. Invalid options are dropped.
|
|
150
|
+
- `crafty.srcset(media, widths, options?)` → `"<url> 400w, <url> 800w"`, or `""` when the image cannot be transformed.
|
|
151
|
+
- Pick `width` from the rendered size (about 2x for retina on small images); use `fit: "cover"` with both `width` and `height` for fixed-ratio cards and thumbnails.
|
|
152
|
+
- Transforms only apply to **public** raster images when the Crafty server has Cloudflare Image Transformations enabled (the media then carries `image_transform_base`). Otherwise `image()` returns the original `url` and `srcset()` returns `""`, so the same code always works. Upload images to a public repository (`manage_media_collection` with `is_public: true`) when the site needs permanent, resizable URLs.
|
|
153
|
+
- Use `og:image` with `crafty.image(media, { width: 1200, height: 630, fit: "cover", format: "jpeg" })`: social scrapers do not support AVIF/WebP negotiation.
|
|
154
|
+
- Do not hand-build `/cdn-cgi/image/...` URLs or append `?w=` query parameters.
|
|
155
|
+
|
|
124
156
|
Prefer modelling lists as `repeater` / `stringList` fields on a block type over collections or `json` fields; see the `crafty-cms` skill.
|
|
125
157
|
|
|
158
|
+
### Generated types (`crafty types`)
|
|
159
|
+
|
|
160
|
+
Do not guess field keys or shapes: generate them from the site's real content models with the CLI.
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
npx @craftware/crafty-cli types # CRAFTY_API_URL + CRAFTY_API_KEY from .env
|
|
164
|
+
npx @craftware/crafty-cli types --api-key=$KEY --url=https://<tenant>.crafty.do --out=src/crafty.types.ts
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
It reads `GET /api/v1/schema` (JSON Schema of every collection and block type of the API key's environment, versioned by `format_version`) and writes `crafty.types.ts`:
|
|
168
|
+
|
|
169
|
+
- `<Slug>Entry` per collection (entry metadata + fields), `<Slug>BlockData` and `<Slug>Block` per block type, the `CraftyPageBlock` union (discriminated by `type`), the `CraftyCollectionTypes` / `CraftyBlockDataTypes` slug maps, and `CraftyEntryReference` / `CraftyMediaSummary`.
|
|
170
|
+
- `select` → union of option values, `stringList` → `string[]`, `repeater` → array of item objects, `json` → `unknown`; optional fields are `| null`.
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
import type { CarsEntry, CraftyPageBlock } from "./crafty.types";
|
|
174
|
+
|
|
175
|
+
const cars = (await crafty.entries.list("cars")).data as unknown as CarsEntry[];
|
|
176
|
+
const page = await crafty.pages.get("home");
|
|
177
|
+
for (const block of page.blocks as unknown as CraftyPageBlock[]) {
|
|
178
|
+
if (block.type === "hero") block.data.heading; // HeroBlockData
|
|
179
|
+
}
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Types depend on the environment (generate with the key of the environment the code targets) and go stale when a collection or block type changes — re-run the command after `create_block_type`, `update_block_type`, `create_collection` or `update_collection`.
|
|
183
|
+
|
|
126
184
|
### `forms`
|
|
127
185
|
|
|
128
186
|
```ts
|
|
129
187
|
crafty.forms.list(options?) // => { data: CraftyForm[] } (requires apiKey)
|
|
130
188
|
crafty.forms.get(slug, options?) // => { data: CraftyForm } (requires apiKey)
|
|
131
|
-
crafty.forms.submit(formSlug, data, options?) // => { id, form_id, submitted_at } (public,
|
|
189
|
+
crafty.forms.submit(formSlug, data, options?) // => { id, form_id, submitted_at } (public; apiKey optional, picks the environment)
|
|
132
190
|
```
|
|
133
191
|
|
|
134
|
-
`forms.submit()` uses a **public** endpoint:
|
|
192
|
+
`forms.submit()` uses a **public** endpoint: the `X-API-Key`, when configured, only selects the environment (a staging key submits to staging, no key submits to production), but it **requires `siteSlug` in the client config** — calling it without one throws a synchronous `CraftyError` with code `BAD_REQUEST`.
|
|
135
193
|
|
|
136
194
|
```ts
|
|
137
195
|
const crafty = createCraftyClient({
|
|
@@ -163,7 +221,7 @@ crafty.mediaCollections.list(query?, options?) // => { data: CraftyMediaCollect
|
|
|
163
221
|
crafty.mediaFiles.list(query?, options?) // => { data: CraftyMediaFile[], links, meta: { ...pagination, ttl_minutes } }
|
|
164
222
|
```
|
|
165
223
|
|
|
166
|
-
`query`: `collection`, `directory_id`, `ttl_minutes` (signed-URL lifetime), `per_page`, `page`.
|
|
224
|
+
`query`: `collection`, `directory_id`, `ttl_minutes` (signed-URL lifetime), `per_page`, `page`. Each file has `url`, `is_public`, `expires_at` and `image_transform_base`; pass the file to `crafty.image()` to render it.
|
|
167
225
|
|
|
168
226
|
## `llms.txt`
|
|
169
227
|
|
|
@@ -208,6 +266,21 @@ await writeFile("dist/llms-full.txt", await crafty.llms.full());
|
|
|
208
266
|
|
|
209
267
|
**Errors**: `index()` / `full()` (and therefore `handler()`) throw `CraftyError` with code `SERVER_ERROR` if the response body is not text.
|
|
210
268
|
|
|
269
|
+
## Analytics
|
|
270
|
+
|
|
271
|
+
`crafty.analytics` serves the third-party analytics (Google Tag Manager, GA4, Meta Pixel, TikTok Pixel) configured for the API key's environment on the "Analítica" admin screen or with the `configure_analytics` MCP tool.
|
|
272
|
+
|
|
273
|
+
```ts
|
|
274
|
+
crafty.analytics.get(options?) // => { gtm_container_id, ga4_measurement_id, meta_pixel_id, tiktok_pixel_id, configured, head, body }
|
|
275
|
+
crafty.analytics.load(options?) // browser only => "loaded" | "already-loaded" | "not-configured" | "unavailable"
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
- **Crafty Hosting**: do nothing. The deploy injects the loaders into every HTML page and replaces any block the SDK rendered at build time.
|
|
279
|
+
- **SSR elsewhere** (preferred off Crafty Hosting): render `head` before `</head>` and `body` right after `<body>`. Crawlers see the tags and the first page view is kept.
|
|
280
|
+
- **Client-rendered elsewhere**: call `load()` once. It waits for the DOM, and skips without fetching when `window.__CRAFTY_ANALYTICS__` is already set (by the deploy-injected block or an earlier load).
|
|
281
|
+
|
|
282
|
+
Never hard-code GTM / GA4 / pixel snippets in the site, and never add GA4 both here and inside GTM (page views are counted twice).
|
|
283
|
+
|
|
211
284
|
## Error handling
|
|
212
285
|
|
|
213
286
|
The SDK throws `CraftyError` for HTTP, timeout, abort, network, and config failures.
|
|
@@ -262,6 +335,7 @@ try {
|
|
|
262
335
|
|
|
263
336
|
- `attrs(target, field, { label? })` / `crafty.attrs(...)` → `{ "data-crafty-block" | "data-crafty-entry", "data-crafty-field", "data-crafty-label"? }` to spread on the element that renders the value; `{}` when the target has no valid id. `field` is a field key or a path into a list or JSON value (`sections[3].title`, `items[2].title` for a block-type `repeater`, `tags[1]` for a `stringList`, `items[0].tags[1]` for a stringList inside a repeater item). The index is the item's position in the stored data.
|
|
264
337
|
- `attrsHtml(target, field, { label? })` → the same attributes as an escaped HTML string.
|
|
338
|
+
- `image(media, options?)` / `crafty.image(...)` and `srcset(media, widths, options?)` / `crafty.srcset(...)` → resized image URLs; see "Images: always go through `crafty.image()`".
|
|
265
339
|
- `previewFromRequest(req)` → preview token of an incoming request (URL string, `URL`, Web `Request`, Node `req.url`, Express `req.query`) or `null`.
|
|
266
340
|
- `readPreviewToken()` → token in the browser, or `null`.
|
|
267
341
|
- `enableVisualEditing(options)` from `@craftware/crafty-sdk/visual-editing` → connects the page to the editor; no-op outside the admin iframe. Options: `onRefresh`, `adminOrigins`, `page`, `entries`, `touchHighlightMs`.
|