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;
const { entry, isPreview, error } = await getEmDashEntry("posts", slug);
if (error) {
return new Response("服务器错误", { 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",
});
当未设置 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",
});
传递 pathPattern 以使用自定义路径生成 URL:
const blogUrl = await getPreviewUrl({
collection: "posts",
id: "my-draft-post",
secret: import.meta.env.EMDASH_PREVIEW_SECRET,
pathPattern: "/blog/{id}",
});
区域感知路径
pathPattern 还支持 {locale} 占位符。当条目在默认区域设置且 prefixDefaultLocale 为 false 时,传递空的 locale;空值留下的相邻斜杠会自动折叠。
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})。
令牌过期
控制预览链接的有效时间:
await getPreviewUrl({ ..., expiresIn: "1h" }); // 1 小时(默认)
await getPreviewUrl({ ..., expiresIn: "30m" }); // 30 分钟
await getPreviewUrl({ ..., expiresIn: "1d" }); // 1 天
await getPreviewUrl({ ..., expiresIn: "2w" }); // 2 周
await getPreviewUrl({ ..., expiresIn: 3600 }); // 3600 秒
支持的单位:s(秒)、m(分钟)、h(小时)、d(天)、w(周)。
验证令牌
使用 verifyPreviewToken() 验证传入的预览请求:
import { verifyPreviewToken } from "emdash";
const result = await verifyPreviewToken({
url: Astro.url,
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);
// "none" | "malformed" | "invalid" | "expired"
}
预览指示器
{isPreview && (
<div class="preview-banner" role="alert">
<strong>预览</strong> — 您正在查看未发布的内容。
<a href={Astro.url.pathname}>退出预览</a>
</div>
)}
辅助函数
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");
令牌安全
预览令牌是签名的且有时间限制。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("服务器错误", { 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.status === "draft" && (
<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)
collection— 集合 slug (string)id— 内容 ID 或 slug (string)secret— 签名密钥 (string)expiresIn— 令牌有效期(默认:"1h")baseUrl— 用于绝对链接的可选基础 URLpathPattern— 带有{collection}、{id}和{locale}占位符的 URL 模式(默认:"/{collection}/{id}")locale— 替换{locale}的值。空字符串省略区域设置段。
返回: 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)
contentId—collection:id格式的内容 IDexpiresIn— 令牌有效期(默认:"1h")secret— 签名密钥
返回: Promise<string>