预览模式

本页内容

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;

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} 占位符。当条目在默认区域设置且 prefixDefaultLocalefalse 时,传递空的 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 — 用于绝对链接的可选基础 URL
  • pathPattern — 带有 {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)

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

返回: Promise<string>