Desarrollo con @craftware/crafty-sdk

Usa esto cuando escribas código de aplicación que lee contenido publicado de Crafty (páginas/bloques, entradas, colecciones, medios) o envía formularios. La autoría y la configuración se hacen mediante el servidor MCP (consulta mcp-configuration.md), no con el SDK.

Instalación

npm install @craftware/crafty-sdk

Requiere Node >=20, o cualquier entorno de ejecución con fetch (navegador, Bun, Deno, entornos edge modernos).

Crear un cliente

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

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

createCraftyClient(config) devuelve un objeto simple con seis recursos (collections, entries, pages, forms, mediaCollections, mediaFiles), los auxiliares de imagen image y srcset, y los auxiliares del editor visual attrs, attrsHtml y previewFromRequest.

Opciones de configuración

Opción Obligatoria Notas
baseUrl sí URL base del tenant. Las barras finales se eliminan; /api/v1 se añade automáticamente si no está presente.
apiKey no Token de API del entorno. Se envía como X-API-Key. Resuelve el sitio y el entorno en el servidor, así que pages.* no necesita siteSlug.
siteSlug no Slug del sitio. Obligatorio para forms.submit() (la ruta pública de envío es /sites/{siteSlug}/forms/{formSlug}/submit).
fetch no Fetch personalizado. Por defecto usa globalThis.fetch. Si no hay ninguno disponible, la construcción lanza CraftyError (NETWORK_ERROR).
headers no Encabezados por defecto que se combinan con cada solicitud.
timeoutMs no Tiempo límite por defecto para cada solicitud. El límite solo se activa cuando el valor es un número > 0.
preview no Token de vista previa del editor visual que se envía como X-Crafty-Preview. Omítelo en el navegador (se lee de ?crafty_preview= y se guarda en sessionStorage); pasa previewFromRequest(req) en el servidor; false nunca envía ninguno.
onPreviewInvalid no Se llama cuando una solicitud que llevaba token recibe X-Crafty-Preview-Status: invalid (la respuesta es el contenido público).

Opciones por llamada

Cada método acepta un argumento final opcional options:

  • signal: AbortSignal: se combina con la señal de tiempo límite interna.
  • headers: HeadersInit: se combina sobre los encabezados por defecto del cliente.
  • timeoutMs: number: sustituye el tiempo límite del cliente para esta llamada.
const posts = await crafty.entries.list(
  "posts",
  { per_page: 15, page: 1, sort_by: "updated_at", sort_order: "desc" },
  { timeoutMs: 5_000 }
);

Recursos

collections

crafty.collections.list(options?)        // => { data: CraftyCollection[] }
crafty.collections.get(slug, options?)   // => CraftyCollection   (objeto SIMPLE, no envuelto)

entries

crafty.entries.list(collectionSlug, query?, options?)  // => { data: CraftyEntry[], links, meta }  (paginado)
crafty.entries.get(collectionSlug, id, options?)       // => { data: CraftyEntry }

query: q (búsqueda de texto en los campos de texto de la colección), filter (condiciones campo-valor, p. ej. { precio: { gte: 150000, lte: 300000 }, tipo: "apartamento", sector: { in: ["piantini", "naco"] } }; los operadores por tipo de campo están en la guía de modelado de contenido), sort_by, sort_order (asc/desc), per_page, page.

pages

crafty.pages.list(query?, options?)   // => { data: CraftyPage[], links, meta }  (paginado)
crafty.pages.get(pageSlug, options?)  // => página enriquecida (ver abajo)

Las páginas se delimitan en el servidor según el sitio al que resuelve el apiKey — no hay argumento siteSlug. query acepta per_page y page.

pages.get() devuelve { data: CraftyPage } más auxiliares de bloques:

Auxiliar Devuelve
page.blocks CraftyBlock[] (todos los bloques; los datos que no son arreglo se convierten en [])
page.block(type) primer bloque de type, o null
page.blocksOf(type) todos los bloques de type ([] si no hay ninguno)
page.hasBlock(type) boolean
page.requireBlock(type) primer bloque, o lanza un Error simple (Crafty page block not found: …), no un CraftyError
page.byType Record<type, CraftyBlock[]>
page.firstByType Record<type, CraftyBlock | null>
page.data el CraftyPage sin procesar
const landing = await crafty.pages.get("landing");
const hero = landing.block("hero");                 // CraftyBlock | null
const features = landing.blocksOf("feature");       // CraftyBlock[]
const cta = landing.requireBlock("cta");            // lanza Error simple si no existe

