Modo de Vista Previa

En esta página

El sistema de vista previa de EmDash permite a los editores ver contenido no publicado mediante URL seguras y con tiempo limitado. Los enlaces de vista previa usan tokens firmados con HMAC-SHA256 que puedes compartir con los revisores sin exponer todo tu contenido en borrador.

Cómo funcionan las solicitudes de vista previa

  1. El administrador genera una URL de vista previa para una publicación en borrador
  2. La URL contiene un parámetro de consulta _preview firmado con una hora de caducidad
  3. El middleware de EmDash verifica el token automáticamente y configura el contexto de la solicitud
  4. El código de tu plantilla llama a getEmDashEntry() como de costumbre: el contenido en borrador se sirve automáticamente

El middleware verifica el token antes de renderizar la página y registra qué entrada de contenido autoriza. getEmDashEntry() lee ese contexto de la solicitud automáticamente. Por eso la misma plantilla sirve el borrador para un enlace de vista previa válido y coincidente, y la entrada publicada para una solicitud ordinaria. Un token de otra colección o de otra entrada nunca concede acceso al borrador de la página solicitada.

Configurar la vista previa

La vista previa funciona en cuanto EmDash está instalado. En el primer uso, EmDash genera un secreto de vista previa por sitio y lo almacena en la base de datos, de modo que el caso habitual no necesita configuración.

Establece EMDASH_PREVIEW_SECRET en tu entorno solo si necesitas:

  • Compartir el secreto entre varios procesos (por ejemplo, un Worker de vista previa independiente que firma las URL y las envía a tu sitio principal para su verificación)
  • Fijar el secreto a un valor que controles por motivos de cumplimiento o auditoría
  • Migrar a un valor conocido al restaurar desde una copia de seguridad
# Optional: override the auto-generated secret
EMDASH_PREVIEW_SECRET="your-random-secret-key-here"

Si se establece, el valor del entorno prevalece sobre el valor almacenado en la base de datos.

Las plantillas existentes funcionan con la vista previa automáticamente, como en la siguiente página:

---
import { getEmDashEntry } from "emdash";

const { slug } = Astro.params;

const { entry, isPreview, error } = await getEmDashEntry("posts", slug);

if (error) {
  return new Response("Server error", { status: 500 });
}

if (!entry) {
  return Astro.rewrite("/404");
}
---

{isPreview && (
  <div class="preview-banner">
    You are viewing the preview version of this page.
  </div>
)}

<article>
  <h1>{entry.data.title}</h1>
</article>

El indicador isPreview es true cuando un token de vista previa válido coincide con la entrada devuelta por la consulta. Los tokens no válidos, caducados o que no coinciden no exponen el contenido en borrador.

Generar URL de vista previa

La mayoría de los editores usan View on site en el administrador. Esa acción llama al endpoint de URL de vista previa, que resuelve el secreto del sitio sin exponerlo al navegador.

Usa getPreviewUrl() cuando el código de aplicación del servidor deba crear un enlace. El helper requiere el secreto de firma de forma explícita. Lee los secretos de process.env y falla antes de generar un enlace si falta la variable:

import { getPreviewUrl } from "emdash";

const secret = process.env.EMDASH_PREVIEW_SECRET;
if (!secret) throw new Error("EMDASH_PREVIEW_SECRET is required");

const previewUrl = await getPreviewUrl({
	collection: "posts",
	id: "my-draft-post",
	secret,
	expiresIn: "1h",
});
// Returns: /posts/my-draft-post?_preview=eyJjaWQ...

Cuando EMDASH_PREVIEW_SECRET no está establecido, el endpoint de EmDash genera y almacena en la base de datos un secreto por sitio. El helper independiente no puede leer ese valor almacenado, así que configura la variable de entorno si el código de la aplicación llama al helper.

El valor id identifica el contenido autorizado y también sustituye {id} en la ruta de la URL. Puede ser un ID de base de datos o un slug. Usa el mismo identificador que la página de destino pasa a getEmDashEntry().

Pasa baseUrl para generar una URL absoluta:

const fullUrl = await getPreviewUrl({
	collection: "posts",
	id: "my-draft-post",
	secret,
	baseUrl: "https://example.com",
});
// Returns: https://example.com/posts/my-draft-post?_preview=eyJjaWQ...

