プレビューモード

このページ

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;

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() テンプレートヘルパーは引き続きシークレットを明示的に渡すことを要求します。ほとんどのサイトではアドミン 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",
});

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); // "posts:my-draft-post"
	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 とヘルパー関数がそれらを生成・検証します。手動で構築やパースを行う必要はありません。トークンは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("サーバーエラー", { 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 — コレクションスラッグ (string)
  • id — コンテンツ ID またはスラッグ (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>