このサイトは非公式の日本語訳です。Cloudflare・EmDashプロジェクトが運営するサイトではありません。

このページで分かること

  • getEmDashCollection()getEmDashEntry() が返す結果の中身と、entry.identry.data.id の使い分け
  • 絞り込み(タクソノミー、フィールド、ロケール、状態)、データベースでの並べ替え、カーソル方式とオフセット方式のページ分割
  • 1件のエントリーの描画、SEO情報の適用、TypeScriptの型の生成、サーバーでの描画とキャッシュの関係
難易度
実践
読む時間
6分
このページの目次

EmDashのページは、getEmDashCollection()getEmDashEntry() を使って、リクエスト時にコンテンツを読み込みます。1つ目の関数は一覧を返し、2つ目の関数はスラッグまたはコンテンツのIDで指定した1件のエントリーを返します。どちらもエラーをデータとして返すため、ページ側でどう応答するかを決められます。

同梱のテンプレートは、Astroのサーバー出力を使っています。そのため、編集者が公開したあと、次の描画から、訪問者は最新の公開済みのリビジョンを受け取ります。保存した下書きは、公開されるか、有効なプレビューURLでリクエストされるまで非公開のままです。

コレクションの取得

次のページは、最近公開された投稿を7件読み込みます。公開用のコレクションのクエリは既定で status: "published" を使うため、絞り込みで改めて指定する必要はありません。

src/pages/posts/index.astro
---
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:件数を制限したカーソルまたはオフセットのページに、次のページがあるかどうかを示します。
本サイトの補足 やさしい解説

WordPressの WP_Query にあたるのが getEmDashCollection() です。EmDashでは、取得に失敗したときだけ error に値が入ります。結果が0件のときは、entries が空の配列になるだけで、error は空のままです。そのため、ページでは「error があればエラーの応答を返す」「なければ entries を並べる」という順に書きます。

エントリーの識別子

それぞれの結果は、役割の異なる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"] } は、どちらのカテゴリーにも一致します。

1つの言語を明示的に指定するには、locale を使います。

const { entries: frenchPosts } = await getEmDashCollection("posts", {
  locale: "fr",
  orderBy: { published_at: "desc" },
});

locale を省略すると、EmDashはリクエストのロケールを使い、次に設定済みの既定のロケールを使います。フォールバックのルールと翻訳のルートについては、国際化を参照します。

status には、"published""draft""archived" のいずれかを指定できます。公開用のルートから下書きを取得しないでください。訪問者が1件の未公開のエントリーに一時的にアクセスする必要がある場合は、プレビューの流れを使います。

データベースでの並べ替え

orderBy では、フィールド名に "asc" または "desc" を対応付けます。entry.data で返されるキャメルケースの名前ではなく、保存されているフィールド名を使います。

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

システムの列には、created_atupdated_atpublished_at のようなデータベース上の名前を使います。独自のフィールドには、titlepriority のような、コレクションでのスラッグを使います。返されるデータでは、システムの日付は createdAtupdatedAtpublishedAt に対応付けられますが、これらのキャメルケースのプロパティ名は orderBy のフィールドとしては使えません。

EmDashは、最初の有効な orderBy のフィールドをページ分割のキーとして使い、順位が同じ場合の安定した判定にコンテンツのIDを使います。orderBy を指定しない場合、コレクションは既定で created_at の降順になります。サイトで決まって並べ替えや絞り込みに使う独自のスカラーフィールドには、インデックスを設定します。インデックスがあると、コレクションが大きくなってもテーブル全体の走査を避けられます。

本サイトの補足 やさしい解説

WordPressの WP_Queryorderby と同じく、並び順を指定できます。注意する点は、並べ替えに使う名前が、取得結果のプロパティ名(publishedAt)ではなく、データベース上の名前(published_at)であることです。何も指定しないと、作成日時の新しい順になります。並べ替えはデータベースの中で行われるため、件数が多くても全件を読み込む必要はありません。

