EmDash 的预览系统允许编辑者通过安全的、有时间限制的 URL 查看未发布的内容。预览链接使用 HMAC-SHA256 签名的令牌,您可以与审阅者共享这些链接,而无需公开整个草稿内容。
工作原理
- 管理员为草稿文章生成预览 URL
- URL 包含一个带有过期时间的签名
_preview查询参数 - EmDash 的中间件自动验证令牌并设置请求上下文
- 您的模板代码正常调用
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} 占位符。当条目位于默认区域设置且 prefixDefaultLocale 为 false 时,传递空的 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— 绝对链接的可选基础 URLpathPattern— 带有{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的内容 IDexpiresIn— 令牌有效期(默认:"1h")secret— 签名密钥
返回: Promise<string>