Modo de vista previa

En esta página

El sistema de vista previa de EmDash permite a las personas editoras ver contenido no publicado mediante URLs seguras y de duración limitada. Los enlaces usan tokens firmados con HMAC-SHA256 que puede compartir con revisoras y revisores sin exponer todo el borrador.

Cómo funciona

  1. El panel de administración genera una URL de vista previa para un borrador
  2. La URL incluye el parámetro de consulta firmado _preview con tiempo de caducidad
  3. El middleware de EmDash verifica el token automáticamente y prepara el contexto de la petición
  4. Su plantilla llama a getEmDashEntry() con normalidad: el borrador se sirve automáticamente

La vista previa es implícita. No necesita manejar tokens ni opciones de vista previa en la plantilla: el middleware y las funciones de consulta lo gestionan con AsyncLocalStorage.

Configurar la vista previa

Añada un secreto de vista previa al entorno:

EMDASH_PREVIEW_SECRET="your-random-secret-key-here"

Genere una cadena aleatoria segura. Ese secreto firma y verifica los tokens.

Listo. Sus plantillas existentes funcionan con la vista previa automáticamente:

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

const { slug } = Astro.params;

// No hace falta lógica especial: el middleware
// detecta los tokens _preview y sirve el borrador automáticamente
const { entry, isPreview, error } = await getEmDashEntry("posts", slug);

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

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

{isPreview && (
  <div class="preview-banner">
    Está viendo una vista previa. Este contenido no está publicado.
  </div>
)}

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

El indicador isPreview es true cuando se sirve borrador con un token válido.

Generar URLs de vista previa

Use getPreviewUrl():

import { getPreviewUrl } from "emdash";

const previewUrl = await getPreviewUrl({
	collection: "posts",
	id: "my-draft-post",
	secret: import.meta.env.EMDASH_PREVIEW_SECRET,
	expiresIn: "1h",
});

Con URL base para enlaces absolutos:

const fullUrl = await getPreviewUrl({
	collection: "posts",
	id: "my-draft-post",
	secret: import.meta.env.EMDASH_PREVIEW_SECRET,
	baseUrl: "https://example.com",
});

Con patrón de ruta personalizado:

const blogUrl = await getPreviewUrl({
	collection: "posts",
	id: "my-draft-post",
	secret: import.meta.env.EMDASH_PREVIEW_SECRET,
	pathPattern: "/blog/{id}",
});

Caducidad del token

await getPreviewUrl({ ..., expiresIn: "1h" });
await getPreviewUrl({ ..., expiresIn: "30m" });
await getPreviewUrl({ ..., expiresIn: "1d" });
await getPreviewUrl({ ..., expiresIn: "2w" });
await getPreviewUrl({ ..., expiresIn: 3600 });

Unidades: s, m, h, d, w.

Verificar tokens

Use verifyPreviewToken():

import { verifyPreviewToken } from "emdash";

const result = await verifyPreviewToken({
	url: Astro.url,
	secret: import.meta.env.EMDASH_PREVIEW_SECRET,
});

const result2 = await verifyPreviewToken({
	token: someTokenString,
	secret: import.meta.env.EMDASH_PREVIEW_SECRET,
});
if (result.valid) {
	console.log(result.payload.cid);
	console.log(result.payload.exp);
	console.log(result.payload.iat);
} else {
	console.log(result.error);
}

Indicador de vista previa

{isPreview && (
  <div class="preview-banner" role="alert">
    <strong>Vista previa</strong> — Está viendo contenido no publicado.
    <a href={Astro.url.pathname}>Salir de la vista previa</a>
  </div>
)}

Funciones auxiliares

isPreviewRequest(url)

import { isPreviewRequest } from "emdash";

if (isPreviewRequest(Astro.url)) {
}

getPreviewToken(url)

import { getPreviewToken } from "emdash";

const token = getPreviewToken(Astro.url);

parseContentId(contentId)

import { parseContentId } from "emdash";

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

Formato del token

base64url(payload).base64url(signature)

  • cid — ID de contenido collection:id
  • exp — Caducidad (segundos desde epoch)
  • iat — Emisión (segundos desde epoch)

Firmado con HMAC-SHA256.

Ejemplo completo

---
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.redirect("/404");
}
---

<BaseLayout title={entry.data.title}>
  {isPreview && (
    <div class="preview-banner" role="alert">
      <strong>Vista previa</strong> — Este contenido no está publicado.
    </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.publishedAt && (
        <span class="draft-indicator">Borrador</span>
      )}
    </header>

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

Referencia de API

getPreviewUrl(options)

  • collection, id, secret, expiresIn (por defecto "1h"), baseUrl, pathPattern (por defecto "/{collection}/{id}")

Devuelve: Promise<string>

verifyPreviewToken(options)

  • secret y url o token

Devuelve: Promise<VerifyPreviewTokenResult>

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

generatePreviewToken(options)

  • contentId, expiresIn, secret

Devuelve: Promise<string>