コレクションのページ分割

続けて読み込むフィードにはカーソルを、番号付きのページにはオフセットを使います。この2つは別々のページ分割の方式で、1つの型付きのクエリの中で組み合わせることはできません。

カーソルによるページ分割

カーソルによるページ分割では、前のクエリが返した最後のエントリーの続きから取得します。リクエストの間で並べ替えの指定を変えず、nextCursor は中身を調べたり変えたりせずにそのまま渡し直します。

次のルートは、次のページがある場合に「Older posts」のリンクを描画します。

src/pages/posts/index.astro
---
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 を使います。

src/pages/posts/page/[page].astro
---
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>

オフセットは0以上の整数にする必要があります。1ページ目のオフセットは0で、「最初のエントリーから始める」という意味です。オフセットによるページ分割はページ番号で指定しやすい一方、リクエストの間にエントリーが追加されると、後ろのページの内容がずれることがあります。そのずれが分かりにくくなる場合は、カーソルを使います。

本サイトの補足 やさしい解説

WordPressでいうと、オフセットによるページ分割は /page/2/ のような番号付きのページ送りです。ページ番号を指定しやすい反面、読んでいる間に新しい記事が公開されると、次のページに同じ記事がもう一度出るなど、内容がずれることがあります。カーソルによるページ分割は「前回の最後の記事の続きから」取得するため、このずれが起きません。その代わり、総ページ数は分からず、「Older posts」のような「続きを読む」形のリンクになります。

1件のエントリーの取得と描画

次の実行時のルートは、スラッグで投稿を読み込み、アイキャッチ画像とPortable Textの本文を描画します。また、クエリの失敗とエントリーが存在しない場合を区別します。

src/pages/posts/[slug].astro
---
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)" で、公開された表の内容には影響しません。

SEOと編集の機能の適用

SEOに対応しているコレクションでは、getSeoMeta() が、編集者が設定したSEOのタイトル、説明、画像、正規URL、インデックスさせない設定を、エントリーの値を代わりに使いながら解決します。現在のブログテンプレートは、その結果をベースのレイアウトに渡しています。

---
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.titledata.excerpt だけを読んで手で書いたmetaタグでは、編集者が設定した正規URLやインデックスさせない設定は適用されません。

プレビュー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トークンまたは独自の認証ヘッダーを指定できます。これらのオプションについては、型の生成を参照します。

実行時の描画とキャッシュ

Node.jsとCloudflareのブログテンプレートは、astro.config.mjsoutput: "server" を設定しています。コンテンツのクエリはサーバーで描画するたびに実行されるため、新しく公開したリビジョンは次のリクエストから表示の対象になります。ルートを意図的に事前に描画する場合、そのHTMLにはビルド時点のコンテンツが入り、次にビルドするまで変わりません。

Astroのキャッシュを有効にしている場合は、クエリの cacheHintAstro.cache.set() に渡します。EmDashはレスポンスにコレクションとエントリーのタグを関連付けるため、公開したときに、影響を受けるキャッシュ済みのページを無効にできます。更新の遅れを製品として明示的に許容する場合を除き、この連携を、長い固定の Cache-Control の有効期間で置き換えないようにします。

本サイトの補足 やさしい解説

キャッシュを使うと、ページを毎回作り直さずに済む一方で、古い内容が表示される心配があります。EmDashでは、cacheHint をAstroのキャッシュに渡しておくと、記事を公開したときに、その記事に関係するキャッシュだけが無効になります。そのため、キャッシュを使いながらも、公開した内容が次のリクエストから表示されます。Cache-Control で長い有効期間を固定すると、この仕組みが働かず、更新が遅れて表示されます。

正確な関数のシグネチャや、使う機会の少ない絞り込みについては、JavaScript APIリファレンスを参照します。これらのクエリを使った動作する例を作るには、ブログを作るに進みます。