預覽模式

本頁內容

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>