Preparar un sitio para el editor visual de Crafty

El editor visual permite que un usuario no técnico abra su sitio real dentro del panel de administración de Crafty, haga clic (o toque) en un texto o una imagen, lo edite en un panel lateral, vea el cambio en vivo y publique. Solo funciona cuando el frontend está preparado con @craftware/crafty-sdk 0.3.0 o posterior; usa 0.4.1 o posterior para que los botones y enlaces se seleccionen en lugar de navegar, hrefField esté disponible y Escape dentro del sitio cierre los paneles del administrador. Esta guía es la lista de comprobación completa.

La pregunta obligatoria

Cada vez que integres Crafty en un frontend (un sitio nuevo, páginas o secciones nuevas, o al mover contenido fijo a Crafty), haz al usuario esta pregunta exacta una vez, antes de terminar:

¿Quieres dejar el sitio preparado para el editor visual de Crafty?

  • Sí → ejecuta todo el "flujo Sí" de abajo.
  • No → ejecuta el "flujo No".
  • Si el usuario pide después "habilita el editor visual de Crafty" (o algo equivalente) para un sitio existente, no vuelvas a preguntar: ejecuta el flujo Sí.

Flujo No

No añadas nada: nada de attrs(), enableVisualEditing(), gestión del token de vista previa, cabeceras ni llamada a configure_visual_editor. Termina tu mensaje final con esta frase para el usuario:

El editor visual de Crafty no quedó activado. Cuando quieras activarlo, pide: «habilita el editor visual de Crafty».

Flujo Sí

Haz cada paso. Mantén los cambios mínimos y en el estilo existente del sitio.

1. Decide el modo de renderizado

render_mode Cómo reconocerlo Estrategia del token (paso 4)
ssr El contenido se obtiene en el servidor en cada petición: rutas dinámicas de Next.js / Server Components sin caché estática, Nuxt SSR, Astro output: "server", loaders de Remix, plantillas de Express/Fastify. Léelo por petición con previewFromRequest().
hybrid El HTML se prerenderiza en la compilación y el navegador vuelve a pedir el contenido a Crafty al cargar (el patrón Vite + prerender + refresco en cliente). Automático en el navegador.
static El contenido se incorpora solo en la compilación; el navegador nunca llama a la API de Crafty. Añade una nueva petición desde el navegador que solo se ejecute en vista previa.
unknown No puedes saberlo. Trátalo como hybrid e infórmalo al usuario.

2. Actualiza el SDK

npm install @craftware/crafty-sdk@^0.4.1

Justo después de una publicación del SDK, npm puede seguir con metadatos de registro obsoletos y fallar con ETARGET (no hay versión coincidente). Reintenta con npm install @craftware/crafty-sdk@^0.4.1 --prefer-online, que vuelve a consultar el registro en lugar de confiar en la caché.

El paquete incluye esta skill. Si el proyecto mantiene su propia copia (por ejemplo .claude/skills/crafty-cms), actualízala después de cada actualización del SDK para que coincida con la versión instalada:

rm -rf .claude/skills/crafty-cms
cp -R node_modules/@craftware/crafty-sdk/skills/crafty-cms .claude/skills/crafty-cms

3. Marca cada elemento editable

Usa attrs(target, field, { label }) (también disponible como crafty.attrs en el cliente). target es el bloque o la entrada de la que procede el valor; field es la clave del campo o la ruta JSON del valor.

Reglas:

  • Marca solo el contenido que viene de Crafty. Nunca marques texto fijo.
  • Pon los atributos en el elemento más interno cuyo texto completo es el valor del campo (<h1>, <p>, <span>, la etiqueta del <a> o <button>, <li>). La vista previa en vivo reemplaza el texto de ese elemento, así que un elemento que también contenga iconos u otros campos no debe marcarse; envuelve el valor en un <span> y marca el span.
  • Imágenes de un campo reference de medios: marca el <img> con la clave del campo (image, photo).
  • Botones y enlaces (CTA): marca la etiqueta y pasa el campo de destino como hrefField, para que el editor muestre juntos el texto y una entrada «Enlace», y guarde cada uno como su propio borrador. Un clic en cualquier parte de un enlace selecciona su etiqueta marcada (el editor nunca sigue enlaces); en un <button>, <summary> o <label> reales, solo un clic en el elemento marcado selecciona, así los menús y acordeones siguen funcionando. Un enlace cuyo único valor editable es su destino (un campo url) se marca en el propio <a>; los destinos se actualizan después de guardar el borrador, no en vivo mientras escribes.
