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.mediaes un objeto<key>_media, unCraftyMediaFileo una cadena URL.width/heightde 1 a 4096,qualityde 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
widthsegún el tamaño renderizado (aprox. 2x para pantallas retina en imágenes pequeñas); usafit: "cover"conwidthyheightpara 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 laurloriginal ysrcset()devuelve"", de modo que el mismo código siempre funciona. Sube las imágenes a un repositorio público (manage_media_collectionconis_public: true) cuando el sitio necesite URLs permanentes y redimensionables. - Usa
og:imageconcrafty.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>Entrypor colección (metadatos de la entrada + campos),<Slug>BlockDatay<Slug>Blockpor tipo de bloque, la uniónCraftyPageBlock(discriminada portype), los mapas de slugsCraftyCollectionTypes/CraftyBlockDataTypes, yCraftyEntryReference/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 queforms.get()yentries.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
headantes de</head>ybodyjusto 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 cuandowindow.__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 unErrorsimple, no unCraftyError; 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.fieldes una clave de campo o una ruta dentro de una lista o valor JSON (sections[3].title,items[2].titlepara unrepeaterde tipo de bloque,tags[1]para unstringList,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(...)ysrcset(media, widths, options?)/crafty.srcset(...)→ URLs de imagen redimensionadas; consulta "Imágenes: usa siemprecrafty.image()".previewFromRequest(req)→ token de vista previa de una solicitud entrante (cadena URL,URL,Requestde la Web,req.urlde Node,req.queryde Express) onull.readPreviewToken()→ token en el navegador, onull.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/jsonse establece automáticamente salvo que se sobrescriba.baseUrlse normaliza para que/api/v1nunca 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.