预览模式

本页内容

EmDash 的预览系统让编辑者可以通过安全且有时限的 URL 查看未发布的内容。预览链接使用 HMAC-SHA256 签名的令牌,你可以把它分享给审阅者,而不会暴露全部草稿内容。

预览请求的工作原理

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

中间件会在渲染页面之前验证令牌,并记录它授权的是哪个内容条目。getEmDashEntry() 会自动读取该请求上下文。因此,同一个模板在遇到有效且匹配的预览链接时提供草稿,在普通请求时提供已发布的条目。指向其他集合或其他条目的令牌,永远不会授予对所请求页面的草稿访问权限。

设置预览

安装 EmDash 之后,预览即可使用。首次使用时,EmDash 会生成一个按站点区分的预览密钥并存储在数据库中,因此常见情况下无需任何配置。

只有在你需要以下情形时,才需要在环境中设置 EMDASH_PREVIEW_SECRET:

  • 在多个进程之间共享密钥(例如,一个独立的预览 Worker 负责签署 URL,并把它们发送到你的主站点进行验证)
  • 出于合规或审计的原因,把密钥固定为你可控的值
  • 从备份恢复时迁移到一个已知的值
# Optional: override the auto-generated secret
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("Server error", { status: 500 });
}

if (!entry) {
  return Astro.rewrite("/404");
}
---

{isPreview && (
  <div class="preview-banner">
    You are viewing the preview version of this page.
  </div>
)}

<article>
  <h1>{entry.data.title}</h1>
</article>

当有效的预览令牌与查询返回的条目相匹配时,isPreview 标志为 true。无效、已过期以及不匹配的令牌都不会暴露草稿内容。

生成预览 URL

大多数编辑者会使用管理后台中的 View on site。该操作会调用预览 URL 端点,由它在不向浏览器暴露站点密钥的情况下解析该密钥。

当服务端应用代码必须创建链接时,请使用 getPreviewUrl()。该辅助函数要求显式提供签名密钥。请从 process.env 读取密钥,并在变量缺失时先失败,再生成链接:

import { getPreviewUrl } from "emdash";

const secret = process.env.EMDASH_PREVIEW_SECRET;
if (!secret) throw new Error("EMDASH_PREVIEW_SECRET is required");

const previewUrl = await getPreviewUrl({
	collection: "posts",
	id: "my-draft-post",
	secret,
	expiresIn: "1h",
});
// Returns: /posts/my-draft-post?_preview=eyJjaWQ...

当 EMDASH_PREVIEW_SECRET 未设置时,EmDash 端点会生成按站点区分的密钥并存储在数据库中。独立的辅助函数无法读取该存储的值,因此如果应用代码调用该辅助函数,请配置这个环境变量。

id 的值标识被授权的内容,同时也会替换 URL 路径中的 {id}。它可以是数据库 ID 或 slug。请使用目标页面传给 getEmDashEntry() 的同一个标识符。

传入 baseUrl 可以生成绝对 URL:

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

传入 pathPattern 可以生成带有自定义路径的 URL:

const blogUrl = await getPreviewUrl({
	collection: "posts",
	id: "my-draft-post",
	secret,
	pathPattern: "/blog/{id}",
});
// Returns: /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",
});
// Returns: /pt-br/hello?_preview=...

await getPreviewUrl({
	collection: "posts",
	id: "hello",
	secret,
	pathPattern: "/{locale}/{id}",
	locale: "", // default locale, no prefix
});
// Returns: /hello?_preview=...

管理后台的 View on site 链接会经过 POST /_emdash/api/content/{collection}/{id}/preview-url。该端点使用条目的数据库 ID,并自动提供它的语言区域。当你的公开路由与默认的 /{collection}/{id} 模式不同时,请设置 EMDASH_PREVIEW_PATH_PATTERN。例如,/{locale}/posts/{id} 会为葡萄牙语条目生成 /pt-br/posts/01ABC...,并为不带前缀的默认语言区域条目生成 /posts/01ABC...。目标路由可以把该数据库 ID 传给 getEmDashEntry()。请求体中的 pathPattern 会覆盖环境变量的值。

