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>