Estructura de los datos de bloque (tipos de bloque)

block.data se indexa con las claves de campo del tipo de bloque. Valores de campo por tipo:

Tipo de campo block.data[key]
string, text, url string
longText cadena HTML saneada
number entero
boolean boolean
stringList string[] (p. ej. ["AI", "Cloud"])
repeater arreglo de objetos; cada subcampo declarado está presente, null cuando está vacío (p. ej. [{ icon, title, text, tags: string[] }])
reference referencia a colección: resumen de entrada { id, slug, label } o null; referencia a medios (model: "Media"): el id del medio, más <key>_media con { id, url, file_name, mime_type, size, is_public, image_transform_base } o null
multiReference colección: arreglo de entradas; medios (model: "Media"): arreglo de ids de medios, más <key>_media con los archivos en el mismo orden (los archivos que faltan se omiten)

Las entradas exponen referencias a medios de la misma forma (entry.image es el id, entry.image_media.url es el archivo).

const solutions = home.block("solutions");            // slug del tipo de bloque "solutions"
const items = solutions?.data.items ?? [];
items.map((item) => `${item.title}: ${item.tags.join(", ")}`);

const hero = home.block("hero");
const heroUrl = crafty.image(hero?.data.image_media, { width: 1600, format: "auto" }); // referencia, model "Media"
const slides = hero?.data.gallery_media ?? [];         // multiReference, model "Media"

Imágenes: usa siempre crafty.image()

Renderiza cada imagen de Crafty con crafty.image(media, { width, format: "auto" }) (y crafty.srcset(media, widths, { format: "auto" }) para imágenes responsivas) en lugar de la url original. Los sitios que sirven el archivo original fallan en Core Web Vitals (LCP).

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}
  height={450}
  loading="lazy"
  alt={block.data.title ?? ""}
/>
  • crafty.image(media, { width?, height?, fit?, quality?, format? }) → string. media es un objeto <key>_media, un CraftyMediaFile o una cadena URL. width/height de 1 a 4096, quality de 1 a 100, fit: scale-down | contain | cover | crop | pad, format: auto | webp | avif | jpeg | png. Las opciones no válidas se descartan.
  • crafty.srcset(media, widths, options?) → "<url> 400w, <url> 800w", o "" cuando la imagen no se puede transformar.
  • Elige width según el tamaño renderizado (aprox. 2x para pantallas retina en imágenes pequeñas); usa fit: "cover" con width y height para tarjetas y miniaturas de proporción fija.
  • Las transformaciones solo se aplican a imágenes ráster públicas cuando el servidor de Crafty tiene habilitadas las Transformaciones de Imagen de Cloudflare (el medio entonces incluye image_transform_base). De lo contrario, image() devuelve la url original y srcset() devuelve "", de modo que el mismo código siempre funciona. Sube las imágenes a un repositorio público (manage_media_collection con is_public: true) cuando el sitio necesite URLs permanentes y redimensionables.
  • Usa og:image con crafty.image(media, { width: 1200, height: 630, fit: "cover", format: "jpeg" }): los rastreadores sociales no admiten la negociación AVIF/WebP.
  • No construyas a mano URLs /cdn-cgi/image/... ni añadas parámetros de consulta ?w=.

Prefiere modelar listas como campos repeater / stringList en un tipo de bloque en lugar de colecciones o campos json; consulta la skill crafty-cms.

Tipos generados (crafty types)

No adivines claves ni estructuras de campos: genéralas a partir de los modelos de contenido reales del sitio con la CLI.

npx @craftware/crafty-cli types                       # CRAFTY_API_URL + CRAFTY_API_KEY desde .env
npx @craftware/crafty-cli types --api-key=$KEY --url=https://<tenant>.crafty.do --out=src/crafty.types.ts

Lee GET /api/v1/schema (JSON Schema de cada colección y tipo de bloque del entorno de la clave de API, versionado por format_version) y escribe crafty.types.ts:

  • <Slug>Entry por colección (metadatos de la entrada + campos), <Slug>BlockData y <Slug>Block por tipo de bloque, la unión CraftyPageBlock (discriminada por type), los mapas de slugs CraftyCollectionTypes / CraftyBlockDataTypes, y CraftyEntryReference / CraftyMediaSummary.
  • select → unión de valores de opción, stringList → string[], repeater → arreglo de objetos de elemento, json → unknown; los campos opcionales son | null.
import type { CarsEntry, CraftyPageBlock } from "./crafty.types";

