预览模式

本页内容

EmDash 的预览系统允许编辑者通过安全的、有时间限制的 URL 查看未发布的内容。预览链接使用 HMAC-SHA256 签名的令牌,您可以与审阅者共享这些链接,而无需公开整个草稿内容。

工作原理

  1. 管理员为草稿文章生成预览 URL
  2. URL 包含一个带有过期时间的签名 _preview 查询参数
  3. EmDash 的中间件自动验证令牌并设置请求上下文
  4. 您的模板代码正常调用 getEmDashEntry() — 草稿内容会自动提供

预览是隐式的。中间件验证令牌,查询函数通过 AsyncLocalStorage 读取它,因此相同的模板代码在预览期间提供草稿内容,在其他情况下提供已发布的内容。

设置预览

预览在安装 EmDash 后即可工作。首次使用时,EmDash 会生成每个站点的预览密钥并将其存储在数据库中,因此常见情况不需要配置。

仅在需要时才在环境中设置 EMDASH_PREVIEW_SECRET

  • 在多个进程之间共享密钥(例如,一个单独的预览 Worker,它对 URL 进行签名并将其发送到主站点进行验证)
  • 出于合规性/审计原因,将密钥固定为您控制的值
  • 从备份恢复时迁移到已知值
# 可选:覆盖自动生成的密钥
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>

当通过有效的预览令牌提供草稿内容时,isPreview 标志为 true

生成预览 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",
});
// 返回:/posts/my-draft-post?_preview=eyJjaWQ...

EMDASH_PREVIEW_SECRET 未设置时,EmDash 会自动生成并在数据库中存储每个站点的密钥以进行令牌验证。getPreviewUrl() 模板助手仍然要求您显式传递密钥 — 如果从页面模板调用它,请固定您的环境变量。大多数站点使用管理界面的”生成预览链接”按钮,该按钮通过 API 进行,并自动使用已解析的密钥。

传递 baseUrl 以生成绝对 URL:

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

传递 pathPattern 以使用自定义路径生成 URL:

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

区域感知路径

pathPattern 还支持 {locale} 占位符。当条目位于默认区域设置且 prefixDefaultLocalefalse 时,传递空的 locale;空值留下的相邻斜杠会自动折叠。

以下示例构建带有区域设置前缀的预览 URL:

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

await getPreviewUrl({
	collection: "posts",
	id: "hello",
	secret,
	pathPattern: "/{locale}/{id}",
	locale: "", // 默认区域设置,无前缀
});
// 返回:/hello?_preview=...

管理员的”在站点上查看”链接通过 POST /_emdash/api/content/{collection}/{id}/preview-url,它读取条目的区域设置,查找站点的 i18n 配置并自动提供 locale。要更改该端点使用的默认模式,请设置 EMDASH_PREVIEW_PATH_PATTERN(例如 /{locale}/{id})— 当请求主体包含自己的 pathPattern 时,请求主体仍然优先。

令牌过期

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

// 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" }

令牌安全性

预览令牌已签名且有时间限制。CLI 和辅助函数为您生成和验证它们;您不需要手动构造或解析它们。令牌标识一个条目,并在过期后停止工作。

完整示例

以下页面在完整的博客文章模板中结合了预览和可视化编辑支持:

---
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(字符串)
  • id — 内容 ID 或 slug(字符串)
  • secret — 签名密钥(字符串)
  • expiresIn — 令牌有效期(默认:"1h"
  • baseUrl — 绝对链接的可选基础 URL
  • pathPattern — 带有 {collection}{id}{locale} 占位符的 URL 模式(默认:"/{collection}/{id}"
  • locale — 替换 {locale} 的值。空字符串省略区域设置段(斜杠被折叠)。

返回: Promise<string>

verifyPreviewToken(options)

验证预览令牌。

选项:

  • secret — 验证密钥(字符串)
  • 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>