@craftware/crafty-sdk

中文文档
0.12.0发布于 2026/11/01TypeScriptES Module零依赖

Official JavaScript SDK for Crafty CMS public API

原始使用文档
文档目录24 节
  1. AI agent skill
  2. Requirements
  3. Create a client
  4. Client config
  5. Available resources
  6. collections
  7. entries
  8. pages
  9. Localized content
  10. forms
  11. mediaCollections
  12. mediaFiles
  13. bookings
  14. Images
  15. llms.txt
  16. Analytics
  17. Structured data (JSON-LD)
  18. Request options (per call)
  19. Responses (current shapes)
  20. Errors
  21. End-to-end example
  22. Pages scoping (site+environment via API key)
  23. Visual editor
  24. Notes

@craftware/crafty-sdk

Official JavaScript SDK for the Crafty public API (current local implementation).

AI agent skill

This package ships an agent skill that teaches Claude, Cursor, and other agents how to configure a Crafty site through its MCP server and build against it with this SDK. It is included in the published package under skills/crafty-cms/.

The quickest way to install it, together with the CLI's crafty-deploy skill, is npx @craftware/crafty-cli skills install (into .claude/skills/; --global for ~/.claude/skills/, --dir=<path> for other agents). The skills are also downloadable as zips for Claude.ai and Claude Desktop at https://docs.crafty.do/skills/crafty-cms.zip.

Or copy it into your project's skills directory after installing the SDK:

cp -r node_modules/@craftware/crafty-sdk/skills/crafty-cms .claude/skills/

(Use the equivalent skills directory for your agent, e.g. .cursor/skills/.) The skill is self-contained — it includes SKILL.md plus references/mcp-configuration.md and references/sdk-development.md.

Requirements

  • Node >=20 (package engine)
  • Or any runtime with fetch available (browser, Bun, Deno, etc.)

Create a client

import { createCraftyClient } from "@craftware/crafty-sdk";

const crafty = createCraftyClient({
  baseUrl: "https://tenant.crafty.do",
  apiKey: "ENVIRONMENT_API_TOKEN",
  timeoutMs: 10_000
});

Client config

  • baseUrl (required): tenant base URL. The SDK appends /api/v1 automatically if missing.
  • apiKey (optional): environment API token sent as X-API-Key.
    • When provided, the API resolves the active site from this token.
    • pages.* requests use this site context automatically (you do not pass siteSlug).
  • siteSlug (optional): site slug used for public form submissions (forms.submit()).
  • fetch (optional): custom fetch implementation.
  • headers (optional): default headers applied to all requests.
  • timeoutMs (optional): default timeout (ms) for all requests. A timeout is only applied when the value is a number greater than 0.
  • preview (optional): visual editor preview token sent as X-Crafty-Preview. Omit it in the browser (the SDK reads ?crafty_preview= and keeps it in sessionStorage); on the server pass previewFromRequest(req); pass false to never send one.
  • onPreviewInvalid (optional): called when the API answers X-Crafty-Preview-Status: invalid (expired or foreign token; the response is the public content).
  • locale (optional): default locale for entries.* and pages.* (e.g. "en", "pt-BR"). See Localized content.

Available resources

collections

  • crafty.collections.list(options?)
  • crafty.collections.get(slug, options?)

entries

  • crafty.entries.list(collectionSlug, query?, options?)
  • crafty.entries.get(collectionSlug, id, options?)

Only published entries are returned (each with status and published_at; sort_by: "published_at" orders by publication date). Draft entries are included only in preview mode.