Pasa pathPattern para generar una URL con una ruta personalizada:

const blogUrl = await getPreviewUrl({
	collection: "posts",
	id: "my-draft-post",
	secret,
	pathPattern: "/blog/{id}",
});
// Returns: /blog/my-draft-post?_preview=eyJjaWQ...

Rutas con reconocimiento de configuración regional

pathPattern también admite un marcador {locale}. Pasa un locale vacío cuando la entrada está en la configuración regional predeterminada y prefixDefaultLocale es false; las barras adyacentes que deja el valor vacío se colapsan automáticamente.

El siguiente ejemplo construye una URL de vista previa con prefijo de configuración regional:

await getPreviewUrl({
	collection: "posts",
	id: "hello",
	secret,
	pathPattern: "/{locale}/{id}",
	locale: "pt-br",
});
// Returns: /pt-br/hello?_preview=...

await getPreviewUrl({
	collection: "posts",
	id: "hello",
	secret,
	pathPattern: "/{locale}/{id}",
	locale: "", // default locale, no prefix
});
// Returns: /hello?_preview=...

El enlace View on site del administrador pasa por POST /_emdash/api/content/{collection}/{id}/preview-url. El endpoint usa el ID de base de datos de la entrada y proporciona automáticamente su configuración regional. Establece EMDASH_PREVIEW_PATH_PATTERN cuando tu ruta pública difiera del patrón predeterminado /{collection}/{id}. Por ejemplo, /{locale}/posts/{id} produce /pt-br/posts/01ABC... para una entrada en portugués y /posts/01ABC... para una entrada sin prefijo en la configuración regional predeterminada. La ruta de destino puede pasar ese ID de base de datos a getEmDashEntry(). Un pathPattern en el cuerpo de la solicitud sustituye el valor del entorno.

El endpoint no puede sustituir el slug de la entrada por separado de su ID de base de datos. Si una URL de vista previa debe usar el slug, genérala en el código de aplicación del servidor con getPreviewUrl() y pasa el slug como id.

El marcador {locale} recibe el código de configuración regional configurado. No aplica las asignaciones personalizadas de path de configuración regional de Astro. Si la ruta pública usa un segmento distinto, llama al helper con ese segmento o proporciona una ruta que acepte el código de configuración regional que usa el endpoint del administrador.

Establecer la caducidad del token

Controla durante cuánto tiempo siguen siendo válidos los enlaces de vista previa:

const preview = {
	collection: "posts",
	id: "my-draft-post",
	secret,
};

// Valid for 1 hour (default)
await getPreviewUrl(preview);

// Valid for 30 minutes
await getPreviewUrl({ ...preview, expiresIn: "30m" });

// Valid for 1 day
await getPreviewUrl({ ...preview, expiresIn: "1d" });

// Valid for 2 weeks
await getPreviewUrl({ ...preview, expiresIn: "2w" });

// Valid for 3600 seconds
await getPreviewUrl({ ...preview, expiresIn: 3600 });

Unidades admitidas: s (segundos), m (minutos), h (horas), d (días), w (semanas).

Verificar tokens en un flujo personalizado

Las páginas normales de Astro no llaman a verifyPreviewToken(); el middleware de EmDash ya verifica _preview. Usa este helper solo cuando el código ajeno a ese middleware deba validar un token:

import { verifyPreviewToken } from "emdash";

// From a URL (extracts _preview query parameter)
const fromUrl = await verifyPreviewToken({
	url: Astro.url,
	secret,
});

// Or with a token directly
const fromToken = await verifyPreviewToken({
	token: Astro.url.searchParams.get("_preview"),
	secret,
});

El resultado indica si el token es válido:

if (fromUrl.valid) {
	// Token is valid
	console.log(fromUrl.payload.cid); // "posts:my-draft-post"
	console.log(fromUrl.payload.exp); // Expiry timestamp
	console.log(fromUrl.payload.iat); // Issued-at timestamp
} else {
	// Token is invalid
	console.log(fromUrl.error);
	// "none" - no token present
	// "malformed" - token structure is invalid
	// "invalid" - signature verification failed
	// "expired" - token has expired
}

