预览模式

本页内容

EmDash 的预览系统让编辑者可以通过安全、限时的 URL 查看未发布内容。预览链接使用 HMAC-SHA256 签名的令牌,你可以与审阅者分享,而无需暴露整篇草稿。

工作原理

  1. 管理后台为草稿文章生成预览 URL
  2. URL 包含带过期时间的已签名 _preview 查询参数
  3. EmDash 中间件自动验证令牌并设置请求上下文
  4. 你的模板照常调用 getEmDashEntry() — 草稿会自动返回

预览是隐式的。模板无需处理令牌或传入预览选项 — 中间件与查询函数通过 AsyncLocalStorage 完成一切。

配置预览

在环境中添加预览密钥:

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

请生成安全的随机字符串。该密钥用于签名和校验预览令牌。

仅此而已。现有模板会自动支持预览:

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

const { slug } = Astro.params;

// 无需特殊预览逻辑 — 中间件会
// 识别 _preview 令牌并自动返回草稿
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">
    你正在查看预览。此内容尚未发布。
  </div>
)}

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

当通过有效预览令牌提供草稿时,isPreviewtrue

生成预览 URL

使用 getPreviewUrl() 创建预览链接:

import { getPreviewUrl } from "emdash";

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

需要绝对链接时指定基础 URL:

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

自定义路径模式:

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

令牌过期

控制预览链接的有效时长:

// 有效 1 小时(默认)
await getPreviewUrl({ ..., expiresIn: "1h" });

// 有效 30 分钟
await getPreviewUrl({ ..., expiresIn: "30m" });

// 有效 1 天
await getPreviewUrl({ ..., expiresIn: "1d" });

// 有效 2 周
await getPreviewUrl({ ..., expiresIn: "2w" });

// 有效 3600 秒
await getPreviewUrl({ ..., expiresIn: 3600 });

支持的单位:s(秒)、m(分)、h(时)、d(天)、w(周)。

验证令牌

使用 verifyPreviewToken() 校验预览请求:

import { verifyPreviewToken } from "emdash";

// 从 URL(提取 _preview 查询参数)
const result = await verifyPreviewToken({
	url: Astro.url,
	secret: import.meta.env.EMDASH_PREVIEW_SECRET,
});

// 或直接传入令牌字符串
const result = await verifyPreviewToken({
	token: someTokenString,
	secret: import.meta.env.EMDASH_PREVIEW_SECRET,
});

根据结果判断令牌是否有效:

if (result.valid) {
	// 令牌有效
	console.log(result.payload.cid); // "posts:my-draft-post"
	console.log(result.payload.exp); // 过期时间戳
	console.log(result.payload.iat); // 签发时间戳
} else {
	// 令牌无效
	console.log(result.error);
	// "none" — 无令牌
	// "malformed" — 结构无效
	// "invalid" — 签名校验失败
	// "expired" — 已过期
}

预览提示

你可以在预览时显示视觉提示。getEmDashEntry 返回的 isPreview 表示正在提供草稿内容:

{isPreview && (
  <div class="preview-banner" role="alert">
    <strong>预览</strong> — 你正在查看未发布内容。
    <a href={Astro.url.pathname}>退出预览</a>
  </div>
)}

辅助函数

isPreviewRequest(url)

检查 URL 是否包含预览令牌:

import { isPreviewRequest } from "emdash";

if (isPreviewRequest(Astro.url)) {
	// 处理预览请求
}

getPreviewToken(url)

从 URL 提取令牌字符串:

import { getPreviewToken } from "emdash";

const token = getPreviewToken(Astro.url);
// 返回令牌字符串或 null

parseContentId(contentId)

将内容 ID 解析为集合与 ID:

import { parseContentId } from "emdash";

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

令牌格式

预览令牌采用紧凑格式:base64url(payload).base64url(signature)

载荷包含:

  • cid — 格式为 collection:id 的内容 ID
  • exp — 过期时间戳(自纪元起的秒数)
  • iat — 签发时间戳(自纪元起的秒数)

令牌使用预览密钥通过 HMAC-SHA256 签名。

完整示例

支持预览与可视化编辑的完整博客文章页:

---
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>预览</strong> — 此内容尚未发布。
    </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">草稿</span>
      )}
    </header>

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

{...entry.edit}{...entry.edit.title} 会添加 data-emdash-ref 属性,供已登录编辑者进行可视化编辑。生产环境中不会产生输出。

API 参考

getPreviewUrl(options)

生成带签名令牌的预览 URL。

选项:

  • collection — 集合 slug(string)
  • id — 内容 ID 或 slug(string)
  • secret — 签名密钥(string)
  • expiresIn — 令牌有效期(默认 "1h"
  • baseUrl — 可选,用于绝对链接的基础 URL
  • pathPattern — 含 {collection}{id} 占位符的 URL 模式(默认 "/{collection}/{id}"

返回: Promise<string>

verifyPreviewToken(options)

验证预览令牌。

选项:

  • secret — 校验密钥(string)
  • url — 用于提取令牌的 URL,或
  • token — 令牌字符串本身

返回: Promise<VerifyPreviewTokenResult>

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

generatePreviewToken(options)

仅生成令牌,不构建 URL。

选项:

  • contentId — 格式为 collection:id 的内容 ID
  • expiresIn — 有效期(默认 "1h"
  • secret — 签名密钥

返回: Promise<string>