このページで分かること

  • 署名付きで有効期限のあるプレビューURLの仕組みと、ミドルウェアが下書きを返す流れ
  • プレビュー用の秘密鍵(EMDASH_PREVIEW_SECRET)を設定する必要がある場合と、getPreviewUrl() でURLを作る方法(ロケール付きのパスを含む)
  • 有効期限の設定、独自の処理でのトークンの検証、プレビュー中であることの表示、関連する関数のリファレンス
難易度
実践
読む時間
5分
このページの目次

EmDashのプレビューの仕組みを使うと、編集者は安全で有効期限のあるURLから、未公開のコンテンツを確認できます。プレビューリンクはHMAC-SHA256で署名したトークンを使うため、下書きのコンテンツ全体を公開することなく、確認担当者と共有できます。

プレビューのリクエストの流れ

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

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

本サイトの補足 やさしい解説

WordPressの「プレビュー」と同じく、公開前の記事を確認するための仕組みです。EmDashでは、プレビュー用のURLに署名付きの _preview パラメーターが付き、そのURLを開いたときだけ下書きが表示されます。テンプレートの側で下書きかどうかを判定するコードを書く必要はなく、通常どおり getEmDashEntry() を呼び出すだけです。トークンには有効期限があり、1つのエントリーにしか使えないため、リンクを共有しても他の下書きは見られません。

プレビューの設定

プレビューは、EmDashをインストールした時点で使えます。初めて使うときに、EmDashはサイトごとのプレビュー用の秘密鍵を生成してデータベースに保存するため、一般的な場合は設定が要りません。

次の場合にだけ、環境変数に EMDASH_PREVIEW_SECRET を設定します。

  • 複数のプロセスで秘密鍵を共有する必要がある場合(例:URLに署名してメインのサイトに送り、メインのサイトで検証させる、別のプレビュー用Worker)
  • コンプライアンスや監査の理由で、秘密鍵を自分で管理する値に固定する場合
  • バックアップから復元するときに、既知の値に移行する場合
.env
# Optional: override the auto-generated secret
EMDASH_PREVIEW_SECRET="your-random-secret-key-here"

設定した場合は、データベースに保存された値より環境変数の値が優先されます。

既存のテンプレートは、次のページのように、そのままプレビューに対応します。

src/pages/posts/[...slug].astro
---
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.redirect("/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に署名するための値)は、EmDashが初回に自動で作り、データベースに保存します。EMDASH_PREVIEW_SECRET を自分で設定するのは、このページに挙げられた3つの場合(複数のプロセスで共有する、値を固定する、バックアップからの復元で既知の値に移す)だけです。

プレビュー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とスラッグのどちらでも指定できます。移動先のページが 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} のプレースホルダーにも対応しています。エントリーが既定のロケールで、prefixDefaultLocalefalse の場合は、空の locale を渡します。空の値によって連続したスラッシュは、自動的に1つにまとめられます。

次の例は、ロケールの接頭辞付きのプレビュー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 があれば、環境変数の値より優先されます。

このエンドポイントは、エントリーのデータベースIDとは別にスラッグを差し込むことはできません。プレビューURLにスラッグを使う必要がある場合は、サーバー側のアプリケーションのコードで getPreviewUrl() を使って作り、スラッグを 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つのトークンは1つのエントリーを識別し、有効期限が過ぎると使えなくなります。

完全な例

次のページは、ブログ記事のテンプレート全体で、プレビューとビジュアル編集への対応を組み合わせています。

src/pages/posts/[...slug].astro
---
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>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 — コレクションのスラッグ(文字列)
  • id — コンテンツのIDまたはスラッグ(文字列)
  • secret — 署名用の秘密鍵(文字列)
  • expiresIn — トークンの有効期間(既定値:"1h"
  • baseUrl — 絶対リンクを作るための、任意のベースURL
  • pathPattern{collection}{id}{locale} のプレースホルダーを含むURLのパターン(既定値:"/{collection}/{id}"
  • locale{locale} に入る値。空文字列の場合はロケールのセグメントを省略します(スラッシュは1つにまとめられます)。

戻り値: 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>