미리보기 모드

이 페이지

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와 헬퍼 함수가 이를 생성하고 검증합니다. 수동으로 구성하거나 파싱할 필요가 없습니다. 토큰은 하나의 항목을 식별하며 만료 후에는 작동하지 않습니다.

전체 예제

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