EmDash 페이지는 요청 시점에 getEmDashCollection()과 getEmDashEntry()로 콘텐츠를 읽습니다.
첫 번째 함수는 목록을 반환하고, 두 번째 함수는 슬러그 또는 콘텐츠 ID로 항목 하나를 반환합니다. 둘 다
오류를 데이터로 반환하므로 페이지가 어떻게 응답할지 결정할 수 있습니다.
기본 제공 템플릿은 Astro의 서버 출력을 사용합니다. 따라서 편집자가 게시하면 방문자는 다음 렌더링에서 가장 최근에 게시된 리비전을 받습니다. 저장된 초안은 게시되거나 유효한 미리보기 URL로 요청되기 전까지 비공개로 유지됩니다.
컬렉션 쿼리
다음 페이지는 가장 최근에 게시된 게시물 7개를 불러옵니다. 공개 컬렉션 쿼리는 기본적으로
status: "published"를 사용하므로 필터에서 이를 반복할 필요가 없습니다.
---
import { getEmDashCollection } from "emdash";
const { entries: posts, error, cacheHint } = await getEmDashCollection("posts", {
orderBy: { published_at: "desc" },
limit: 7,
});
if (error) {
console.error("Failed to load posts:", error);
return new Response("Unable to load posts", { status: 500 });
}
if (Astro.cache?.enabled) Astro.cache.set(cacheHint);
---
<h1>Posts</h1>
<ul>
{posts.map((post) => (
<li>
<a href={`/posts/${post.id}`}>{post.data.title}</a>
</li>
))}
</ul>
정렬과 개수 제한은 EmDash가 항목을 구성하기 전에 데이터베이스에서 수행됩니다. 이렇게 하면 컬렉션 전체를 불러온 다음 페이지에서 정렬하거나 잘라 내는 일을 피할 수 있습니다.
결과에는 다음이 포함됩니다.
entries: 일치하는 항목이 없거나 쿼리가 실패하면 빈 배열입니다.error: 쿼리가 실패하면 설정되고, 빈 결과일 때는 설정되지 않습니다.cacheHint: Astro 캐시를 위한 태그와 마지막 수정 시각을 담고 있습니다.nextCursor: 개수가 제한된 커서 페이지에 항목이 더 있을 때 설정됩니다.hasMore: 개수가 제한된 커서 또는 오프셋 페이지에 다음 페이지가 있는지 알려 줍니다.
항목 식별자
각 결과에는 역할이 서로 다른 두 가지 식별자가 있습니다.
entry.id는 콘텐츠 로더가 만드는 URL용 식별자입니다. 보통은 슬러그입니다. 국제화가 로케일을 접두사로 붙이는 경우 그 접두사도 포함됩니다. 컬렉션 결과로 링크를 만들 때 이 값을 사용합니다.entry.data.id는 데이터베이스에 저장된 안정적인 콘텐츠 ID입니다. 편집자가 슬러그를 바꿔도 변하지 않습니다. API, 택소노미 헬퍼, 페이지 컨텍스트 또는 관계가 콘텐츠 ID를 요구할 때 사용합니다.
getEmDashEntry()는 슬러그와 안정적인 콘텐츠 ID 중 어느 쪽이든 받습니다. 국제화가 활성화되어 있으면
슬러그 조회는 로케일 범위로 한정되고, 콘텐츠 ID는 해당 행을 직접 가리킵니다.
컬렉션 필터
필터는 두 번째 인수로 전달합니다. 다음 쿼리는 news
카테고리에 속하고 series 필드가 engineering인 게시된 게시물을 반환합니다.
const { entries } = await getEmDashCollection("posts", {
where: {
category: "news",
series: "engineering",
},
});
택소노미 이름의 키는 할당된 용어 슬러그와 일치합니다. 그 밖의 키는 컬렉션 필드와 일치합니다. 여러
키는 AND 로직으로 결합됩니다. 한 키에 배열을 지정하면 나열된 값 중 하나와 일치하므로
{ category: ["news", "updates"] }는 두 카테고리 중 어느 쪽과도 일치합니다.
특정 언어를 명시적으로 요청하려면 locale을 사용합니다.
const { entries: frenchPosts } = await getEmDashCollection("posts", {
locale: "fr",
orderBy: { published_at: "desc" },
});
locale을 생략하면 EmDash는 요청 로케일을 사용하고, 그다음 구성된 기본 로케일을 사용합니다. 대체 규칙과
번역된 라우트는 국제화 (i18n)를 참조하세요.
status는 "published", "draft", "archived"를 받습니다. 공개
라우트에서 초안을 요청하지 마세요. 방문자가 게시되지 않은 항목 하나에 임시로 접근해야 한다면 미리보기 흐름을 사용하세요.
데이터베이스에서 결과 정렬
orderBy는 필드 이름을 "asc" 또는 "desc"에 매핑합니다. entry.data에서
반환되는 카멜 케이스 이름이 아니라 저장된 필드 이름을 사용하세요.
const { entries } = await getEmDashCollection("posts", {
orderBy: {
published_at: "desc",
title: "asc",
},
});
시스템 컬럼은 created_at, updated_at, published_at처럼 데이터베이스 이름을 사용합니다.
사용자 정의 필드는 title이나 priority처럼 컬렉션 슬러그를 사용합니다. 반환되는 데이터에서는 시스템
날짜가 createdAt, updatedAt, publishedAt으로 매핑되지만, 이러한 카멜 케이스 속성 이름은
유효한 orderBy 필드가 아닙니다.
EmDash는 첫 번째 유효한 orderBy 필드를 페이지네이션 키로 사용하고, 콘텐츠 ID를 안정적인
타이브레이커로 사용합니다. orderBy가 없으면 컬렉션은 기본적으로 created_at 내림차순입니다. 사이트가 사용자 정의 스칼라
필드로 정기적으로 정렬하거나 필터링한다면 해당 필드를 인덱싱된 것으로 표시하세요. 컬렉션이 커져도 인덱스가 전체 테이블
스캔을 피하게 해 줍니다.
컬렉션 페이지네이션
계속 이어지는 피드에는 커서를, 번호가 매겨진 페이지에는 오프셋을 사용합니다. 이 둘은 별개의 페이지네이션 모델이며 하나의 타입 지정 쿼리에서 함께 사용할 수 없습니다.
커서 페이지네이션
커서 페이지네이션은 이전 쿼리가 반환한 마지막 항목 다음부터 이어집니다. 요청 간에 정렬을
바꾸지 말고, nextCursor는 살펴보거나 수정하지 말고 그대로 다시 전달하세요.
다음 라우트는 다른 페이지가 있을 때 Older posts 링크를 렌더링합니다.
---
import { getEmDashCollection } from "emdash";
const cursor = Astro.url.searchParams.get("cursor") ?? undefined;
const { entries: posts, nextCursor, error } = await getEmDashCollection("posts", {
limit: 10,
cursor,
orderBy: { published_at: "desc" },
});
if (error) return new Response("Unable to load posts", { status: 500 });
---
<ul>
{posts.map((post) => <li><a href={`/posts/${post.id}`}>{post.data.title}</a></li>)}
</ul>
{nextCursor && (
<a href={`/posts?cursor=${encodeURIComponent(nextCursor)}`}>Older posts</a>
)}
마지막 페이지에는 nextCursor가 없습니다. 커서 페이지네이션은 전체 페이지 수를 계산하지 않으며
이전 페이지 커서도 제공하지 않습니다. 인터페이스에 뒤로 가기 탐색이 필요하다면 이전 URL을 브라우저 기록에 보존하세요.
오프셋 페이지네이션
오프셋 페이지네이션은 /posts/page/3 같은 라우트에 적합합니다. 페이지 번호를 오프셋으로 변환하고
다음 페이지 링크에는 hasMore를 사용하세요.
---
import { getEmDashCollection } from "emdash";
const parsedPage = Number(Astro.params.page ?? "1");
if (!Number.isInteger(parsedPage) || parsedPage < 1) {
return Astro.rewrite("/404");
}
const perPage = 10;
const { entries: posts, hasMore, error } = await getEmDashCollection("posts", {
limit: perPage,
offset: (parsedPage - 1) * perPage,
orderBy: { published_at: "desc" },
});
if (error) return new Response("Unable to load posts", { status: 500 });
---
<ul>
{posts.map((post) => <li><a href={`/posts/${post.id}`}>{post.data.title}</a></li>)}
</ul>
<nav aria-label="Post pages">
{parsedPage > 1 && <a href={`/posts/page/${parsedPage - 1}`}>Newer posts</a>}
{hasMore && <a href={`/posts/page/${parsedPage + 1}`}>Older posts</a>}
</nav>
오프셋은 음이 아닌 정수여야 합니다. 1페이지는 오프셋 0을 사용하며, 이는 “첫 번째 항목에서 시작”을 뜻합니다. 오프셋 페이지네이션은 페이지 번호로 접근하기 쉽지만, 요청 사이에 추가된 항목이 뒤쪽 페이지를 밀어낼 수 있습니다. 그런 이동이 혼란을 줄 수 있다면 커서를 사용하세요.
한 항목 쿼리 및 렌더링
다음 런타임 라우트는 슬러그로 게시물을 읽고, 대표 이미지와 Portable Text 본문을 렌더링하며, 쿼리 실패와 존재하지 않는 항목을 구분합니다.
---
import { decodeSlug, getEmDashEntry } from "emdash";
import { Image, PortableText } from "emdash/ui";
const slug = decodeSlug(Astro.params.slug);
if (!slug) return Astro.rewrite("/404");
const { entry: post, error, isPreview, cacheHint } = await getEmDashEntry("posts", slug);
if (error) {
console.error("Failed to load post:", error);
return new Response("Unable to load post", { status: 500 });
}
if (!post) return Astro.rewrite("/404");
if (Astro.cache?.enabled) Astro.cache.set(cacheHint);
---
{isPreview && <p>This is an unpublished preview.</p>}
<article>
{post.data.featured_image && <Image image={post.data.featured_image} priority />}
<h1>{post.data.title}</h1>
<PortableText value={post.data.content} />
</article>
PortableText는 이미지, 동영상, 갤러리, 코드,
표, HTML 블록, iframe 블록을 포함한 EmDash의 표준 블록용 렌더러를 제공합니다. 사이트가 자체 Portable Text
블록을 추가하면 사용자 정의 컴포넌트를 전달하세요.
isolated: true인 HTML 블록은 html, css, js를 콘텐츠에 맞게 크기가 조정되는 샌드박스 iframe 안에서 렌더링합니다.
그 외의 HTML 블록은 정제한 html을 페이지 안에서 렌더링합니다. htmlBlock 렌더러를 교체한다면 격리된 블록은
emdash/ui의 HtmlBlock에 전달하거나, sandbox에 allow-same-origin이 없는 iframe 안에서 렌더링하세요.
인라인 HTML은 직접 정제하고, 사이트가 신뢰하려는 iframe 호스트만 허용하세요.
iframe 블록은 emdash/ui의 Iframe을 통해 반응형이며 지연 로딩되는 iframe으로 렌더링되며, sandbox와
strict-origin-when-cross-origin 리퍼러 정책이 적용됩니다. 사이트 자체 호스트의 페이지는
동일 출처 접근 권한을 잃으며, https 소스만 렌더링됩니다.
동영상 블록은 emdash/ui의 Video를 통해 렌더링됩니다. 미디어 라이브러리의 동영상은 스토리지의 공개 URL이 설정되어 있으면 해당 URL에서 컨트롤과 캡션과 함께 브라우저 자체 플레이어로 재생됩니다. 공개 URL이 없으면 동영상은 /_emdash/api/media/file/를 통해 제공됩니다. 미디어 프로바이더의 동영상은 해당 프로바이더를 통해 렌더링됩니다. asset이 없는 빈 동영상 블록은 아무것도 렌더링하지 않습니다.
Astro의 콘텐츠 보안 정책을
켜면 브라우저가 격리된 HTML 블록 안의 스타일과 스크립트를 차단하므로,
해당 블록은 CSS, JavaScript, 자동 높이 조정 없이 렌더링됩니다. iframe 블록도 사용자 지정 크기가 적용되지 않으며,
정책의 frame-src가 임베드하는 호스트를 허용해야 합니다. 스토리지 공개 URL이나 미디어 프로바이더처럼 미디어가 다른 호스트에 있는 동영상 블록은 해당 호스트를 media-src에 포함해야 합니다.
PortableText는 편집 모드에서 표를 읽기 전용 자리 표시자로 보여 줍니다. 초기 레이블을 현지화하려면 tablePlaceholder={translatedLabel}을 전달하세요. 기본값은 "Table (edit in admin)"이며 게시된 표 콘텐츠에는 영향을 주지 않습니다.
참조 필드 읽기
reference 필드는 항목을 다른 컬렉션의 항목에 연결하며, 정의 방법은 관계에서 다룹니다. 그 값은 data에 포함되지 않습니다. 페이지가 렌더링할 필드를 필드 슬러그를 키로 하여 지정한 references 옵션을 getEmDashEntry에 전달하면, 각 필드가 항목 페이지로 반환됩니다.
---
import { getEmDashEntry } from "emdash";
const { entry: post } = await getEmDashEntry("posts", Astro.params.slug, {
references: { author: true, related_posts: { limit: 6 } },
});
if (!post) return Astro.rewrite("/404");
const author = post.references?.author.entries[0];
---
<article>
<h1>{post.data.title}</h1>
{author && <p>By {author.data.name}</p>}
<ul>
{post.references?.related_posts.entries.map((related) => (
<li><a href={`/posts/${related.data.slug}`}>{related.data.title}</a></li>
))}
</ul>
</article>
true는 기본 한도인 50개 항목으로 첫 페이지를 요청합니다. 더 많은 항목을 담는 필드에는 { limit, cursor }를 사용하세요. 페이지당 최대 100개입니다.
참조된 각 항목은 직접 불러온 항목과 같은 형태의 ContentEntry입니다. id, 날짜는 Date 객체이고 미디어 값은 해석된 data 객체, 그리고 참조된 항목으로 범위가 한정된 edit 프록시를 가지므로, 시각적 편집에서 카드를 클릭하면 그 카드가 가리키는 항목이 열립니다. Byline과 택소노미 용어는 예외입니다. EmDash는 참조된 항목에 이를 구성해 붙이지 않으므로, data.bylines와 data.terms는 해당 항목 자체에서 읽으세요.
필드가 관계의 부모 쪽에 있으면 항목은 편집자가 배열한 순서대로 도착합니다. 자식 쪽 필드는 그 필드를 가리키는 항목을 나열하며, 이 항목들에는 고유한 순서가 없습니다.
이 옵션은 양방향 모두 선택 적용 방식입니다. references를 전달하지 않은 호출은 추가 쿼리를 실행하지 않으며, 선택에서 빠진 필드는 읽지 않습니다. 필드를 선택한 호출은 필드마다 링크 쿼리 한 번과, 각 필드가 담고 있는 항목 수와 관계없이 서로 다른 대상 컬렉션마다 항목 쿼리 한 번의 비용이 듭니다.
참조 필드 페이지네이션
getEmDashReferences는 이전 페이지가 반환한 커서를 사용해 단일 필드의 다음 페이지를 가져옵니다.
import { getEmDashReferences } from "emdash";
const { entries, nextCursor } = await getEmDashReferences("posts", post.id, "related_posts", {
cursor,
limit: 20,
});
초안 가시성은 getEmDashEntry와 같은 요청 컨텍스트에서 읽으므로, 미리보기에서 시작한 순회는 보류 중인 선택을 계속 볼 수 있습니다. 페이지 id는 i18n이 접두사를 붙이는 경우 로케일을 포함하며(fr/about), 다시 전달할 때 사용할 id가 바로 이것입니다.
출력을 캐시하는 라우트에서는 페이지가 반환하는 cacheHint를 라우트 자체의 힌트에 병합하세요. 그러면 해당 페이지가 지목한 항목 중 하나에 쓰기가 일어날 때 페이지가 만료됩니다.
미리보기의 참조 필드
공개 렌더링에서는 게시된 항목과 게시된 선택이 보입니다. 항목의 미리보기나 시각적 편집 중인 편집자에게는 게시되지 않은 항목과 항목의 초안에 스테이징된 선택이 보이므로, 미리보기 링크는 페이지가 게시된 뒤에 갖게 될 참조를 보여 줍니다.
SEO 및 편집 기능 적용
SEO를 지원하는 컬렉션에서는 getSeoMeta()가 편집자의 SEO 제목, 설명,
이미지, 정식 URL, noindex 선택을 항목의 대체값과 함께 해석합니다. 현재 블로그 템플릿은
그 결과를 기본 레이아웃에 전달합니다.
---
import { getSeoMeta } from "emdash";
const seo = getSeoMeta(post, {
siteTitle: "My Blog",
siteUrl: Astro.url.origin,
path: Astro.url.pathname,
});
---
<Base
title={seo.title}
pageTitle={seo.ogTitle}
description={seo.description}
image={seo.ogImage}
canonical={seo.canonical}
robots={seo.robots}
>
<!-- Post content -->
</Base>
<EmDashHead>를 포함하는 레이아웃은 동일한 SEO 필드와 플러그인 기여를
서버 렌더링되는 콘텐츠 페이지에 적용할 수 있습니다. data.title이나 data.excerpt만 읽는 직접 작성한 meta 태그는
편집자의 정식 URL이나 noindex 설정을 적용하지 않습니다.
미리보기 URL에는 별도의 쿼리가 필요하지 않습니다. 미들웨어가 _preview 토큰을 검증하고,
getEmDashEntry()가 일치하는 초안을 isPreview: true와 함께 반환합니다.
미리보기 및 시각적 편집 가이드에서 URL 생성 방법과 인라인 편집에 사용되는 entry.edit
주석을 설명합니다.
TypeScript 타입 생성
개발 서버는 활성 스키마로부터 emdash-env.d.ts를 생성합니다. 이 파일이 프로젝트의 TypeScript 구성에
포함되어 있도록 유지하면 "posts" 같은 컬렉션 이름이 생성된
Post 데이터 타입을 자동으로 선택합니다.
원격 EmDash 인스턴스에서는 CLI가 스키마를 가져와 타입을 .emdash/types.ts에 쓸 수 있습니다.
npx emdash types --url https://cms.example.com
이 명령은 API 토큰 또는 사용자 정의 인증 헤더를 받습니다. 해당 옵션은 타입 생성을 참조하세요.
관계에 바인딩된 참조 필드가 있는 각 컬렉션은 같은 슬러그로 등록되는 두 번째 인터페이스
{Collection}References를 얻습니다. getEmDashEntry는 결과를
references 옵션이 지정한 필드로 좁히며, 각 페이지의 항목은 대상 컬렉션의
인터페이스를 가집니다.
const { entry: post } = await getEmDashEntry("posts", "my-post", {
references: { author: true },
});
// post?.references?.author.entries[0] carries the Author interface
// post?.references?.related_posts is a type error: it was not selected
런타임 렌더링 및 캐싱
Node.js 및 Cloudflare 블로그 템플릿은 astro.config.mjs에서 output: "server"를 설정합니다. 이들의 콘텐츠
쿼리는 서버 렌더링 때마다 실행되므로 새로 게시된 리비전은 다음
요청에서 반영될 수 있습니다. 라우트를 의도적으로 프리렌더링하면 그 HTML에는 빌드 시점에 사용할 수 있던
콘텐츠가 담기고, 다시 빌드한 뒤에만 바뀝니다.
Astro 캐시가 활성화되어 있으면 쿼리의 cacheHint를 Astro.cache.set()에 전달하세요. EmDash는
응답을 컬렉션 및 항목 태그와 연결하므로, 게시할 때 영향을 받는 캐시된 페이지를 무효화할 수 있습니다.
업데이트 지연이 명시적인 제품 결정이 아닌 한, 이 통합을 긴 고정 Cache-Control 수명으로 대체하지 마세요.
정확한 시그니처와 덜 일반적인 필터는 JavaScript API 참조를 참조하세요. 이러한 쿼리를 바탕으로 동작하는 예제를 만들려면 블로그 만들기로 이어서 진행하세요.