Mostrar un indicador de vista previa

Usa el indicador isPreview que devuelve getEmDashEntry para mostrar que la página proviene de una solicitud de vista previa:

{isPreview && (
  <div class="preview-banner" role="alert">
    <strong>Preview</strong> — You are viewing the preview version of this page.
    <a href={Astro.url.pathname}>Exit preview</a>
  </div>
)}

Inspeccionar URL de vista previa

isPreviewRequest(url)

Comprueba si una URL contiene un parámetro _preview. Esto no verifica el token:

import { isPreviewRequest } from "emdash";

if (isPreviewRequest(Astro.url)) {
	// Handle preview request
}

getPreviewToken(url)

Extrae la cadena del token de una URL:

import { getPreviewToken } from "emdash";

const token = getPreviewToken(Astro.url);
// Returns the token string or null

parseContentId(contentId)

Analiza un ID de contenido en colección e ID:

import { parseContentId } from "emdash";

const { collection, id } = parseContentId("posts:my-draft-post");
// { collection: "posts", id: "my-draft-post" }

Seguridad de los tokens

Los tokens de vista previa están firmados y tienen un tiempo limitado. El endpoint del administrador y las funciones helper los generan y verifican por ti; no los construyes ni los analizas a mano. Un token identifica una entrada y deja de funcionar cuando caduca.

Ejemplo completo

La siguiente página combina la vista previa y la compatibilidad con la edición visual en una plantilla completa de publicación de blog:

---
import { getEmDashEntry } from "emdash";
import BaseLayout from "../../layouts/Base.astro";
import { PortableText } from "emdash/ui";

const { slug } = Astro.params;

const { entry, isPreview, error } = await getEmDashEntry("posts", slug);

if (error) {
  return new Response("Server error", { status: 500 });
}

if (!entry) {
  return Astro.rewrite("/404");
}
---

<BaseLayout title={entry.data.title}>
  {isPreview && (
    <div class="preview-banner" role="alert">
      <strong>Preview</strong> — You are viewing the preview version of this page.
    </div>
  )}

  <article {...entry.edit}>
    <header>
      <h1 {...entry.edit.title}>{entry.data.title}</h1>
      {entry.data.publishedAt && (
        <time datetime={entry.data.publishedAt.toISOString()}>
          {entry.data.publishedAt.toLocaleDateString()}
        </time>
      )}
      {isPreview && entry.data.status === "draft" && (
        <span class="draft-indicator">Draft</span>
      )}
    </header>

    <div class="content" {...entry.edit.content}>
      <PortableText value={entry.data.content} />
    </div>
  </article>
</BaseLayout>

Fíjate en los spreads {...entry.edit} y {...entry.edit.title}: añaden atributos data-emdash-ref que habilitan la edición visual para los editores autenticados. En producción no generan ninguna salida.

Referencia de la API

getPreviewUrl(options)

Genera una URL de vista previa con un token firmado.

Opciones:

  • collection — Slug de la colección (string)
  • id — ID del contenido o slug (string)
  • secret — Secreto de firma (string)
  • expiresIn — Duración de validez del token (predeterminado: "1h")
  • baseUrl — URL base opcional para enlaces absolutos
  • pathPattern — Patrón de URL con los marcadores {collection}, {id} y {locale} (predeterminado: "/{collection}/{id}")
  • locale — Valor que sustituye a {locale}. Una cadena vacía omite el segmento de configuración regional (las barras se colapsan).

Devuelve: Promise<string>

verifyPreviewToken(options)

Verifica un token de vista previa.

Opciones:

  • secret — Secreto de verificación (string)
  • url — URL de la que extraer el token, O
  • token — La cadena del token directamente

Devuelve: Promise<VerifyPreviewTokenResult>

type VerifyPreviewTokenResult =
	| { valid: true; payload: PreviewTokenPayload }
	| { valid: false; error: "invalid" | "expired" | "malformed" | "none" };

generatePreviewToken(options)

Genera un token sin construir una URL.

Opciones:

  • contentId — ID de contenido con el formato collection:id
  • expiresIn — Duración de validez del token (predeterminado: "1h")
  • secret — Secreto de firma

Devuelve: Promise<string>