コンテンツのクエリ

このページ

EmDash ページはリクエスト時に getEmDashCollection() と getEmDashEntry() でコンテンツを読み取ります。前者はリストを返し、後者はスラッグまたはコンテンツ ID で 1 件のエントリを返します。どちらもエラーをデータとして返すため、ページが応答方法を決められます。

同梱テンプレートは 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 — 制限付きカーソルまたはオフセットページに次のページがあるかを報告する。

エントリ識別子

各結果には役割の異なる 2 つの識別子があります:

  • entry.id はコンテンツローダーが生成する URL 向け識別子です。通常はスラッグです。国際化がロケールを接頭辞として付ける場合、接頭辞が含まれます。コレクション結果からリンクを作るときはこの値を使います。
  • entry.data.id はデータベースに保存される安定したコンテンツ ID です。編集者がスラッグを変更しても変わりません。API、タクソノミーヘルパー、ページコンテキスト、またはリレーションがコンテンツ ID を期待するときに使います。

getEmDashEntry() はスラッグまたは安定したコンテンツ ID のいずれかを受け付けます。スラッグ検索は国際化が有効なときロケールにスコープされます。コンテンツ ID は行を直接識別します。

コレクションをフィルタする

2 番目の引数にフィルターを渡します。次のクエリは、series フィールドが engineering の news カテゴリの公開済み投稿を返します:

const { entries } = await getEmDashCollection("posts", {
  where: {
    category: "news",
    series: "engineering",
  },
});

タクソノミーを名前付けるキーは割り当てられたタームスラッグに一致します。その他のキーはコレクションフィールドに一致します。複数のキーは AND ロジックで結合されます。1 つのキーの配列はリストされたいずれかの値に一致するため、{ 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 降順になります。サイトが定期的にソートやフィルタに使うカスタムスカラーフィールドはインデックス付きとしてマークしてください。インデックスはコレクションの成長に伴うフルテーブルスキャンを避けます。

コレクションをページネーションする

継続的なフィードにはカーソル、番号付きページにはオフセットを使います。別々のページネーションモデルであり、1 つの型付きクエリで組み合わせることはできません。

カーソルページネーション

カーソルページネーションは、前のクエリが返した最後のエントリの後から続きます。リクエスト間でソートを変えず、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 は最終ページではありません。カーソルページネーションは総ページ数を計算せず、前ページのカーソルも提供しません。UI が戻るナビゲーションを必要とする場合は、ブラウザ履歴に以前の 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 を使い、「最初のエントリから開始」を意味します。オフセットページネーションはページ番号でアドレスしやすい一方、リクエスト間に追加されたエントリが後のページをずらすことがあります。その動きが混乱を招く場合はカーソルを使います。

1 件のエントリをクエリしてレンダリングする

次のランタイムルートはスラッグで投稿を読み取り、特集画像と 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 を渡さない呼び出しは追加クエリを実行せず、選択から外したフィールドは読み取られません。フィールドを選択する呼び出しはフィールドごとに 1 回のリンククエリと、各フィールドが保持するエントリ数にかかわらず対象コレクションごとに 1 回のエントリクエリのコストがかかります。

参照フィールドをページネーションする

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 トークンまたはカスタム認証ヘッダーを受け付けます。オプションについては 型の生成 を参照してください。

リレーションにバインドされた参照フィールドを持つ各コレクションは、同じスラッグの下に登録される 2 つ目のインターフェース {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 リファレンス を参照してください。これらのクエリを中心に動く例を作るには、ブログを作成する に進んでください。