プレビューモード

このページ

EmDash のプレビューシステムを使うと、編集者は有効期限付きの安全な URL を通じて未公開のコンテンツを表示できます。プレビューリンクは HMAC-SHA256 で署名されたトークンを使用しており、下書きコンテンツ全体を公開することなくレビュアーと共有できます。

プレビューリクエストの仕組み

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

ミドルウェアはページをレンダリングする前にトークンを検証し、そのトークンが認可するコンテンツエントリを記録します。getEmDashEntry() はそのリクエストコンテキストを自動的に読み取ります。そのため、同じテンプレートが、有効で一致するプレビューリンクに対しては下書きを、通常のリクエストに対しては公開済みのエントリを提供します。別のコレクションやエントリのトークンが、リクエストされたページへの下書きアクセスを許可することはありません。

プレビューを設定する

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

次のような場合にのみ、環境に EMDASH_PREVIEW_SECRET を設定してください。

  • 複数のプロセス間でシークレットを共有する場合(例:URL に署名してメインサイトに検証のために送る、別個のプレビュー Worker)
  • コンプライアンスや監査上の理由で、シークレットを自分が管理する値に固定する場合
  • バックアップから復元する際に、既知の値へ移行する場合
# 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() に渡すものと同じ識別子を使ってください。

絶対 URL を生成するには、baseUrl を渡します。

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

カスタムパスの URL を生成するには、pathPattern を渡します。

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

トークンのセキュリティ

プレビュートークンは署名されており、有効期限があります。管理画面のエンドポイントとヘルパー関数がトークンを生成して検証するため、手作業で構築したり解析したりする必要はありません。トークンは 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.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>