プレビューモード

このページ

EmDashのプレビューシステムにより、編集者は安全で時間制限付きのURLを介して未公開のコンテンツを表示できます。プレビューリンクは、下書きコンテンツ全体を公開することなくレビュアーと共有できるHMAC-SHA256署名付きトークンを使用します。

仕組み

  1. 管理者が下書き投稿のプレビューURLを生成します
  2. URLには有効期限付きの署名された_previewクエリパラメータが含まれます
  3. EmDashのミドルウェアが自動的にトークンを検証し、リクエストコンテキストをセットアップします
  4. テンプレートコードは通常通りgetEmDashEntry()を呼び出します — 下書きコンテンツが自動的に提供されます

プレビューは暗黙的です。ミドルウェアがトークンを検証し、クエリ関数がAsyncLocalStorageを介してそれを読み取るため、同じテンプレートコードがプレビュー中は下書きコンテンツを、それ以外の場合は公開されたコンテンツを提供します。

プレビューの設定

プレビューはEmDashをインストールするとすぐに機能します。初回使用時に、EmDashはサイトごとのプレビューシークレットを生成し、データベースに保存するため、一般的なケースでは設定は不要です。

以下が必要な場合にのみ、環境にEMDASH_PREVIEW_SECRETを設定してください:

  • 複数のプロセス間でシークレットを共有する必要がある場合(例:URLに署名して検証のためにメインサイトに送信する別のプレビューWorker)
  • コンプライアンス/監査の理由で、制御する値にシークレットを固定する必要がある場合
  • バックアップから復元する際に既知の値に移行する必要がある場合
# オプション:自動生成されたシークレットを上書き
EMDASH_PREVIEW_SECRET="your-random-secret-key-here"

設定されている場合、環境値はDBに保存された値よりも優先されます。

既存のテンプレートは、以下のページのように自動的にプレビューと連携します:

---
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>

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",
});
// 戻り値: /posts/my-draft-post?_preview=eyJjaWQ...

EMDASH_PREVIEW_SECRETが設定されていない場合、EmDashはトークン検証のためにサイトごとのシークレットを自動生成してデータベースに保存します。getPreviewUrl()テンプレートヘルパーは、シークレットを明示的に渡すことを要求します — ページテンプレートから呼び出す場合は環境変数を固定してください。ほとんどのサイトは、代わりに管理UIの「プレビューリンクを生成」ボタンを使用します。これはAPIを経由し、解決されたシークレットを自動的に使用します。

baseUrlを渡して絶対URLを生成します:

const fullUrl = await getPreviewUrl({
	collection: "posts",
	id: "my-draft-post",
	secret: import.meta.env.EMDASH_PREVIEW_SECRET,
	baseUrl: "https://example.com",
});
// 戻り値: https://example.com/posts/my-draft-post?_preview=eyJjaWQ...

pathPatternを渡してカスタムパスでURLを生成します:

const blogUrl = await getPreviewUrl({
	collection: "posts",
	id: "my-draft-post",
	secret: import.meta.env.EMDASH_PREVIEW_SECRET,
	pathPattern: "/blog/{id}",
});
// 戻り値: /blog/my-draft-post?_preview=eyJjaWQ...

ロケール対応パス

pathPattern{locale}プレースホルダーもサポートしています。エントリがデフォルトロケールにあり、prefixDefaultLocalefalseの場合は空のlocaleを渡します。空の値によって残された隣接するスラッシュは自動的に折りたたまれます。

次の例は、ロケールプレフィックス付きのプレビューURLを構築します:

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}) — リクエストボディに独自のpathPatternが含まれている場合は、それが優先されます。

トークンの有効期限

プレビューリンクが有効である期間を制御します:

// 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" }

トークンのセキュリティ

プレビュートークンは署名され、時間制限があります。CLIとヘルパー関数がトークンを生成および検証します。手動で構築または解析する必要はありません。トークンは1つのエントリを識別し、有効期限が切れると機能しなくなります。

完全な例

次のページは、完全なブログ投稿テンプレートでプレビューとビジュアル編集のサポートを組み合わせています:

---
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 — コレクションスラッグ(文字列)
  • id — コンテンツIDまたはスラッグ(文字列)
  • 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を構築せずにトークンを生成します。

オプション:

  • contentIdcollection:id形式のコンテンツID
  • expiresIn — トークンの有効期間(デフォルト:"1h"
  • secret — 署名シークレット

戻り値: Promise<string>