Supported common query params:

  • sort_by (any sortable field, including collection fields like title)
  • sort_order (asc | desc)
  • q (case-insensitive text search over the collection's string, text and longText fields; 2 to 100 characters)
  • per_page
  • page
  • Additional filters as key/value pairs (passed through to the API)

pages

  • crafty.pages.list(query?, options?)
  • crafty.pages.get(pageSlug, options?)

pages.* endpoints are scoped by the environment resolved from apiKey.

pages.get() returns a page response enriched with block helpers:

  • page.data => the raw CraftyPage
  • page.blocks => all blocks (CraftyBlock[]; a non-array data.blocks is coerced to [])
  • page.block(type) => first block of that type or null
  • page.blocksOf(type) => all blocks of that type ([] if none)
  • page.hasBlock(type) => boolean
  • page.requireBlock(type) => first block, or throws a plain Error (not a CraftyError)
  • page.byType.<type> => array of blocks for that type
  • page.firstByType.<type> => first block for that type or null

Localized content

When the site has locales configured in Crafty, entries and pages can be requested in any of them. Pass locale to the client, or per call in options:

const crafty = createCraftyClient({ baseUrl, apiKey, locale: "en" });

const posts = await crafty.entries.list("posts");                        // English
const home = await crafty.pages.get("home", { locale: "pt-BR" });        // overrides the client locale

Fallback is per field: anything not translated comes back in the site's default locale, so a partial translation never leaves empty fields. An unsupported locale falls back to the closest one (en-US → en) or the default. Responses carry meta.locale (the locale served), meta.default_locale and meta.locales; get() responses add meta.translations, the locales that item has content in (useful for hreflang). Sites without locales ignore locale and return no locale meta.

forms

  • crafty.forms.list(options?) — list all forms (requires API key)
  • crafty.forms.get(slug, options?) — get a single form by slug (requires API key)
  • crafty.forms.submit(formSlug, data, options?) — submit a public form into the API key's environment

The submit() method uses a public endpoint that does not require authentication. When the client has an apiKey, it is sent so the submission lands in that key's environment (a staging key writes to staging); without one it goes to production. You must provide siteSlug in the client config for submissions to work — calling submit() without it throws a synchronous CraftyError with code BAD_REQUEST:

const crafty = createCraftyClient({
  baseUrl: "https://tenant.crafty.do",
  apiKey: "ENVIRONMENT_API_TOKEN",
  siteSlug: "my-site",
});

// List forms (authenticated)
const forms = await crafty.forms.list();

// Get form details (authenticated)
const contactForm = await crafty.forms.get("contact");

// Submit a form into the API key's environment
const result = await crafty.forms.submit("contact", {
  name: "Jane Doe",
  email: "[email protected]",
  message: "Hello!",
});
// result => { id, form_id, submitted_at }

mediaCollections

  • crafty.mediaCollections.list(query?, options?)

Supported query.include values:

  • "stats"
  • "directories"
  • Array form is supported, de-duplicated, and serialized as comma-separated values; a raw string is passed through unchanged

mediaFiles

  • crafty.mediaFiles.list(query?, options?)

Supported common query params:

  • collection
  • directory_id
  • ttl_minutes
  • per_page
  • page

Each file has url, is_public, expires_at and image_transform_base (see Images).

bookings

Online bookings (needs siteSlug; the site's "Citas" tab must be on):

  • crafty.bookings.config(options?) — services (with duration and price), professionals, hours, rules, time zone, currency and payment methods; enabled: false while bookings are off.
  • crafty.bookings.availability({ service, professional?, from?, to? }, options?) — free slots per local day: { days: [{ date, closed, slots: [{ start, time, professionals }] }] }. Call it in the browser, never at build time.
  • crafty.checkout.start({ booking: { service, professional?, start } }, { returnUrl?, cancelUrl?, fields? }) — holds the slot and sends the visitor to the hosted checkout (checkout.create returns the session without redirecting). A slot taken meanwhile rejects with SLOT_UNAVAILABLE.
  • crafty.bookings.get(number, token, options?) — a booking as its customer sees it (token from their link).

Guide: skills/crafty-cms/references/online-bookings.md.

Images

crafty.image(media, options?) returns the URL of a resized, re-encoded image; crafty.srcset(media, widths, options?) returns a srcset value with one URL per width. Both are also exported as imageUrl and imageSrcSet. media is a CraftyMediaFile, a <key>_media object from a block or entry, or a URL string.

const photo = block.data.image_media;

<img
  src={crafty.image(photo, { width: 800, format: "auto" })}
  srcSet={crafty.srcset(photo, [400, 800, 1200, 1600], { format: "auto" })}
  sizes="(min-width: 768px) 50vw, 100vw"
  width={800}
  alt=""
/>
OptionValues
width, heightinteger pixels, clamped to 1–4096
fit"scale-down", "contain", "cover", "crop", "pad"
quality1–100
format"auto" (AVIF/WebP when the browser accepts it), "webp", "avif", "jpeg", "png"

Unknown options and values are dropped. URLs have the form <image_transform_base>/cdn-cgi/image/width=800,format=auto,onerror=redirect/<source> and are served by Cloudflare Image Transformations; Crafty itself never resizes images.

The API returns image_transform_base only for public raster images, and only when the Crafty server has image transformations enabled. Otherwise (private media with signed URLs, SVGs, PDFs, transformations off) image() returns the original url unchanged and srcset() returns "", so the browser falls back to src. It is always safe to call.

llms.txt

Crafty generates llms.txt and llms-full.txt from your published content. Title, summary, language, and which collections without a route are opted in come from the "llms.txt" settings screen in the admin, or the configure_llms MCP tool; URLs are resolved from the visual editor routes. A collection without a route pattern is excluded by default — opt it in to have its entries appear under "## Optional" in the index and in llms-full.txt.

Sites hosted by Crafty get both files injected at the site root on their next deploy; a deploy already made before you started using this feature is not retroactively updated. A file the site's own build already ships (an llms.txt / llms-full.txt already in the deploy output) always wins — Crafty only adds the files when they are missing.

Serve them from your own domain:

// Next.js — app/llms.txt/route.ts
export const GET = crafty.llms.handler();

// Next.js — app/llms-full.txt/route.ts
export const GET = crafty.llms.handler({ full: true });

// Astro — src/pages/llms.txt.ts
export const GET = crafty.llms.handler();

// Nuxt — server/routes/llms.txt.ts
export default defineEventHandler(() => crafty.llms.handler()());

For a static build, write the files into dist/ yourself at build time instead of relying on deploy-time injection:

// e.g. a postbuild script, run after `vite build` / `astro build`
import { writeFile } from "node:fs/promises";

await writeFile("dist/llms.txt", await crafty.llms.index());
await writeFile("dist/llms-full.txt", await crafty.llms.full());

Advertise it in <head> with crafty.llms.linkTags() (HTML string) or crafty.llms.linkTagAttrs() (objects for useHead / <Head>).

Analytics

Google Tag Manager, GA4, Meta Pixel and TikTok Pixel IDs are set per environment on the "Analítica" admin screen or with the configure_analytics MCP tool. Don't hard-code their snippets in the site.

  • Crafty Hosting: the deploy injects the loaders into every HTML page. It also replaces any block the SDK rendered at build time, so the deploy settings always win. Nothing else is needed.
  • SSR on any hosting: render the ready-made HTML on the server, so crawlers see it and the first page view is kept:
const analytics = await crafty.analytics.get();
// analytics.head  → before </head>
// analytics.body  → right after <body> (<noscript> fallbacks)
  • Client-rendered sites on any hosting: call load() once in the browser. It returns "already-loaded" without fetching when the page already carries Crafty analytics (window.__CRAFTY_ANALYTICS__ is set by the deploy-injected block or a previous load), so on Crafty Hosting the deploy wins:
await crafty.analytics.load(); // "loaded" | "already-loaded" | "not-configured" | "unavailable"

Client-side loading misses crawlers that don't run JavaScript. Changes made in the admin show up on the next request with get()/load(); on Crafty Hosting they need a redeploy.

Structured data (JSON-LD)

Crafty turns content into schema.org JSON-LD so Google can show rich results (hours, address, prices, FAQ) and AI search engines get reliable facts. Don't write JSON-LD by hand:

  • The business behind the site (type, address, hours, phone, social profiles) is set on the "Datos estructurados" admin screen or with the configure_structured_data MCP tool. The home page (pages.get("home")) and crafty.site.get() carry it in structured_data.
  • A collection can be mapped to Product, Article, BlogPosting, FAQPage, Event or Service (collection editor or structured_data on update_collection). Each published entry then carries its JSON-LD in structured_data.

crafty.structuredData(...sources) returns the <script type="application/ld+json"> tags for any mix of pages, entries, list responses, the site or JSON-LD objects ("" when none has any). FAQPage objects are merged into one, as Google expects a single FAQPage per page:

const page = await crafty.pages.get("home");
const faqs = await crafty.entries.list("faq");

head += crafty.structuredData(page, faqs); // LocalBusiness + one FAQPage with every question

For questions that live in a page block, crafty.faqPage(block.data.items) builds the FAQPage from { question, answer } items. crafty.jsonLd(...sources) returns the objects instead of HTML, for useHead / <Head> components.

On Crafty Hosting the deploy also adds the business JSON-LD to the home page (index.html) when the page does not render it already, so a site gets LocalBusiness even without the helper.

Request options (per call)

Every method accepts options? as the last argument:

  • signal: AbortSignal
  • headers: request-specific headers (merged with client headers)
  • timeoutMs: overrides client timeout for that call
const posts = await crafty.entries.list(
  "posts",
  { per_page: 15, page: 1, sort_by: "updated_at", sort_order: "desc" },
  { timeoutMs: 5_000 }
);

Responses (current shapes)

  • collections.list() => { data: CraftyCollection[] }
  • collections.get() => CraftyCollection
  • entries.list() => paginated { data, links, meta }
  • entries.get() => { data: CraftyEntry }
  • pages.list() => paginated { data, links, meta }
  • pages.get() => { data: CraftyPage } plus the block helpers listed above
  • mediaCollections.list() => { data, meta: { includes } }
  • mediaFiles.list() => { data, links, meta } (meta includes ttl_minutes)
  • forms.list() => { data: CraftyForm[] }
  • forms.get() => { data: CraftyForm }
  • forms.submit() => { id, form_id, submitted_at }

Errors

The SDK throws CraftyError for HTTP, timeout, abort, network, and configuration failures (e.g. no fetch implementation available at construction).

import { CraftyError } from "@craftware/crafty-sdk";

try {
  await crafty.pages.get("home");
} catch (error) {
  if (error instanceof CraftyError) {
    console.error(error.code, error.status, error.message);
    console.error(error.requestId); // if API sent x-request-id
  }
}

Current error codes (with their HTTP-status mapping):

  • NETWORK_ERROR — network failure, or no fetch available at construction
  • TIMEOUT — request exceeded timeoutMs
  • BAD_REQUEST — HTTP 400 or 422
  • UNAUTHORIZED — HTTP 401
  • FORBIDDEN — HTTP 403
  • NOT_FOUND — HTTP 404
  • RATE_LIMITED — HTTP 429
  • SLOT_UNAVAILABLE — HTTP 409 from a booking checkout: the slot was taken meanwhile
  • SERVER_ERROR — HTTP 5xx
  • UNKNOWN_ERROR — any other / unmapped status

End-to-end example

import { createCraftyClient } from "@craftware/crafty-sdk";

const crafty = createCraftyClient({
  baseUrl: "https://tenant.crafty.do",
  apiKey: process.env.CRAFTY_API_KEY,
  siteSlug: "my-site",
  timeoutMs: 10_000
});

const collections = await crafty.collections.list();
const posts = await crafty.entries.list("posts", { per_page: 15 });
const mediaCollections = await crafty.mediaCollections.list({ include: ["stats", "directories"] });
const mediaFiles = await crafty.mediaFiles.list({ collection: "library", per_page: 24 });
const homePage = await crafty.pages.get("home");
const forms = await crafty.forms.list();
const submission = await crafty.forms.submit("contact", { name: "Jane", email: "[email protected]" });

Pages scoping (site+environment via API key)

pages requests no longer require a siteSlug parameter. The site is inferred server-side from the X-API-Key header (set from apiKey in createCraftyClient()).

const crafty = createCraftyClient({
  baseUrl: "https://tenant.crafty.do",
  apiKey: "ENVIRONMENT_API_TOKEN",
});

await crafty.pages.list({ per_page: 12 });

const landing = await crafty.pages.get("landing");
const processBlock = landing.block("process");
const allProcessBlocks = landing.blocksOf("process");
const processFirst = landing.firstByType.process;
const processList = landing.byType.process ?? [];

Visual editor

Crafty's visual editor loads your site in an iframe and lets editors click a text or image to change it. Three pieces make a site editable:

1. Mark editable elements with crafty.attrs(target, field, { label }) (also exported as attrs). target is a block or an entry from the API (or { kind: "block" | "entry", id }); field is a field key or a JSON path inside a json field.

<h1 {...crafty.attrs(block, "title")}>{block.data.title}</h1>
<h2 {...crafty.attrs(entry, `sections[${i}].title`, { label: "Título de la sección" })}>{section.title}</h2>

Buttons and links: pass hrefField (and optionally hrefLabel) so the editor edits the destination together with the text. Clicking anywhere on a link (padding, icon, keyboard activation) selects its marked label instead of following it. Real <button>, <summary> and <label> elements keep their own behavior (menus, accordions) unless the click lands on the marked element itself:

<a href={cta.href} className="btn">
  <span {...crafty.attrs(entry, "hero.cta.label", { label: "Texto del botón", hrefField: "hero.cta.href", hrefLabel: "Enlace del botón" })}>{cta.label}</span>
  <ArrowIcon />
</a>

A link whose only editable value is its destination (a url field) is marked on the <a> itself: <a {...crafty.attrs(entry, "maps_url", { label: "Enlace del mapa" })} href={entry.maps_url}>Ver mapa</a>. Link destinations are never written live into the page; they show after saving the draft.

Pass hrefField only when the content stores a destination: if the site falls back to a hardcoded href when it is missing, leave hrefField out (the editor cannot create a missing key inside a json field). hrefField is read from the same target as the label; when the label and the URL live in different blocks or entries, mark them separately. Hardcoded link text is never editable: mark the <a> with the url field only, or move the text into Crafty first.

String templates use attrsHtml(target, field, options), which returns escaped data-crafty-*="…" pairs. Attributes are always emitted, so static builds keep them.

2. Carry the preview token. In the browser nothing is needed. With server rendering, read it per request:

import { createCraftyClient, previewFromRequest } from "@craftware/crafty-sdk";

const crafty = createCraftyClient({ baseUrl, apiKey, preview: previewFromRequest(req) });

previewFromRequest accepts a URL string, URL, a Web Request, a Node request (req.url) or an Express request (req.query).

3. Enable the bridge once, in the browser:

import { enableVisualEditing } from "@craftware/crafty-sdk/visual-editing";

enableVisualEditing({ onRefresh: () => reloadContent() });

It does nothing unless the page is inside the Crafty admin iframe with a preview token, so it is safe in production. Even then it stays inert until the backend confirms the token (a response with X-Crafty-Preview-Status: active) — normally the site's own first Crafty request during that page load. Sites where no browser-side request carries the token (pure SSR, where content is only ever fetched on the server) must pass client so the bridge can confirm it itself with one lightweight request (pages.list({ per_page: 1 })):

enableVisualEditing({ client: crafty });

If the API answers invalid (expired or foreign token), the bridge stays inert and tells the admin instead of activating. Options: adminOrigins (defaults to https://*.crafty.do), onRefresh (re-fetch instead of a full reload), page / entries (what a server-rendered page shows), touchHighlightMs, client (verify the token instead of waiting for the site's own request).

While the bridge is active, an Escape pressed inside the site is forwarded to the admin (SDK 0.4.1+), so the editor's bottom sheet and dialogs close even when focus is in the iframe. An Escape the site handles itself (closing its own menu or modal) must call event.preventDefault(); the bridge then leaves it alone.

Custom fetch layers. If a site fetches Crafty with something other than this SDK's client (so neither the site's own request nor client ever runs through HttpClient), report the X-Crafty-Preview-Status response header yourself, from @craftware/crafty-sdk/visual-editing:

import { getPreviewStatus, notifyPreviewActive, notifyPreviewInvalid } from "@craftware/crafty-sdk/visual-editing";

const status = response.headers.get("X-Crafty-Preview-Status");
if (status === "active") notifyPreviewActive();
if (status === "invalid") notifyPreviewInvalid();

getPreviewStatus() reads back the last status reported this way (or by the SDK's own client); PREVIEW_ACTIVE_EVENT / PREVIEW_INVALID_EVENT are the event names a pending bridge waits for.

Without a bundler:

<script src="https://cdn.jsdelivr.net/npm/@craftware/[email protected]/dist/crafty-visual-editing.global.js"></script>
<script>CraftyVisualEditing.enableVisualEditing();</script>

For production, pin an exact version (@0.4.1) and add integrity="sha384-…" crossorigin="anonymous" with the hash jsDelivr shows for that file.

Sites not hosted on Crafty Hosting must allow the admin to frame them: Content-Security-Policy: frame-ancestors 'self' https://<tenant>.crafty.do.

Notes

  • The SDK normalizes baseUrl and avoids duplicating /api/v1.
  • Accept: application/json is set automatically unless overridden.
  • src/types/generated.ts is still a placeholder until OpenAPI codegen is wired.
  • Public blockTypes endpoints are not exposed yet; block-related types are currently hand-modeled.

版本与发布记录

@craftware/crafty-sdk 共发布 20 个版本,首次发布于 2026/02/24;过去 12 个月发布了 20 个版本。

版本发布日期距今
0.12.0latest2026/11/012 天前
0.11.02026/11/012 天前
0.10.42026/11/012 天前
0.10.22026/10/312 天前
0.10.12026/10/312 天前
0.10.02026/10/313 天前

关于 @craftware/crafty-sdk 的常见问题

@craftware/crafty-sdk 是什么?

官方JavaScript SDK用于Crafty CMS公共API

如何安装 @craftware/crafty-sdk?

在项目目录运行 npm install @craftware/crafty-sdk,或使用 pnpm add / yarn add / bun add。

@craftware/crafty-sdk 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@craftware/crafty-sdk 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@craftware/crafty-sdk 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=20。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@craftware/crafty-sdk 可以商用吗?

@craftware/crafty-sdk 使用 未声明 许可。package.json 没有声明许可证。没有许可证的代码默认保留全部权利,商用前请联系作者或查看仓库中的 LICENSE 文件。以上为概括,不构成法律意见,请以许可原文为准。

@craftware/crafty-sdk 还在维护吗?

最新版本 0.12.0 发布于 2026/11/01(2 天前);至今共发布 20 个版本,其中过去 12 个月发布了 20 个;首次发布于 2026/02/24。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。