預覽模式

本頁內容

EmDash 的預覽系統讓編輯者可以透過安全、有時限的 URL 檢視未發佈內容。預覽連結使用 HMAC-SHA256 簽署的權杖,你可以與審稿者分享,而不必暴露整份草稿。

運作方式

  1. 管理後台為草稿文章產生預覽 URL
  2. URL 包含帶過期時間的已簽署 _preview 查詢參數
  3. EmDash 中介軟體自動驗證權杖並設定請求內容
  4. 你的範本照常呼叫 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>

透過有效預覽權杖提供草稿時,isPreviewtrue

產生預覽 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 的內容 ID
  • exp — 過期時間戳(自紀元起的秒數)
  • 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 — 選用,絕對連結的基底 URL
  • pathPattern — 含 {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 的內容 ID
  • expiresIn — 有效期(預設 "1h"
  • secret — 簽署密鑰

傳回: Promise<string>