預覽模式

本頁內容

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>