<Link to={cta.href} className="btn">
  <span {...attrs(entry, `sections[${i}].cta.label`, { label: "Texto del botón", hrefField: `sections[${i}].cta.href`, hrefLabel: "Enlace del botón" })}>{cta.label}</span>
  <Arrow />
</Link>
  • Pasa hrefField solo cuando el contenido realmente almacena un destino. Si el sitio muestra un valor de respaldo cuando falta el dato (cta.href || "/#contact"), no hay nada en Crafty que editar: el editor no puede crear una clave que falte dentro de un campo json, así que oculta la entrada «Enlace» para ese elemento. Un pequeño helper mantiene esto coherente:
// Añade el campo de enlace solo cuando el elemento almacenado tiene un destino.
export function linkTo(href, field, label = "Enlace") {
  return typeof href === "string" && href.trim() !== "" ? { hrefField: field, hrefLabel: label } : {};
}

<span {...attrs(entry, `sections[${i}].cta.label`, { label: "Texto del botón", ...linkTo(cta.href, `sections[${i}].cta.href`, "Enlace del botón") })}>{cta.label}</span>
  • hrefField se resuelve en el mismo bloque o entrada que la etiqueta. Cuando la etiqueta y el destino están en objetivos distintos (la etiqueta en una sección de página, la URL en la entrada de ajustes del sitio), no pueden emparejarse: márcalos por separado, la etiqueta en su <span> con su propio objetivo y el destino en el <a> con el otro objetivo (como un enlace cuyo único valor editable es su destino).
  • El texto de enlace fijo no es editable y no debe marcarse. Para un enlace como <a href={settings.portal_url}>Portal</a>, marca el <a> solo con el campo url: el editor edita el destino, el texto «Portal» no cambia y el nuevo destino se muestra tras guardar. Si el cliente debe poder cambiar ese texto, primero pásalo a un campo de Crafty y márcalo como cualquier otra etiqueta.
  • Valores dentro de campos json: usa la ruta exacta del valor renderizado, por ejemplo sections[2].title, hero.title_segments[0].text, extra.ctaPrimaryLabel. Los índices de array son posiciones en el JSON almacenado. Cuando renderizas el array tal como está almacenado, el índice del bucle es esa posición (`sections[${i}].title`). Cuando filtras, ordenas, divides o reordenas (productos destacados frente a normales, elementos ordenados por order, solo secciones visibles), conserva el índice almacenado de cada elemento antes de transformarlo y úsalo, nunca el índice renderizado:
// Conserva la posición almacenada antes de filtrar u ordenar.
const items = section.items.map((item, index) => ({ item, index }));
const featured = items.filter(({ item }) => item.featured).sort((a, b) => a.item.order - b.item.order);

featured.map(({ item, index }) => (
  <h3 key={index} {...attrs(entry, `sections[${sectionIndex}].items[${index}].title`, { label: "Título del producto" })}>{item.title}</h3>
));

Los componentes de sección que reciben un fragmento de un campo JSON pueden usar un helper con ámbito de ruta en lugar de la ruta completa:

// editable(entry).at(`sections[${i}]`)("title", "Título") -> attrs(entry, `sections[${i}].title`, { label: "Título" })
export function editable(target, base = "") {
  const join = (path) => (base ? `${base}.${path}` : path);
  const edit = (field, label, options = {}) => attrs(target, join(field), { ...options, ...(label ? { label } : {}), ...(options.hrefField ? { hrefField: join(options.hrefField) } : {}) });
  edit.at = (path) => editable(target, join(path));
  return edit;
}
  • Las listas de tipo bloque son editables por elemento: para un repeater usa items[${i}].title (el índice almacenado, misma regla que en los arrays json) y para un stringList tags[${i}] o items[${i}].tags[${j}]. Solo los subcampos de tipo string, text, longText, url o number son editables, y el elemento ya debe existir en los datos almacenados. Marca los valores individuales, no el elemento completo.
  • Tipos editables: string, text, longText, url, number, reference de medios, y valores string/number dentro de json. No marques booleanos, fechas, selects, multirreferencias ni elementos de array que sean objetos.
  • label es un nombre corto en español que el cliente entiende («Título principal», «Texto del botón»). Sin él, el editor muestra la clave del campo.
  • Pasa los bloques y entradas tal como los devuelve la API. Si una entrada tiene a la vez campos llamados order, data y type, pasa { kind: "entry", id: entry.id } en su lugar.

React / Next.js / Astro (JSX):

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

<h1 {...attrs(block, "title", { label: "Título principal" })}>{block.data.title}</h1>
<img {...attrs(block, "image", { label: "Imagen principal" })} src={imageUrl} alt="" />

{sections.map((section, i) => (
  <section key={i}>
    <h2 {...attrs(entry, `sections[${i}].title`, { label: "Título de la sección" })}>{section.title}</h2>
    <p {...attrs(entry, `sections[${i}].body`, { label: "Texto de la sección" })}>{section.body}</p>
  </section>
))}

Vue:

<h1 v-bind="attrs(entry, 'hero.title', { label: 'Título principal' })">{{ entry.hero.title }}</h1>

Plantillas HTML como cadenas (TS vanilla):

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

html += `<h1 ${attrsHtml(entry, "extra.title", { label: "Título" })}>${escapeHtml(extra.title)}</h1>`;

4. Entrega el token de vista previa

El editor carga el sitio con ?crafty_preview=<token>. Las peticiones que lo llevan como X-Crafty-Preview reciben borradores no publicados y evitan la caché de la API.

Los enlaces de vista previa compartidos (panel "Compartir vista previa" o el create_preview_link de MCP) usan el mismo parámetro con un token cps_…. Duran de 1 a 30 días, pueden revocarse y solo desbloquean su propia página o entrada: el resto del sitio responde como publicado. La misma configuración de abajo sirve para ambos, así que nada cambia en el frontend.

  • hybrid / SPA: no hay nada que hacer. Un cliente creado en el navegador lee el token de la URL, lo guarda en sessionStorage y lo envía en cada petición. Asegúrate de que la nueva petición desde el navegador se ejecute realmente en cada carga de página.
  • ssr: crea el cliente por petición con el token, nunca compartas un cliente a nivel de módulo que haya recibido uno, y nunca almacenes en caché una respuesta de vista previa:
import { createCraftyClient, previewFromRequest } from "@craftware/crafty-sdk";

// Express / Node: previewFromRequest(req)   Web Request: previewFromRequest(request)
// Next.js App Router: previewFromRequest({ query: await searchParams })
const preview = previewFromRequest(req);
const crafty = createCraftyClient({ baseUrl: process.env.CRAFTY_URL!, apiKey: process.env.CRAFTY_API_KEY!, preview });

if (preview) {
  res.setHeader("Cache-Control", "no-store"); // Next.js: llama a noStore() / usa renderizado dinámico para esta petición
}

Las páginas renderizadas en servidor también deben indicar al editor qué muestran (paso 5, page / entries).

  • static: el HTML compilado no tiene borradores, así que vuelve a pedir el contenido en el navegador solo al previsualizar y vuelve a renderizar:
import { readPreviewToken } from "@craftware/crafty-sdk";

if (readPreviewToken()) {
  const fresh = await crafty.entries.list("site-pages");
  renderPage(fresh.data); // la misma función que usa la compilación
}

Si el sitio no puede volver a renderizar en el navegador, define igualmente render_mode como static e informa al usuario de que el editor mostrará los cambios solo después de guardar y recargar, y el sitio público solo después de un despliegue.

5. Activa el puente

Llámalo una vez en el punto de entrada del navegador (sigue observando el DOM, así que puede ejecutarse antes o después del primer renderizado):

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

