@craftware/crafty-sdk 0.6.0 → 0.9.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 +68 -4
- package/dist/client.d.ts +11 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +12 -1
- 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 +6 -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/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/captcha.d.ts +20 -0
- package/dist/resources/captcha.d.ts.map +1 -0
- package/dist/resources/captcha.js +100 -0
- package/dist/resources/captcha.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 +11 -2
- package/dist/resources/forms.d.ts.map +1 -1
- package/dist/resources/forms.js +23 -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/resources/site.d.ts +12 -0
- package/dist/resources/site.d.ts.map +1 -0
- package/dist/resources/site.js +15 -0
- package/dist/resources/site.js.map +1 -0
- package/dist/structured-data.d.ts +41 -0
- package/dist/structured-data.d.ts.map +1 -0
- package/dist/structured-data.js +91 -0
- package/dist/structured-data.js.map +1 -0
- package/dist/types/public.d.ts +138 -1
- 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 +1 -1
- package/skills/crafty-cms/SKILL.md +48 -7
- package/skills/crafty-cms/references/mcp-configuration.md +23 -7
- package/skills/crafty-cms/references/migrate-to-crafty.md +93 -0
- package/skills/crafty-cms/references/sdk-development.md +84 -7
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: crafty-cms
|
|
3
|
-
description: "Build on Crafty CMS — configure sites, pages, blocks, collections, entries, forms, and media through the Crafty MCP server, then develop a frontend with the @craftware/crafty-sdk JavaScript SDK. Use when connecting an agent to a Crafty MCP server; calling Crafty MCP tools like list_sites, create_page, create_block_type, publish_form, upload_media, or request_media_upload; modelling site content with block types (repeater and stringList fields); installing or importing @craftware/crafty-sdk or createCraftyClient; fetching Crafty pages, entries, collections, or media; rendering page blocks; submitting Crafty forms; or preparing a site for the Crafty visual editor (crafty.attrs, enableVisualEditing, preview tokens, configure_visual_editor)."
|
|
3
|
+
description: "Build on Crafty CMS — start a new site from an official starter with `npm create crafty`, configure sites, pages, blocks, collections, entries, forms, and media through the Crafty MCP server, then develop a frontend with the @craftware/crafty-sdk JavaScript SDK. Use when starting a new Crafty site or project (npm create crafty, crafty create, starters); connecting an agent to a Crafty MCP server; calling Crafty MCP tools like list_sites, create_page, create_block_type, publish_form, upload_media, or request_media_upload; modelling site content with block types (repeater and stringList fields); installing or importing @craftware/crafty-sdk or createCraftyClient; fetching Crafty pages, entries, collections, or media; rendering page blocks; submitting Crafty forms; or preparing a site for the Crafty visual editor (crafty.attrs, enableVisualEditing, preview tokens, configure_visual_editor)."
|
|
4
4
|
license: MIT
|
|
5
5
|
metadata:
|
|
6
6
|
author: craftware
|
|
@@ -8,6 +8,12 @@ metadata:
|
|
|
8
8
|
|
|
9
9
|
# Crafty CMS (MCP + JavaScript SDK)
|
|
10
10
|
|
|
11
|
+
> **MCP prompt: `build-site`.** It takes a site from a business description to a published website: content model, pages, frontend with the SDK, preview and deploy, confirming the plan with you before it writes. Arguments: `description` (the business and what the site needs) and optionally `site_id` (an existing site). Needs a token with `crafty:write`.
|
|
12
|
+
>
|
|
13
|
+
> In Claude Code, type `/` and pick `mcp__<server>__build-site`, where `<server>` is the name you gave the Crafty server; other clients list it in their prompt menu. If your client does not show MCP prompts, ask the agent to call `get_guide getting-started` and follow it.
|
|
14
|
+
>
|
|
15
|
+
> Redesigning a client's existing site? The `redesign-site` prompt reads their current site and social media and proposes the new one before building it (see `get_guide migrate`).
|
|
16
|
+
|
|
11
17
|
Crafty is a headless, multi-tenant CMS. There are two integration surfaces, and they are used at different stages:
|
|
12
18
|
|
|
13
19
|
| Surface | Purpose | Auth | Use it to |
|
|
@@ -15,15 +21,36 @@ Crafty is a headless, multi-tenant CMS. There are two integration surfaces, and
|
|
|
15
21
|
| **MCP server** | Author and configure content | Bearer token (PAT or OAuth), scoped | Create sites, pages, blocks, collections, entries, forms; upload media; publish |
|
|
16
22
|
| **`@craftware/crafty-sdk`** | Read published content into an app | `X-API-Key` environment token | Fetch pages/blocks, entries, collections, media; submit forms from a site |
|
|
17
23
|
|
|
18
|
-
The normal flow is **configure with the MCP server → build a frontend with the SDK**.
|
|
24
|
+
The normal flow is **configure with the MCP server → build a frontend with the SDK**. For a **new site**, `npm create crafty` does both in one step (see [Start a new site](#start-a-new-site)).
|
|
19
25
|
|
|
20
26
|
## When to Use
|
|
21
27
|
|
|
28
|
+
- Starting a new Crafty site or frontend project from scratch (`npm create crafty`, `crafty create`, official starters).
|
|
22
29
|
- Connecting Claude, Cursor, VS Code, Codex, or ChatGPT to a Crafty tenant's MCP server.
|
|
23
30
|
- Driving Crafty MCP tools (`list_sites`, `create_page`, `create_entry`, `create_form`, `publish_page`, `upload_media`, …).
|
|
24
31
|
- Installing or importing `@craftware/crafty-sdk` / `createCraftyClient`.
|
|
25
32
|
- Fetching Crafty pages, entries, collections, or media in app code, or submitting a Crafty form.
|
|
26
33
|
|
|
34
|
+
## Start a new site
|
|
35
|
+
|
|
36
|
+
When the user wants a **new** site (not Crafty added to an existing frontend), don't model content and write the frontend by hand: scaffold it from an official starter.
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
npm create crafty@latest mi-sitio -- --url=https://<tenant>.crafty.do
|
|
40
|
+
# same flow: npx @craftware/crafty-cli create mi-sitio --url=https://<tenant>.crafty.do
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
It asks for the framework and template (today: Astro, `astro-services` = "sitio de servicios" or `astro-docs` = "documentación"; Next.js and Nuxt are coming), logs in through the browser (device flow; the user approves, an admin account is needed), creates the site with the starter's block types, collections, form and demo content, configures the visual editor routes, and writes `.env` (`CRAFTY_API_URL`, `CRAFTY_API_KEY`, `CRAFTY_SITE`), `crafty.json` and `src/crafty.types.ts`. Then:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
cd mi-sitio && npm install && npm run dev # npm run deploy → Crafty Hosting
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
- Flags (after `--`): `--url`, `--framework=astro`, `--template=astro-services|astro-docs`, `--name`, `--site=<slug>`, `--draft` (keep demo content as drafts; by default it is published so the static build shows it), `--yes` (no prompts). Agents should pass `--url` and `--yes` so nothing blocks on a prompt; the browser approval still needs the user.
|
|
50
|
+
- The command is interactive only for the login: tell the user to open the printed URL and approve the code.
|
|
51
|
+
- After it finishes, **read the project's `AGENTS.md`** and follow its conventions (one renderer per block type in `src/render/blocks/`, routes in `src/lib/routes.ts`, `npm run types` after changing the content model) instead of the generic steps in Phase 2.
|
|
52
|
+
- If the site slug already exists it fails before creating anything; if provisioning fails midway it names the half-created site: delete it in the panel or rerun with another `--site`.
|
|
53
|
+
|
|
27
54
|
## Scope
|
|
28
55
|
|
|
29
56
|
- **In scope:** using a *deployed* Crafty tenant via its MCP server and the published SDK.
|
|
@@ -59,9 +86,9 @@ How to model it:
|
|
|
59
86
|
- several images (gallery, slider, logo wall): `{ key: "gallery", type: "multiReference", model: "Media" }`, value `[42, 43, 44]`;
|
|
60
87
|
- an image per repeater item: a `reference` subfield with `model: "Media"` (`multiReference` is top-level only).
|
|
61
88
|
|
|
62
|
-
The public API returns the id under the field key and adds `<key>_media` with the file (`{ id, url, file_name, mime_type, size, is_public }`, a list for `multiReference`), so the frontend
|
|
89
|
+
The public API returns the id under the field key and adds `<key>_media` with the file (`{ id, url, file_name, alt, mime_type, size, is_public, image_transform_base }`, a list for `multiReference`), so the frontend renders `crafty.image(block.data.image_media, { width: 800, format: "auto" })` with `alt={block.data.image_media?.alt ?? ""}`. Editors get a media picker in the panel and the visual editor can swap the image. A `photo_url` string field gives them none of that and breaks when the file moves.
|
|
63
90
|
- **Shared data** (address, phone, social links, footer text used in several places): block types have no global blocks. Put it in a dedicated page (e.g. slug `site-settings`) with one `site-settings` block and read it with `crafty.pages.get("site-settings")`.
|
|
64
|
-
- **Languages**: pages
|
|
91
|
+
- **Languages**: do not duplicate pages per language. Set the site's locales (`configure_locales`, e.g. default `es` plus `en`), write the default-locale content as usual, then add each translation with `upsert_translation` (entries; pages with their blocks by id). The frontend passes `locale` (`createCraftyClient({ locale })` or per call) and untranslated fields fall back to the default. Mark fields that must never change per language (SKU, codes) with `translatable: false`. `meta.translations` on `get()` lists the locales an item has, for `hreflang`. When AI translation is enabled for the workspace (from Crafty central) and for the site (`configure_translation` with `enabled: true`), `translate_content` translates an entry or page (with its blocks) into the site's locales without overwriting what a person wrote, and `configure_translation` also turns on re-translation after every change plus a glossary of untranslated terms.
|
|
65
92
|
- There is no `json` field type in block types. If you catch yourself wanting one, model it as a repeater or `stringList`.
|
|
66
93
|
- **References to records**: `{ type: "reference", collection: "<collection slug>" }` (one entry) or `multiReference` (several); the API expands them into the referenced entries.
|
|
67
94
|
|
|
@@ -99,14 +126,18 @@ Block types: `get_block_type` (`site_id` + `block_type_id` or `slug`) → `creat
|
|
|
99
126
|
|
|
100
127
|
Collections/entries: `list_collections` → `create_entry` (`collection_slug`, `site_id`, `slug`, `data`, optional `status: "draft"` to prepare it unpublished) → `update_entry` (snapshots a revision) → `publish_entry` / `unpublish_entry`. To publish or unpublish a page or entry at a given time, use `schedule_publication` (`publish_at` / `unpublish_at`).
|
|
101
128
|
Forms: `create_form` (`site_id`, `name`, `slug`, `fields`) → `publish_form` → `list_form_submissions`.
|
|
102
|
-
Media: upload files before you fill content, and put the returned media `id` in `reference` / `multiReference` fields with `model: "Media"` (`list_media` lists existing ids). `upload_media` (`url`, `site_id`, optional `collection_name`, `sha256`) takes an http(s) URL or a small base64 data URI (256 KB decoded). For a **local binary file** call `request_media_upload` (`site_id`, `file_name`, `sha256`, optional `collection_name`) and run the returned `curl -T` command from your shell; the bytes never pass through the model and the upload is verified against the sha256. Files land on a public disk only if the `collection_name` repository is public — create or change it with `manage_media_collection` (`site_id`, `key`, `is_public`) BEFORE uploading.
|
|
129
|
+
Media: upload files before you fill content, and put the returned media `id` in `reference` / `multiReference` fields with `model: "Media"` (`list_media` lists existing ids). `upload_media` (`url`, `site_id`, optional `collection_name`, `sha256`) takes an http(s) URL or a small base64 data URI (256 KB decoded). For a **local binary file** call `request_media_upload` (`site_id`, `file_name`, `sha256`, optional `collection_name`) and run the returned `curl -T` command from your shell; the bytes never pass through the model and the upload is verified against the sha256. Files land on a public disk only if the `collection_name` repository is public — create or change it with `manage_media_collection` (`site_id`, `key`, `is_public`) BEFORE uploading. **Always set alt text on content images**: pass `alt` (a concise description of what the image shows, in the site language, max 500 characters) to `upload_media` / `request_media_upload`, or set it later with `update_media` (`site_id`, `media_id`, `alt`); `list_media` shows `alt: null` for files still missing it. Purely decorative images may keep `alt` empty.
|
|
103
130
|
Cache: `set_api_cache_status` (`site_id`, `enabled`) to toggle per-site; `flush_api_cache` (`site_id`, optional `resource`) to invalidate cached responses after bulk edits.
|
|
104
131
|
Visual editor: `configure_visual_editor` (`site_id`, optional `environment`, `frontend_url`, `routes`, `render_mode`, `upload_media_collection`) — see `references/visual-editor.md`.
|
|
105
132
|
|
|
106
133
|
> `update_page` **replaces all blocks** when `blocks` is supplied (send every block again with its `id` to keep it). `update_entry` creates a revision. `create_page` always starts as `draft`.
|
|
107
134
|
|
|
135
|
+
Migrating an existing site (WordPress, Webflow, a static site…)? Don't recreate it by hand: follow `references/migrate-to-crafty.md` — it uses `bulk_upsert_entries` (100 entries per call, drafts, `dry_run`), `import_media_from_urls` (50 URLs per call, deduplicated) and `upsert_redirects` so old URLs keep working.
|
|
136
|
+
|
|
108
137
|
## Phase 2 — Build a frontend with the SDK
|
|
109
138
|
|
|
139
|
+
Starting a new site from scratch? Use `npm create crafty` ([Start a new site](#start-a-new-site)) and follow the starter's `AGENTS.md` instead of the steps below. Adding Crafty to an existing frontend:
|
|
140
|
+
|
|
110
141
|
```bash
|
|
111
142
|
npm install @craftware/crafty-sdk
|
|
112
143
|
```
|
|
@@ -128,12 +159,18 @@ const hero = page.block("hero"); // first block of that type, or null
|
|
|
128
159
|
const posts = await crafty.entries.list("posts", { per_page: 15 });
|
|
129
160
|
```
|
|
130
161
|
|
|
162
|
+
**Generate types before writing code against the content.** Field shapes depend on the site's collections and block types — do not guess them. Run `npx @craftware/crafty-cli types` (reads `CRAFTY_API_URL` + `CRAFTY_API_KEY` from `.env`, or pass `--url` / `--api-key`; `--out` changes the file) to write `crafty.types.ts` with `<Slug>Entry` per collection, `<Slug>BlockData` / `<Slug>Block` per block type and the `CraftyPageBlock` union; cast SDK results to them (`page.blocks as unknown as CraftyPageBlock[]`). Types follow the API key's environment (staging may differ from production); re-run it after `create_block_type` / `update_block_type` / `update_collection`. The raw JSON Schema is `GET /api/v1/schema`. Details: `references/sdk-development.md`.
|
|
163
|
+
|
|
164
|
+
Images: render every Crafty image with `crafty.image(media, { width, format: "auto" })` and `crafty.srcset(media, [400, 800, 1200], { format: "auto" })` instead of the raw `url`; they return resized AVIF/WebP URLs when the server has image transformations enabled and fall back to the original `url` otherwise. Never hand-build `/cdn-cgi/image/` URLs.
|
|
165
|
+
|
|
131
166
|
`pages.get()` returns the page enriched with block helpers (`page.block(type)`, `page.blocksOf(type)`, `page.byType`, …). **Full client config, every resource and return shape, block helpers, errors, and an end-to-end example: read `references/sdk-development.md`.**
|
|
132
167
|
|
|
133
168
|
Serving `llms.txt` / `llms-full.txt` for AI agents and crawlers: `crafty.llms.handler()` as a route (Next/Astro/Nuxt), or `crafty.llms.index()` / `full()` written into `dist/` for a static build. Title, summary, language, and routeless collections are configured on the "llms.txt" admin screen or the `configure_llms` MCP tool. **Full API, per-framework wiring, and error handling: `references/sdk-development.md`.**
|
|
134
169
|
|
|
135
170
|
Analytics and ad pixels (Google Tag Manager, GA4, Meta Pixel, TikTok Pixel): do **not** hard-code their snippets into the site. Set the IDs on the "Analítica" admin screen or with the `configure_analytics` MCP tool; Crafty hosting injects the official loaders into every HTML page on the next deploy. If the site already loads GA4 or pixels through GTM, set only the GTM container. Hosted elsewhere: render `(await crafty.analytics.get()).head` before `</head>` in SSR, or call `crafty.analytics.load()` once in the browser (it skips itself when the deploy already injected them). **Details: `references/sdk-development.md`.**
|
|
136
171
|
|
|
172
|
+
Structured data (schema.org JSON-LD for Google rich results and AI search): do **not** write JSON-LD by hand. Fill the business data (type, address, hours, phone, social profiles) with the `configure_structured_data` MCP tool, map collections to Product / Article / BlogPosting / FAQPage / Event / Service with `structured_data` on `create_collection` / `update_collection`, and render `crafty.structuredData(page, entries)` in `<head>`: the home page carries the business JSON-LD, mapped entries their own. For FAQ blocks, `crafty.structuredData(crafty.faqPage(block.data.items))`. **Details: `references/sdk-development.md`.**
|
|
173
|
+
|
|
137
174
|
## Phase 3 — Deploy a static site with the CLI
|
|
138
175
|
|
|
139
176
|
```bash
|
|
@@ -184,12 +221,15 @@ These are two different credentials — do not mix them:
|
|
|
184
221
|
- **Wrong tenant host** → the MCP endpoint won't resolve. Use the exact `https://<tenant-domain>/mcp`; each tenant has its own endpoint.
|
|
185
222
|
- **401 from the MCP server** → the Bearer token is missing, invalid, or expired; reconnect or regenerate it.
|
|
186
223
|
- **A tool is missing or fails with "Insufficient OAuth scope"** → the token lacks that tool's scope; re-issue it with `crafty:write` / `crafty:publish` / `crafty:submissions` as needed.
|
|
187
|
-
- **`forms.submit()` throws `BAD_REQUEST`** → `siteSlug` was not set in `createCraftyClient()`. Submissions hit the public endpoint
|
|
224
|
+
- **`forms.submit()` throws `BAD_REQUEST`** → `siteSlug` was not set in `createCraftyClient()`. Submissions hit the public endpoint; the API key, when set, only picks the environment the submission lands in.
|
|
188
225
|
- **`update_entry` / `update_page` wipe fields you don't send** → both MCP tools do a **full replace**, not a merge. If you send only the new fields, all existing fields are deleted in that revision. Always fetch the current entry first (`get_entry`), merge your changes into the full field map, then call `update_entry` with the complete merged object. Entry `data` is validated against the collection schema: invalid values are rejected and undeclared keys are dropped (listed in `warnings`).
|
|
189
226
|
- **`create_page` / `update_page` reject block data** → block data is validated against the block type (`blocks.2.data.items.1.title: …`), `number` fields are integers (use `decimal` for prices), `select` values must be one of the declared options, `string` fields max 255 characters, and `block_type_id` must belong to the same site. Read the schema with `get_block_type` first.
|
|
190
227
|
- **Data silently missing from a block** → keys not declared in the block type are dropped; check the `warnings` array in the response.
|
|
191
228
|
- **Modelled a list as a collection or a `json` field** → use a block type with a `repeater` (or `stringList`) instead; see "Model content with block types first". Inside a collection, use a `repeater`, `stringList`, `select` with `multiple: true` or `multiReference` (to media or to another collection) instead of `json`.
|
|
192
|
-
- **
|
|
229
|
+
- **Guessed a field name or shape (`block.data.title` vs `heading`, a select value, `image` vs `image_media.url`)** → generate `crafty.types.ts` with `crafty types` and let the compiler catch it.
|
|
230
|
+
- **Stored an image as a `url` / `string` field (`photo_url`, `image`, `logo`)** → upload it to the media library and use a `reference` (`multiReference` for several) with `model: "Media"`; render `crafty.image(<key>_media, { width, format: "auto" })`. Change an existing field with `update_block_type` / `update_collection` and re-save the content with media ids.
|
|
231
|
+
- **Serving the original image (`<img src={media.url}>`)** → large uploads hurt LCP. Use `crafty.image(media, { width, format: "auto" })` plus `crafty.srcset()`; it falls back to `url` when transforms are unavailable.
|
|
232
|
+
- **Uploaded images without alt text** → set `alt` with `update_media` for every content image (`list_media` shows which have `alt: null`) and render `<key>_media.alt` in the frontend's `<img alt>`.
|
|
193
233
|
- **Content not visible via the SDK** → it isn't published. Pages/forms must be published (`publish_page` / `publish_form`); entries created as drafts need `publish_entry`.
|
|
194
234
|
- **Confusing the two tokens** → the SDK `apiKey` (X-API-Key) is not an MCP Bearer token.
|
|
195
235
|
- **Stale skill copy** → a project copy of this skill (e.g. `.claude/skills/crafty-cms`) does not update with the SDK. After upgrading `@craftware/crafty-sdk`, copy `node_modules/@craftware/crafty-sdk/skills/crafty-cms` over it.
|
|
@@ -198,4 +238,5 @@ These are two different credentials — do not mix them:
|
|
|
198
238
|
|
|
199
239
|
- `references/mcp-configuration.md` — connecting every client, scopes, and the full MCP tool catalog with workflows.
|
|
200
240
|
- `references/sdk-development.md` — the complete SDK surface, block helpers, error handling, and examples.
|
|
241
|
+
- `references/migrate-to-crafty.md` — migrating an existing site (WordPress or any other) into Crafty: crawl, classify, model, bulk import as drafts, media, redirects, human review, publish.
|
|
201
242
|
- `references/visual-editor.md` — preparing a site for the visual editor: the mandatory question, marking, preview tokens, bridge, headers, `configure_visual_editor`, verification.
|
|
@@ -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:
|
|
@@ -106,9 +108,11 @@ All tools return a JSON text response. IDs are integers; slugs are strings. Pagi
|
|
|
106
108
|
| `list_collections` | — | `site_id` | active collections (with their `id`, used by `update_collection` / `delete_collection`) |
|
|
107
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
|
|
|
@@ -136,15 +140,23 @@ Form submissions may contain personal data, so they require their own scope.
|
|
|
136
140
|
| `delete_collection` | `collection_id`, `confirm: true` | — | **permanently** deletes the collection and all its entries |
|
|
137
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 |
|
|
@@ -163,7 +175,7 @@ Form submissions may contain personal data, so they require their own scope.
|
|
|
163
175
|
|
|
164
176
|
1. `list_sites` → choose a `site_id` (or `create_site`).
|
|
165
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.
|
|
166
|
-
3. `create_page`
|
|
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`).
|
|
167
179
|
4. `publish_page`.
|
|
168
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).
|
|
169
181
|
|
|
@@ -189,9 +201,13 @@ Form submissions may contain personal data, so they require their own scope.
|
|
|
189
201
|
**Media**
|
|
190
202
|
|
|
191
203
|
1. Need permanent public URLs? `manage_media_collection` with `site_id`, `key`, `is_public: true` first (otherwise files get expiring signed URLs).
|
|
192
|
-
2. From a URL or a tiny file: `upload_media` with `url` and `
|
|
193
|
-
From a local file: compute `shasum -a 256 <file>`, call `request_media_upload
|
|
194
|
-
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.
|
|
195
211
|
|
|
196
212
|
**Visual editor**
|
|
197
213
|
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Migrating an existing site to Crafty
|
|
2
|
+
|
|
3
|
+
> **MCP prompt: `migrate-site`.** It runs this whole runbook for you and stops at every checkpoint for you to confirm. Arguments: `site_id` (the target site), `source` (the old site's URL, its `sitemap.xml` or a description of the export) and optionally `environment` (default `production`) and `research` (default `yes`: it also researches the brand on the web and proposes content the old site lacks; `no` for a strict 1:1 move). Needs a token with `crafty:write`.
|
|
4
|
+
>
|
|
5
|
+
> In Claude Code, type `/` and pick `mcp__<server>__migrate-site`, where `<server>` is the name you gave the Crafty server; other clients list it in their prompt menu. If your client does not show MCP prompts, ask the agent to call `get_guide migrate` and follow it.
|
|
6
|
+
>
|
|
7
|
+
> **Redesign instead of a 1:1 move? MCP prompt: `redesign-site`.** It studies the client's current site, social media and what the rest of the web says about them, sums up their brand, voice and content, and proposes a new site (site map, sections built from your block types, draft copy, visual direction) before it creates anything. Once you approve the proposal it builds it as drafts and never publishes. Arguments: `website` (the current site's URL) and optionally `social` (profile URLs or handles), `goals` and `site_id` (leave it out to make the proposal before the client has a Crafty site). Needs a token with `crafty:write`. Social networks often block automated reading, so be ready to paste the bio and a few posts or share screenshots.
|
|
8
|
+
|
|
9
|
+
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.
|
|
10
|
+
|
|
11
|
+
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.
|
|
12
|
+
|
|
13
|
+
## Golden rules
|
|
14
|
+
|
|
15
|
+
- **Everything lands as a draft.** Never pass `publish: true` until a human has reviewed the content in the panel.
|
|
16
|
+
- **`dry_run: true` first** on every `bulk_upsert_entries` and `upsert_redirects` batch; fix the `failed` items, then run it for real.
|
|
17
|
+
- **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.
|
|
18
|
+
- **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.
|
|
19
|
+
- **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.
|
|
20
|
+
- Work against the `production` environment of the target site unless the user asks for another (tools take `environment`).
|
|
21
|
+
|
|
22
|
+
## Phase 1 — Crawl
|
|
23
|
+
|
|
24
|
+
Build an inventory of every URL of the old site:
|
|
25
|
+
|
|
26
|
+
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.
|
|
27
|
+
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.
|
|
28
|
+
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.
|
|
29
|
+
4. Research the brand beyond its site: search the web for its name (Google Business profile and reviews, social media, press, directories and marketplaces, award or partner pages). List what the old site lacks as **proposed additions**: reviews and testimonials, FAQs drawn from customer questions, opening hours, locations, services or products listed elsewhere, awards, press mentions, social links. Give each one its source URL and how sure you are, flag information that contradicts the site or looks outdated, and never invent facts. Keep the additions apart from the inventory: they are proposals, not migrated content. Skip this step for a strict 1:1 move (the `migrate-site` prompt takes `research: "no"`).
|
|
30
|
+
|
|
31
|
+
**Checkpoint:** show the user the counts per type, any URL you could not fetch and the proposed additions. Only the additions the user picks are added, in phase 5, as new drafts.
|
|
32
|
+
|
|
33
|
+
## Phase 2 — Classify
|
|
34
|
+
|
|
35
|
+
Group the URLs into:
|
|
36
|
+
|
|
37
|
+
- **Singleton pages** (home, about, contact, landing pages) → Crafty **pages** built from block types.
|
|
38
|
+
- **Repeating content** (posts, news, products, team members, case studies, events) → one **collection** per type, one entry per item.
|
|
39
|
+
- **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.
|
|
40
|
+
- **Junk** (search results, feeds, attachment pages, `?replytocom=` …) → drop; redirect to the closest useful page when they had traffic.
|
|
41
|
+
|
|
42
|
+
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.
|
|
43
|
+
|
|
44
|
+
**Checkpoint:** the user approves the content types and the URL scheme.
|
|
45
|
+
|
|
46
|
+
## Phase 3 — Model
|
|
47
|
+
|
|
48
|
+
Follow "Model content with block types first" in `SKILL.md`:
|
|
49
|
+
|
|
50
|
+
1. Inspect the current schemas: `list_block_types`, `list_collections`.
|
|
51
|
+
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.
|
|
52
|
+
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.
|
|
53
|
+
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`.
|
|
54
|
+
|
|
55
|
+
**Checkpoint:** the user reviews the schemas in the panel before any import.
|
|
56
|
+
|
|
57
|
+
## Phase 4 — Media
|
|
58
|
+
|
|
59
|
+
1. From the inventory, list the distinct image and file URLs the migrated content uses (full-size originals only).
|
|
60
|
+
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`.
|
|
61
|
+
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`).
|
|
62
|
+
|
|
63
|
+
## Phase 5 — Content (as drafts)
|
|
64
|
+
|
|
65
|
+
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.
|
|
66
|
+
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).
|
|
67
|
+
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.
|
|
68
|
+
4. Write the additions the user picked in phase 1 as new drafts (no `external_id` from the old site) and list them, with their sources, in the review report.
|
|
69
|
+
5. 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.
|
|
70
|
+
|
|
71
|
+
## Phase 6 — Redirects
|
|
72
|
+
|
|
73
|
+
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.
|
|
74
|
+
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`.
|
|
75
|
+
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).
|
|
76
|
+
4. `upsert_redirects` with `dry_run: true`, fix `failed`, then for real (500 per call). Check with `list_redirects`.
|
|
77
|
+
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.
|
|
78
|
+
|
|
79
|
+
## Phase 7 — Human review
|
|
80
|
+
|
|
81
|
+
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`).
|
|
82
|
+
|
|
83
|
+
## Phase 8 — Publish and switch over
|
|
84
|
+
|
|
85
|
+
Only after the user's explicit go-ahead:
|
|
86
|
+
|
|
87
|
+
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.
|
|
88
|
+
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.
|
|
89
|
+
3. Keep the redirects: removing them later breaks links and search rankings.
|
|
90
|
+
|
|
91
|
+
## Not covered yet
|
|
92
|
+
|
|
93
|
+
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,7 +113,7 @@ 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 (`model: "Media"`): the media id, plus `<key>_media` with `{ id, url, file_name, mime_type, size, is_public }` or `null` |
|
|
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
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
118
|
|
|
119
119
|
Entries expose media references the same way (`entry.image` is the id, `entry.image_media.url` the file).
|
|
@@ -124,21 +124,73 @@ const items = solutions?.data.items ?? [];
|
|
|
124
124
|
items.map((item) => `${item.title}: ${item.tags.join(", ")}`);
|
|
125
125
|
|
|
126
126
|
const hero = home.block("hero");
|
|
127
|
-
const heroUrl = hero?.data.image_media
|
|
127
|
+
const heroUrl = crafty.image(hero?.data.image_media, { width: 1600, format: "auto" }); // reference, model "Media"
|
|
128
128
|
const slides = hero?.data.gallery_media ?? []; // multiReference, model "Media"
|
|
129
129
|
```
|
|
130
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
|
+
/>
|
|
147
|
+
```
|
|
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
|
+
|
|
131
156
|
Prefer modelling lists as `repeater` / `stringList` fields on a block type over collections or `json` fields; see the `crafty-cms` skill.
|
|
132
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
|
+
|
|
133
184
|
### `forms`
|
|
134
185
|
|
|
135
186
|
```ts
|
|
136
187
|
crafty.forms.list(options?) // => { data: CraftyForm[] } (requires apiKey)
|
|
137
188
|
crafty.forms.get(slug, options?) // => { data: CraftyForm } (requires apiKey)
|
|
138
|
-
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)
|
|
190
|
+
crafty.forms.captcha(form, container) // => CraftyFormCaptcha | null (browser; renders the form's captcha widget)
|
|
139
191
|
```
|
|
140
192
|
|
|
141
|
-
`forms.submit()` uses a **public** endpoint:
|
|
193
|
+
`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`.
|
|
142
194
|
|
|
143
195
|
```ts
|
|
144
196
|
const crafty = createCraftyClient({
|
|
@@ -154,6 +206,17 @@ const result = await crafty.forms.submit("contact", {
|
|
|
154
206
|
// => { id, form_id, submitted_at }
|
|
155
207
|
```
|
|
156
208
|
|
|
209
|
+
Forms protected by a captcha return `captcha: { provider, site_key, token_field }` (absent otherwise), and their submissions are rejected with 422 without the widget token. In the browser, render the widget next to the form and pass it to `submit()`:
|
|
210
|
+
|
|
211
|
+
```ts
|
|
212
|
+
const { data: form } = await crafty.forms.get("contact");
|
|
213
|
+
const captcha = await crafty.forms.captcha(form, document.querySelector("#captcha")!); // null when not needed
|
|
214
|
+
|
|
215
|
+
await crafty.forms.submit("contact", values, { captcha }); // adds the token, then resets the widget
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Turnstile runs invisibly and only shows a challenge to suspicious visitors. Without the SDK, load the provider script yourself and post the token in `captcha.token_field`.
|
|
219
|
+
|
|
157
220
|
> Note the asymmetry: `collections.get()` returns the bare object, while `forms.get()` and `entries.get()` are wrapped in `{ data }`.
|
|
158
221
|
|
|
159
222
|
### `mediaCollections`
|
|
@@ -170,7 +233,7 @@ crafty.mediaCollections.list(query?, options?) // => { data: CraftyMediaCollect
|
|
|
170
233
|
crafty.mediaFiles.list(query?, options?) // => { data: CraftyMediaFile[], links, meta: { ...pagination, ttl_minutes } }
|
|
171
234
|
```
|
|
172
235
|
|
|
173
|
-
`query`: `collection`, `directory_id`, `ttl_minutes` (signed-URL lifetime), `per_page`, `page`.
|
|
236
|
+
`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.
|
|
174
237
|
|
|
175
238
|
## `llms.txt`
|
|
176
239
|
|
|
@@ -230,6 +293,19 @@ crafty.analytics.load(options?) // browser only => "loaded" | "already-loaded"
|
|
|
230
293
|
|
|
231
294
|
Never hard-code GTM / GA4 / pixel snippets in the site, and never add GA4 both here and inside GTM (page views are counted twice).
|
|
232
295
|
|
|
296
|
+
## Structured data
|
|
297
|
+
|
|
298
|
+
The public API serves schema.org JSON-LD in `structured_data`: on the home page (`pages.get(homeSlug)`, the business set with `configure_structured_data`), on `crafty.site.get()`, and on each published entry of a collection with a structured data mapping (`structured_data` on `create_collection` / `update_collection`). It is `null` when nothing is configured or a required property is empty.
|
|
299
|
+
|
|
300
|
+
```ts
|
|
301
|
+
crafty.site.get(options?) // => { name, slug, environment, default_locale, locales, structured_data }
|
|
302
|
+
crafty.structuredData(...sources) // => '<script type="application/ld+json">…</script>' tags, "" when none
|
|
303
|
+
crafty.jsonLd(...sources) // => the JSON-LD objects, for useHead / <Head>
|
|
304
|
+
crafty.faqPage([{ question, answer }]) // => FAQPage JSON-LD for a FAQ block, or null
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
Sources can be pages, entries, their API responses, list responses, the site or JSON-LD objects; FAQPage objects are merged into one per page. The script content escapes `<`, so it is safe to inject as raw HTML (`set:html`, `v-html`, `dangerouslySetInnerHTML`). Validate the result at https://search.google.com/test/rich-results.
|
|
308
|
+
|
|
233
309
|
## Error handling
|
|
234
310
|
|
|
235
311
|
The SDK throws `CraftyError` for HTTP, timeout, abort, network, and config failures.
|
|
@@ -284,6 +360,7 @@ try {
|
|
|
284
360
|
|
|
285
361
|
- `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.
|
|
286
362
|
- `attrsHtml(target, field, { label? })` → the same attributes as an escaped HTML string.
|
|
363
|
+
- `image(media, options?)` / `crafty.image(...)` and `srcset(media, widths, options?)` / `crafty.srcset(...)` → resized image URLs; see "Images: always go through `crafty.image()`".
|
|
287
364
|
- `previewFromRequest(req)` → preview token of an incoming request (URL string, `URL`, Web `Request`, Node `req.url`, Express `req.query`) or `null`.
|
|
288
365
|
- `readPreviewToken()` → token in the browser, or `null`.
|
|
289
366
|
- `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`.
|