EmDash 的预览系统让编辑者可以通过安全、限时的 URL 查看未发布内容。预览链接使用 HMAC-SHA256 签名的令牌,你可以与审阅者分享,而无需暴露整篇草稿。
工作原理
- 管理后台为草稿文章生成预览 URL
- URL 包含带过期时间的已签名
_preview查询参数 - EmDash 中间件自动验证令牌并设置请求上下文
- 你的模板照常调用
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>
当通过有效预览令牌提供草稿时,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",
});
// 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的内容 IDexp— 过期时间戳(自纪元起的秒数)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— 可选,用于绝对链接的基础 URLpathPattern— 含{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的内容 IDexpiresIn— 有效期(默认"1h")secret— 签名密钥
返回: Promise<string>