콘텐츠 쿼리

이 페이지

EmDash 페이지는 요청 시 getEmDashCollection()과 getEmDashEntry()로 콘텐츠를 읽습니다. 전자는 목록을 반환하고, 후자는 슬러그 또는 콘텐츠 ID로 한 항목을 반환합니다. 둘 다 오류를 데이터로 반환하므로 페이지가 응답 방식을 결정할 수 있습니다.

번들 템플릿은 Astro의 서버 출력을 사용합니다. 따라서 방문자는 편집자가 게시한 직후 다음 렌더에서 최신 게시 리비전을 받습니다. 저장된 초안은 게시되거나 유효한 미리보기 URL을 통해 요청될 때까지 비공개로 유지됩니다.

컬렉션 쿼리

다음 페이지는 가장 최근에 게시된 게시물 일곱 개를 로드합니다. 공개 컬렉션 쿼리는 기본적으로 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는 행을 직접 식별합니다.

컬렉션 필터

두 번째 인자에 필터를 전달합니다. 다음 쿼리는 series 필드가 engineering인 news 카테고리의 게시된 게시물을 반환합니다:

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는 요청 로케일, 그다음 구성된 기본 로케일을 사용합니다. 폴백 규칙과 번역된 경로는 국제화를 참조하세요.

status는 "published", "draft", 또는 "archived"를 받습니다. 공개 경로에서 초안을 요청하지 마세요. 방문자가 미게시 항목에 일시적으로 접근해야 할 때는 미리보기 흐름을 사용하세요.

데이터베이스에서 결과 정렬

orderBy는 필드 이름을 "asc" 또는 "desc"에 매핑합니다. entry.data에 반환되는 camelCase 이름이 아니라 저장된 필드 이름을 사용하세요:

const { entries } = await getEmDashCollection("posts", {
  orderBy: {
    published_at: "desc",
    title: "asc",
  },
});

시스템 열은 created_at, updated_at, published_at 같은 데이터베이스 이름을 사용합니다. 사용자 지정 필드는 title 또는 priority 같은 컬렉션 슬러그를 사용합니다. 반환 데이터는 시스템 날짜를 createdAt, updatedAt, publishedAt에 매핑하지만 그 camelCase 속성 이름은 유효한 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.redirect("/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.redirect("/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.redirect("/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 블록을 포함한 EmDash 표준 블록용 렌더러를 제공합니다. 사이트가 자체 Portable Text 블록을 추가하면 사용자 지정 컴포넌트를 전달하세요. htmlBlock 렌더러를 바꾸면 HTML을 살균하고 사이트가 신뢰하려는 iframe 호스트만 허용하세요.

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.redirect("/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 프록시가 있어 비주얼 편집에서 카드를 클릭하면 카드가 가리키는 항목이 열립니다. 바이라인과 택소노미 텀은 예외입니다. 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, no-index 선택을 항목의 폴백과 함께 해석합니다. 현재 블로그 템플릿은 그 결과를 기본 레이아웃에 전달합니다:

---
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만 읽는 손으로 쓴 메타 태그는 편집자의 정규 URL이나 no-index 설정을 적용하지 않습니다.

미리보기 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 참조를 참조하세요. 이러한 쿼리를 중심으로 작동하는 예제를 만들려면 블로그 만들기로 계속하세요.