미리보기 모드

이 페이지

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나 슬러그일 수 있습니다. 대상 페이지가 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은 환경 변수 값을 덮어씁니다.

이 엔드포인트는 항목의 슬러그를 데이터베이스 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" }

토큰 보안

미리보기 토큰은 서명되어 있으며 시간 제한이 있습니다. 관리자 엔드포인트와 헬퍼 함수가 토큰을 생성하고 검증해 주므로, 직접 만들거나 파싱할 필요가 없습니다. 토큰은 하나의 항목을 식별하며 만료되면 더 이상 동작하지 않습니다.

전체 예제

다음 페이지는 완전한 블로그 게시물 템플릿에서 미리보기와 비주얼 편집 지원을 결합합니다.

---
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 — 컬렉션 슬러그(문자열)
  • 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을 만들지 않고 토큰을 생성합니다.

옵션:

  • contentId — collection:id 형식의 콘텐츠 ID
  • expiresIn — 토큰 유효 기간(기본값: "1h")
  • secret — 서명 시크릿

반환값: Promise<string>