该端点无法把条目的 slug 与其数据库 ID 分开替换。如果预览 URL 必须使用 slug,请在服务端应用代码中用 getPreviewUrl() 生成它,并把 slug 作为 id 传入。

{locale} 占位符接收的是已配置的语言区域代码。它不会应用 Astro 的自定义语言区域 path 映射。如果公开路由使用不同的路径段,请用该路径段调用辅助函数,或提供一个能接受管理后台端点所用语言区域代码的路由。

设置令牌过期时间

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

const preview = {
	collection: "posts",
	id: "my-draft-post",
	secret,
};

// Valid for 1 hour (default)
await getPreviewUrl(preview);

// Valid for 30 minutes
await getPreviewUrl({ ...preview, expiresIn: "30m" });

// Valid for 1 day
await getPreviewUrl({ ...preview, expiresIn: "1d" });

// Valid for 2 weeks
await getPreviewUrl({ ...preview, expiresIn: "2w" });

// Valid for 3600 seconds
await getPreviewUrl({ ...preview, expiresIn: 3600 });

支持的单位:s(秒)、m(分钟)、h(小时)、d(天)、w(周)。

在自定义流程中验证令牌

普通的 Astro 页面不会调用 verifyPreviewToken();EmDash 中间件已经验证了 _preview。只有当该中间件之外的代码必须校验令牌时,才使用这个辅助函数:

import { verifyPreviewToken } from "emdash";

// From a URL (extracts _preview query parameter)
const fromUrl = await verifyPreviewToken({
	url: Astro.url,
	secret,
});

// Or with a token directly
const fromToken = await verifyPreviewToken({
	token: Astro.url.searchParams.get("_preview"),
	secret,
});

返回结果会指示令牌是否有效:

if (fromUrl.valid) {
	// Token is valid
	console.log(fromUrl.payload.cid); // "posts:my-draft-post"
	console.log(fromUrl.payload.exp); // Expiry timestamp
	console.log(fromUrl.payload.iat); // Issued-at timestamp
} else {
	// Token is invalid
	console.log(fromUrl.error);
	// "none" - no token present
	// "malformed" - token structure is invalid
	// "invalid" - signature verification failed
	// "expired" - token has expired
}

显示预览指示器

使用 getEmDashEntry 返回的 isPreview 标志,来表明该页面来自预览请求:

{isPreview && (
  <div class="preview-banner" role="alert">
    <strong>Preview</strong> — You are viewing the preview version of this page.
    <a href={Astro.url.pathname}>Exit preview</a>
  </div>
)}

检查预览 URL

isPreviewRequest(url)

检查 URL 是否包含 _preview 参数。这不会验证令牌:

import { isPreviewRequest } from "emdash";

if (isPreviewRequest(Astro.url)) {
	// Handle preview request
}

getPreviewToken(url)

从 URL 中提取令牌字符串:

import { getPreviewToken } from "emdash";

const token = getPreviewToken(Astro.url);
// Returns the token string or null

parseContentId(contentId)

把内容 ID 解析为集合和 ID:

import { parseContentId } from "emdash";

const { collection, id } = parseContentId("posts:my-draft-post");
// { collection: "posts", id: "my-draft-post" }

令牌安全

预览令牌经过签名并有时间限制。管理后台端点和辅助函数会为你生成并验证它们;你不需要手动构造或解析。一个令牌只标识一个条目,并在过期后失效。

完整示例

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

---
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.rewrite("/404");
}
---

<BaseLayout title={entry.data.title}>
  {isPreview && (
    <div class="preview-banner" role="alert">
      <strong>Preview</strong> — You are viewing the preview version of this page.
    </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">Draft</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>