コンテンツのクエリ

このページ

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 番目の引数に渡します。次のクエリは、news カテゴリで series フィールドが engineering の公開済み投稿を返します。

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 はリクエストのロケール、次に設定されたデフォルトロケールを使います。フォールバックのルール と翻訳されたルートについては、国際化 (i18n) を参照してください。

status には "published"、"draft"、"archived" を指定できます。公開ルートからドラフトを リクエストしないでください。訪問者が未公開のエントリを 1 件だけ一時的に見る必要がある場合は、プレビューのフローを使います。

データベースで結果を並べ替える

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 の降順になります。サイトがカスタムのスカラー フィールドで定期的に並べ替えやフィルターを行う場合は、そのフィールドにインデックスを設定してください。コレクションが大きくなっても、インデックスがあればフルテーブル スキャンを避けられます。

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

続きを読み込み続けるフィードにはカーソルを、番号付きページにはオフセットを使います。これらは別々のページネーション モデルであり、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 がありません。カーソルページネーションでは総ページ数を計算せず、 前のページ用のカーソルも提供されません。インターフェースに「戻る」操作が必要な場合は、以前の 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>

オフセットは 0 以上の整数でなければなりません。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.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 としてレンダリングされます。この 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 } を使います。1 ページあたり最大 100 件です。

参照される各エントリは、直接読み込んだエントリと同じ形の ContentEntry です。id、日付が Date オブジェクトでメディアの値が解決済みの data オブジェクト、そして参照先のエントリに限定された edit プロキシを持つため、ビジュアル編集でカードをクリックすると、そのカードが指すエントリが開きます。Byline とタクソノミーのタームは例外です。EmDash は参照先のエントリにそれらを組み立てて付与しないため、data.bylines と data.terms はそのエントリ自体から読み取ってください。

フィールドがリレーションの親側にある場合、エントリはエディターが並べた順序で届きます。子側のフィールドは、それを指しているエントリを列挙しますが、それらには独自の順序がありません。

このオプションは、どちらの方向でもオプトインです。references を渡さない呼び出しでは追加のクエリは実行されず、選択に含まれないフィールドは読み取られません。フィールドを選択した呼び出しでは、フィールドごとに 1 回のリンククエリと、各フィールドが保持するエントリ数にかかわらず、異なる参照先コレクションごとに 1 回のエントリクエリが発生します。

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

getEmDashReferences は、前のページが返したカーソルを使って、1 つのフィールドの次のページを取得します。

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

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