const cars = (await crafty.entries.list("cars")).data as unknown as CarsEntry[];
const page = await crafty.pages.get("home");
for (const block of page.blocks as unknown as CraftyPageBlock[]) {
  if (block.type === "hero") block.data.heading; // HeroBlockData
}

Los tipos dependen del entorno (genéralos con la clave del entorno al que apunta el código) y quedan desactualizados cuando cambia una colección o un tipo de bloque; vuelve a ejecutar el comando después de create_block_type, update_block_type, create_collection o update_collection.

forms

crafty.forms.list(options?)                  // => { data: CraftyForm[] }            (requiere apiKey)
crafty.forms.get(slug, options?)             // => { data: CraftyForm }              (requiere apiKey)
crafty.forms.submit(formSlug, data, options?) // => { id, form_id, submitted_at }   (público; apiKey opcional, elige el entorno)
crafty.forms.captcha(form, container)         // => CraftyFormCaptcha | null           (navegador; renderiza el widget captcha del formulario)

forms.submit() usa un endpoint público: el X-API-Key, cuando está configurado, solo selecciona el entorno (una clave de staging envía a staging, sin clave envía a producción), pero **requiere siteSlug en la configuración del cliente; llamarlo sin él lanza un CraftyErrorsíncrono con el códigoBAD_REQUEST`.

const crafty = createCraftyClient({
  baseUrl: "https://<your-tenant>.crafty.do",
  siteSlug: "main-site", // obligatorio para submit()
});

const result = await crafty.forms.submit("contact", {
  name: "Jane Doe",
  email: "[email protected]",
  message: "Hello!",
});
// => { id, form_id, submitted_at }

Los formularios protegidos por captcha devuelven captcha: { provider, site_key, token_field } (ausente en otro caso), y sus envíos se rechazan con 422 sin el token del widget. En el navegador, renderiza el widget junto al formulario y pásalo a submit():

const { data: form } = await crafty.forms.get("contact");
const captcha = await crafty.forms.captcha(form, document.querySelector("#captcha")!); // null cuando no se necesita

await crafty.forms.submit("contact", values, { captcha }); // añade el token y luego reinicia el widget

Crafty define el proveedor, así que léelo de captcha.provider en lugar de asumir uno: Turnstile se ejecuta de forma invisible y solo muestra un desafío a visitantes sospechosos, hCaptcha muestra una casilla que el visitante debe marcar, así que deja espacio para ella en el diseño. Sin el SDK, carga tú mismo el script del proveedor y envía el token en captcha.token_field.

Los formularios nuevos reciben captcha por defecto una vez que Crafty tiene claves de captcha configuradas; se activa con la casilla Proteger con captcha en la configuración del formulario en el panel, o con captcha: false en create_form. Activarlo en un formulario de un sitio que no renderiza el widget hace que cada envío falle con 422, así que actualiza primero el frontend.

Ten en cuenta la asimetría: collections.get() devuelve el objeto simple, mientras que forms.get() y entries.get() van envueltos en { data }.

mediaCollections

crafty.mediaCollections.list(query?, options?)  // => { data: CraftyMediaCollection[], meta: { includes } }

query.include acepta "stats", "directories", o un arreglo de ellos (sin duplicados y serializado separado por comas).

mediaFiles

crafty.mediaFiles.list(query?, options?)  // => { data: CraftyMediaFile[], links, meta: { ...pagination, ttl_minutes } }

query: collection, directory_id, ttl_minutes (duración de la URL firmada), per_page, page. Cada archivo tiene url, is_public, expires_at e image_transform_base; pasa el archivo a crafty.image() para renderizarlo.

llms.txt

crafty.llms obtiene y sirve los archivos llms.txt (índice) / llms-full.txt (contenido completo) que Crafty genera a partir de las páginas publicadas del sitio y de las entradas de colecciones incluidas.

crafty.llms.index(options?)                       // => string (el cuerpo de llms.txt)
crafty.llms.full(options?)                         // => string (el cuerpo de llms-full.txt)
crafty.llms.handler({ full?, maxAge? })            // => () => Promise<Response>  (un manejador de ruta)
crafty.llms.linkTags({ full? })                    // => string de etiquetas <link>, listo para insertar en <head>
crafty.llms.linkTagAttrs({ full? })                // => LlmsLinkTag[] (objetos, para useHead / <Head>)

handler(opts) devuelve una función sin argumentos que puedes asignar directamente a la exportación de ruta de un framework; llama a full() cuando opts.full es true (en otro caso, index()) y responde con content-type: text/markdown; charset=utf-8 y cache-control: public, max-age=${opts.maxAge ?? 300}.

// 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, maxAge: 3600 });

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

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

Para un build estático, llama directamente a index() / full() y escribe el resultado en dist/ durante el build en lugar de servir una ruta:

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());

linkTags() / linkTagAttrs() anuncian los archivos en <head> (rel="alternate", type="text/markdown"); pasa { full: true } para incluir también el enlace a llms-full.txt.

Contenido y URLs: el título, el resumen, el idioma y las colecciones sin ruta que están incluidas se configuran en la pantalla de ajustes de "llms.txt" del administrador, o mediante la herramienta MCP configure_llms. Las URLs se resuelven a partir de las rutas del editor visual; una colección sin patrón de ruta se excluye por defecto. Los sitios alojados en Crafty reciben ambos archivos inyectados en la raíz del sitio en su próximo despliegue (un despliegue realizado antes de que empezaras a usar esta función no se actualiza retroactivamente); si el propio build del sitio ya incluye un archivo, ese archivo tiene prioridad sobre el inyectado.

Errores: index() / full() (y, por tanto, handler()) lanzan CraftyError con el código SERVER_ERROR si el cuerpo de la respuesta no es texto.

Analítica

crafty.analytics sirve la analítica de terceros (Google Tag Manager, GA4, Meta Pixel, TikTok Pixel) configurada para el entorno de la clave de API en la pantalla de administración "Analítica" o con la herramienta MCP configure_analytics.

crafty.analytics.get(options?)   // => { gtm_container_id, ga4_measurement_id, meta_pixel_id, tiktok_pixel_id, configured, head, body }
crafty.analytics.load(options?)  // solo navegador => "loaded" | "already-loaded" | "not-configured" | "unavailable"
  • Crafty Hosting: no hagas nada. El despliegue inyecta los cargadores en cada página HTML y reemplaza cualquier bloque que el SDK haya renderizado durante el build.
  • SSR en otro lugar (preferido fuera de Crafty Hosting): renderiza head antes de </head> y body justo después de <body>. Los rastreadores ven las etiquetas y se conserva la primera visualización de página.
  • Renderizado en el cliente en otro lugar: llama a load() una vez. Espera a que el DOM esté listo y omite la carga sin hacer fetch cuando window.__CRAFTY_ANALYTICS__ ya está definido (por el bloque inyectado en el despliegue o por una carga anterior).

Nunca incluyas fragmentos de GTM / GA4 / píxeles directamente en el sitio, y nunca añadas GA4 tanto aquí como dentro de GTM (las visualizaciones de página se contarían dos veces).

Datos estructurados

La API pública sirve JSON-LD de schema.org en structured_data: en la página de inicio (pages.get(homeSlug), con los datos del negocio configurados con configure_structured_data), en crafty.site.get(), y en cada entrada publicada de una colección con un mapeo de datos estructurados (structured_data en create_collection / update_collection). Es null cuando no hay nada configurado o cuando una propiedad obligatoria está vacía.

crafty.site.get(options?)                 // => { name, slug, environment, default_locale, locales, structured_data }
crafty.structuredData(...sources)         // => etiquetas '<script type="application/ld+json">…</script>', "" si no hay ninguna
crafty.jsonLd(...sources)                 // => los objetos JSON-LD, para useHead / <Head>
crafty.faqPage([{ question, answer }])    // => JSON-LD FAQPage para un bloque de preguntas frecuentes, o null

Las fuentes pueden ser páginas, entradas, sus respuestas de API, respuestas de listas, el sitio u objetos JSON-LD; los objetos FAQPage se fusionan en uno por página. El contenido del script escapa <, así que es seguro inyectarlo como HTML sin procesar (set:html, v-html, dangerouslySetInnerHTML). Valida el resultado en https://search.google.com/test/rich-results. En Crafty Hosting, el despliegue también añade el JSON-LD del negocio a la página de inicio (index.html) cuando la página no lo renderiza ya, de modo que un sitio obtiene LocalBusiness incluso sin el auxiliar.

Tienda en línea

Cuando la tienda está activa (la pestaña "Tienda" del sitio), los productos son entradas de la colección productos: lístalos con entries.list("productos") (campos nombre, precio, disponible, variantes, imagenes, sku, impuesto). Nunca calcules totales en el frontend: el carrito solo guarda slugs, variantes y cantidades, Crafty lo pone precio, y el visitante finaliza en el checkout alojado de Crafty (entrega o recogida, efectivo, pago en la recogida o transferencia bancaria). Las llamadas a la tienda necesitan siteSlug en la configuración del cliente; la clave de API, cuando se envía, elige su entorno.

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

crafty.store.get()                                   // => { enabled, currency, taxes, delivery, pickup, payment_methods, checkout_url, … }
crafty.orders.quote(cart.items(), { fulfillment })   // => { items, unavailable, subtotal, delivery_fee, taxes, total } como cadenas decimales
crafty.checkout.start(cart.items(), { returnUrl, cancelUrl })   // crea la sesión y envía el navegador al checkout alojado
crafty.checkout.create(cart.items(), options)        // => { id, url, expires_at } sin redirigir
crafty.orders.get(number, token)                     // => el pedido, con el token de su enlace de página (?t=)

const cart = createCart();          // localStorage, clave "crafty:cart" (pasa { key } por sitio)
cart.add("geisha", { variant: "Molido", quantity: 2 });
cart.update("geisha", 3, "Molido"); cart.remove("taza"); cart.count(); cart.subscribe(render);
cart.clearOnReturn();               // en la página de retorno: vacía el carrito cuando la URL tiene ?order=, devuelve el número

quote().unavailable enumera las líneas que Crafty dejó fuera (not_found, sold_out, variant_not_found, variant_required, no_price). Una tienda cerrada responde 404 STORE_DISABLED. Después del pedido, "Volver a la tienda" lleva a returnUrl (o al ajuste de la tienda) con ?order=<number>.

Manejo de errores

El SDK lanza CraftyError para fallos de HTTP, tiempo límite, cancelación, red y configuración.

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, error.requestId);
  }
}

Códigos: NETWORK_ERROR, TIMEOUT, BAD_REQUEST (HTTP 400 y 422), UNAUTHORIZED (401), FORBIDDEN (403), NOT_FOUND (404), RATE_LIMITED (429), SERVER_ERROR (5xx), UNKNOWN_ERROR (cualquier otro estado o no asignado). error.requestId se rellena cuando la API devuelve un encabezado x-request-id.

page.requireBlock() lanza un Error simple, no un CraftyError; gestiónalo por separado.

Ejemplo de extremo a extremo

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

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

const home = await crafty.pages.get("home");
const hero = home.block("hero");

const { data: posts } = await crafty.entries.list("posts", { per_page: 15, sort_order: "desc" });
const { data: collections } = await crafty.collections.list();

const media = await crafty.mediaCollections.list({ include: ["stats", "directories"] });
const files = await crafty.mediaFiles.list({ collection: "library", per_page: 24 });

try {
  await crafty.forms.submit("contact", { name: "Jane", email: "[email protected]" });
} catch (e) {
  if (e instanceof CraftyError && e.code === "BAD_REQUEST") {
    // falta siteSlug, o la validación falló
  }
}

Auxiliares del editor visual

  • attrs(target, field, { label? }) / crafty.attrs(...) → { "data-crafty-block" | "data-crafty-entry", "data-crafty-field", "data-crafty-label"? } para aplicar al elemento que renderiza el valor; {} cuando el destino no tiene un id válido. field es una clave de campo o una ruta dentro de una lista o valor JSON (sections[3].title, items[2].title para un repeater de tipo de bloque, tags[1] para un stringList, items[0].tags[1] para un stringList dentro de un elemento repeater). El índice es la posición del elemento en los datos almacenados.
  • attrsHtml(target, field, { label? }) → los mismos atributos como cadena HTML escapada.
  • image(media, options?) / crafty.image(...) y srcset(media, widths, options?) / crafty.srcset(...) → URLs de imagen redimensionadas; consulta "Imágenes: usa siempre crafty.image()".
  • previewFromRequest(req) → token de vista previa de una solicitud entrante (cadena URL, URL, Request de la Web, req.url de Node, req.query de Express) o null.
  • readPreviewToken() → token en el navegador, o null.
  • enableVisualEditing(options) desde @craftware/crafty-sdk/visual-editing → conecta la página con el editor; no hace nada fuera del iframe del administrador. Opciones: onRefresh, adminOrigins, page, entries, touchHighlightMs.

Lista de verificación completa de integración: visual-editor.md.

Notas

  • Accept: application/json se establece automáticamente salvo que se sobrescriba.
  • baseUrl se normaliza para que /api/v1 nunca se duplique.
  • La apiKey (X-API-Key) es un token de entorno y es distinta de los tokens Bearer que se usan para el servidor MCP.