enableVisualEditing({
  onRefresh: () => reloadCraftyContent(), // vuelve a pedir + renderizar; omítelo para que el editor recargue la página
});
  • No hace nada para los visitantes normales (necesita el iframe del administrador, un token y un origen de administrador permitido), así que inclúyelo en producción.
  • Aun así, permanece inactivo hasta que el backend confirme el token: una respuesta con X-Crafty-Preview-Status: active, normalmente la primera petición de Crafty del propio sitio para esa página. Los sitios ssr donde el navegador nunca llama a la API de Crafty deben pasar client (un CraftyClient, normalmente el mismo que ya se creó para ese modo de renderizado) para que el puente pueda confirmar el token con una petición ligera (pages.list({ per_page: 1 })): enableVisualEditing({ client: crafty }). Los sitios hybrid y static ya hacen una petición desde el navegador que lleva la cabecera, así que client es opcional para ellos. Si la API responde invalid, el puente se desactiva y avisa al administrador en lugar de activarse.
  • Si el sitio obtiene contenido de Crafty mediante algo distinto del cliente de este SDK (así que ninguno de los caminos anteriores se ejecuta nunca), informa tú mismo de X-Crafty-Preview-Status con notifyPreviewActive() / notifyPreviewInvalid() de @craftware/crafty-sdk/visual-editing (también exporta getPreviewStatus() y los nombres de evento PREVIEW_ACTIVE_EVENT / PREVIEW_INVALID_EVENT).
  • onRefresh: regístralo cuando el sitio pueda volver a pedir contenido a Crafty y renderizar sin recargar (SPAs, sitios hybrid). Si no, omítelo.
  • React, Vue y otros frameworks: la vista previa en vivo escribe el texto directamente en el DOM, en nodos que el framework controla. Reconciliar contra esos nodos después de un refresco puede dejar texto obsoleto o provocar un error (por ejemplo, cuando el elemento también renderiza <strong> desde un helper de énfasis). En onRefresh, vuelve a pedir el contenido y luego remonta el árbol (cambia una key en la raíz) en lugar de parchearlo:
// main.tsx (React)
let remount = () => {};
function Root() {
  const [version, setVersion] = useState(0);
  remount = () => setVersion((v) => v + 1);
  return <App key={version} />;
}
enableVisualEditing({
  onRefresh: async () => {
    await refreshCrafty(); // vuelve a pedir y actualiza la caché de contenido de abajo
    remount();
  },
});

Vue: lo mismo con <App :key="version" /> en el componente raíz.

  • Sitios hybrid: guarda en caché la última petición. Un remontaje (o una navegación en el cliente) debe renderizar el contenido obtenido por última vez (con borradores), no los datos de compilación incrustados en el HTML. Guarda la última respuesta por colección o página en una caché a nivel de módulo, léela primero cuando se monte un componente y actualízala en el refresco:
const cache = new Map<string, unknown>();
export async function loadCollection(slug: string) {
  const fresh = await crafty.entries.list(slug);
  cache.set(slug, fresh.data);
  return fresh.data;
}
export const cachedCollection = (slug: string, buildTime: unknown) => cache.get(slug) ?? buildTime;
export const refreshCrafty = () => Promise.all([...cache.keys()].map(loadCollection));
  • SSR: pasa lo que renderizó el servidor, por ejemplo enableVisualEditing({ client: crafty, page: { id: page.id, slug: page.slug }, entries: [{ id: post.id, collection: "blog" }] }).
  • Escape (SDK 0.4.1+): mientras el puente está activo, un Escape pulsado dentro del sitio se reenvía al administrador para que cierre su hoja inferior y sus diálogos. Si el sitio cierra su propio menú o modal con Escape, ese manejador debe llamar a event.preventDefault(); de lo contrario, una sola pulsación cerrará el menú del sitio y el panel del administrador.
  • adminOrigins: solo cuando el administrador del tenant no está en https://<tenant>.crafty.do (dominio de administración personalizado, o desarrollo local como http://<tenant>.crafty.test). Reemplaza la lista por defecto.
  • Sin bundler:
<script src="https://cdn.jsdelivr.net/npm/@craftware/[email protected]/dist/crafty-visual-editing.global.js"></script>
<script>CraftyVisualEditing.enableVisualEditing();</script>

Fija una versión exacta (@0.4.1) con un atributo integrity="sha384-…" crossorigin="anonymous" (jsDelivr muestra el hash) cuando el sitio use Subresource Integrity.

6. Permite que el administrador enmarque el sitio

El sitio debe aceptar que el administrador lo enmarque. Los sitios desplegados en Crafty Hosting reciben esta cabecera de Crafty; para cualquier otro host, añádela (y elimina cualquier X-Frame-Options: DENY / SAMEORIGIN):

Content-Security-Policy: frame-ancestors 'self' https://<tenant>.crafty.do
  • Netlify / Cloudflare Pages (_headers en la salida de compilación):
/*
  Content-Security-Policy: frame-ancestors 'self' https://<tenant>.crafty.do
  • Vercel (vercel.json):
{ "headers": [{ "source": "/(.*)", "headers": [{ "key": "Content-Security-Policy", "value": "frame-ancestors 'self' https://<tenant>.crafty.do" }] }] }
  • Nginx: add_header Content-Security-Policy "frame-ancestors 'self' https://<tenant>.crafty.do" always;

Si el sitio ya envía un Content-Security-Policy, añade la directiva frame-ancestors a ella en lugar de enviar una segunda cabecera.

7. Asegúrate de que las imágenes vengan de repositorios públicos

Los sitios estáticos e hybrid mantienen las URL de imágenes en su HTML; los repositorios privados generan URL que caducan. Compruébalo con list_media (cada archivo tiene is_public) o con crafty.mediaCollections.list(). Si las imágenes que muestra el sitio están en un repositorio privado, informa al usuario: «Las imágenes del repositorio X son privadas y sus enlaces caducan en sitios estáticos. Márcalo como público en Crafty → Media.» Las subidas hechas desde el editor visual van por defecto al repositorio público "Editor visual".

8. Configura Crafty

Llama a configure_visual_editor (ámbito crafty:write) con lo que has averiguado:

{
  "site_id": 3,
  "environment": "production",
  "frontend_url": "https://www.example.com",
  "routes": {
    "page_pattern": "/{slug}",
    "home_slug": "home",
    "collections": { "blog": "/blog/{slug}" },
    "fixed": ["/", "/en", "/blog"]
  },
  "render_mode": "hybrid"
}
  • frontend_url: la URL pública de ese entorno donde se ejecuta esta compilación (omítela cuando sea el dominio del sitio en producción o la URL de Crafty Hosting). Antes de definir un dominio, comprueba qué host sirve realmente ese dominio: un dominio puede apuntar a una aplicación distinta de la que has preparado (otro servidor, un sitio anterior), y entonces el editor enmarca una página sin el SDK.
curl -sI https://www.example.com/ | grep -iE "^(server|x-frame-options|content-security-policy|set-cookie|location)"
curl -sI https://<site>.pages.crafty.do/ | grep -iE "^(server|x-frame-options|content-security-policy)"

Crafty Hosting responde con content-security-policy: frame-ancestors 'self' https://<tenant>.crafty.do. x-frame-options: deny, una cookie de un framework ajeno (por ejemplo laravel_session) o un server distinto significan que otra aplicación sirve ese dominio: no lo uses como frontend_url; usa la URL de Crafty Hosting (omite frontend_url) o la URL que sirve realmente esta compilación.

  • routes.page_pattern / home_slug: solo si el sitio renderiza páginas de Crafty; los patrones empiezan por / y contienen {slug}.
  • routes.collections: colecciones cuyas entradas tienen su propia URL; los patrones contienen {slug} o {id}.
  • routes.fixed: cada otra URL que muestra contenido de Crafty (inicio en otros idiomas, páginas de listado, sitios de una sola página: basta con ["/"]).
  • render_mode: del paso 1.
  • Repite con environment: "development" (u otros) cuando tengan su propia URL.
  • Si lo llamas solo con site_id, devuelve la configuración actual sin cambiar nada.

9. Verifica

  1. Compila y comprueba los tipos del sitio.
  2. Ejecútalo en local y abre una página con ?crafty_preview=test. En DevTools:
    • document.querySelectorAll("[data-crafty-field]").length es mayor que 0 en cada tipo de página (inicio, una página, una entrada de colección).
    • Las peticiones a la API de Crafty llevan X-Crafty-Preview: test, y las respuestas indican X-Crafty-Preview-Status: invalid (esperado: test no es un token real; el contenido sigue siendo público).
    • Nada aparece resaltado: fuera del iframe del administrador el puente permanece inactivo.
    • La consola no tiene errores de hidratación (React #418/#423, Vue "Hydration mismatch"). Compara con una compilación sin tus cambios: si ya existían, dilo al usuario en lugar de ocultarlos; si son nuevos, el marcado marcado difiere entre el prerenderizado y el renderizado en cliente.
    • Las páginas prerenderizadas (hybrid/static) muestran primero el contenido publicado y luego los borradores cuando termina la nueva petición desde el navegador. Un breve parpadeo del texto publicado en vista previa es normal; si los borradores nunca aparecen, falta la nueva petición de vista previa o un remontaje lee datos de compilación (ver la caché del paso 5).
  3. Tras desplegar, pide al usuario que abra Crafty → el sitio → Editar sitio (https://<tenant>.crafty.do/sites/<site_id>/visual-editor?path=/). Si al pasar el ratón se resaltan los textos y un clic abre el campo, el editor recibió crafty:ready. Si muestra «Este sitio aún no tiene edición con clic activada», revisa la lista de resolución de problemas.
  4. Revisa también la disposición en móvil. El interruptor Móvil del editor reduce el sitio enmarcado al ancho de un teléfono, lo cual basta para probar los toques en el sitio. La disposición móvil propia del administrador (una hoja inferior en lugar del panel lateral, que se cierra con su botón × o con Escape, también desde dentro del sitio con SDK 0.4.1 o posterior) solo aparece cuando la ventana del navegador es más estrecha que 1024 px: reduce la ventana o usa el modo de dispositivo de DevTools en la pestaña del administrador.

Termina con un breve resumen para el usuario en español: qué se marcó, el modo de renderizado y qué debe hacer tras publicar (nada en ssr/hybrid; un despliegue en static).

Resolución de problemas

  • El editor muestra «Este sitio aún no tiene edición con clic activada» — el iframe está bloqueado (consola del navegador: frame-ancestors o X-Frame-Options), enableVisualEditing() no está en el bundle de esa página, el SDK es anterior a 0.3.0, el origen del administrador no está permitido (dominio de administración personalizado → adminOrigins), una redirección eliminó la query string (las redirecciones de barra final o de idioma deben conservar ?crafty_preview=), o el sitio es ssr y nunca confirmó el token porque no se pasó client a enableVisualEditing().
  • «Esta URL no mostró la página …» — las routes no coinciden con las URL del sitio; corrígelas con configure_visual_editor.
  • Los borradores guardados no aparecen en el editor — SSR: el cliente se creó una sola vez a nivel de módulo o la respuesta se guardó en caché; static: falta la nueva petición de vista previa.
  • El texto vuelve atrás o la página falla tras guardar — un framework reconcilió un DOM que la vista previa en vivo había cambiado; remonta en onRefresh (paso 5).
  • Escribir no actualiza el texto en vivo — los atributos están en un contenedor que incluye más que el valor, o el campo es texto enriquecido (longText se muestra tras guardar el borrador).
  • Hacer clic en un enlace de menú abre su texto en lugar de la página — es lo esperado: con el puente activo, las etiquetas de menú marcadas se seleccionan para editar, no se siguen. Cambia de página con el selector de rutas del editor (los enlaces internos no marcados sí navegan).
  • Hacer clic en una tarjeta editable navega a otra parte — el SDK es anterior a 0.4.0 (las versiones anteriores solo seleccionaban si el clic caía exactamente sobre el elemento marcado) o no hay nada marcado dentro del enlace; marca su etiqueta.