Skip to content
Cloud AI エンジニア入門ガイド
Go back

06. Astro コンテンツコレクションで記事を管理する

この章の全体像として、何のためか、定義する、取り出す、型を更新するの4つを番号順に並べ、記事を足すだけでページが増える形を作れるようになることを示した図

シリーズ目次前章: 05. Astroのファイルベースルーティング次章: 07. AstroでMarkdownとMDXを使いこなす

Table of contents

Open Table of contents

この章のねらい

Markdownで書いた記事が10本、50本と増えると、必ずこうなります。あの記事だけ pubDate の綴りを間違えていた。タグを文字列で書いた記事と配列で書いた記事が混ざっている。

コンテンツコレクション(Content Collections)は、この手の事故をビルド時に見つける仕組みです。Astroがコンテンツ中心を掲げる理由が、いちばん分かりやすく出ている機能でもあります。

コンテンツコレクションとは

コンテンツコレクションは、関連するコンテンツをひとまとまりとして定義し、その形式を検証しながら型安全に取り出せるようにする機能です。Astro 2.0で導入され、その後「コンテンツレイヤー」として拡張され、Markdownファイルに限らず外部APIやCMSのデータも同じ仕組みで扱えるようになりました。

全体の流れは次のようになります。

Markdownファイルがローダーで読み込まれ、スキーマ検証を経て型付きエントリになり、getCollectionとrenderを通じてページとして出力されるまでの流れを示した図

検証に失敗するとビルドが止まり、どのファイルのどの項目が問題かを教えてくれます。公開する前に止まる。この一点に尽きます。

コレクションを定義する

定義は src/content.config.ts に書きます(.js でも構いません)。

// src/content.config.ts
import { defineCollection } from "astro:content";
import { glob } from "astro/loaders";
import { z } from "astro/zod";

const blog = defineCollection({
  loader: glob({ base: "./src/content/blog", pattern: "**/*.{md,mdx}" }),
  schema: z.object({
    title: z.string(),
    description: z.string(),
    pubDate: z.coerce.date(),
    tags: z.array(z.string()).default([]),
    draft: z.boolean().optional(),
  }),
});

export const collections = { blog };

書くのは2つだけです。

最後に collections としてエクスポートしたオブジェクトのキー(この例では blog)が、コレクション名になります。

ローダーの種類

ローダー用途
glob()指定ディレクトリ内の複数ファイル(Markdown、JSON、YAML、TOMLなど)を読み込む
file()1つのファイルから複数のエントリを取り出す(例:authors.json に著者を配列で持つ)
カスタムローダー外部APIやヘッドレスCMSからデータを取得する

glob()pattern はglobパターンで指定します。**/*.md なら、サブディレクトリを含むすべてのMarkdownファイルが対象です。

用語: globパターンとは、*** といった記号でファイルのまとまりを表す書き方です。* は同じ階層の任意の名前、** は任意の深さのディレクトリを表します。

スキーマとZod

schema にはZod(ゾッド)というライブラリの記法を使います。Astroに同梱されているため、追加インストールは不要です。

用語: Zodは、データの形を宣言的に書いて検証できるライブラリです。「titleは文字列」「pubDateは日付」といった宣言から、検証処理とTypeScriptの型の両方が自動的に得られます。

よく使うのは次のあたりです。

記法意味
z.string()文字列であること
z.number()数値であること
z.boolean()真偽値であること
z.coerce.date()日付に変換できること(YAMLの日付や文字列を受け取れる)
z.array(z.string())文字列の配列であること
z.enum(["tech", "life"])指定した値のいずれかであること
.optional()省略してよい
.default(値)省略時に既定値を入れる
.max(60)文字数などの上限を設ける

たとえば description.max(160) を付けておけば、検索結果に表示しきれない長すぎる説明文をビルド時に弾けます。

スキーマを満たす場合と満たさない場合で、その後の扱いが分かれます。

スキーマを満たすフロントマターは型の付いたエントリとして取り出せるのに対し、項目が欠けていたり型が合わないフロントマターはビルド時にエラーとして弾かれることを二つの経路で示した図

コレクション間の参照

記事と著者のように、別々のコレクションを関連づけたい場合は reference() を使います。

import { defineCollection, reference } from "astro:content";

const blog = defineCollection({
  loader: glob({ base: "./src/content/blog", pattern: "**/*.md" }),
  schema: z.object({
    title: z.string(),
    author: reference("authors"),
    relatedPosts: z.array(reference("blog")).optional(),
  }),
});

存在しないIDを指定するとビルド時にエラーになるため、リンク切れを防げます。

コンテンツを取り出す

定義したコレクションは、astro:content からの関数で取得します。

一覧を取得する:getCollection()

---
import { getCollection } from "astro:content";

const posts = await getCollection("blog");

// 下書きを除外して新しい順に並べる
const published = posts
  .filter(post => !post.data.draft)
  .sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime());
---

<ul>
  {published.map(post => (
    <li>
      <a href={`/blog/${post.id}/`}>{post.data.title}</a>
      <time>{post.data.pubDate.toLocaleDateString("ja-JP")}</time>
    </li>
  ))}
</ul>

各エントリは、次のような形をしています。

プロパティ内容
idエントリを一意に識別する文字列(ファイルパス由来)
data検証済みのフロントマター(スキーマどおりの型が付く)
body本文の生テキスト
collectionコレクション名

post.data.pubDateDate 型として扱えるのは、スキーマで z.coerce.date() と宣言したからです。エディタの補完もここまで効きます。

getCollection() の第2引数にフィルタ関数を渡す書き方もできます。

const published = await getCollection("blog", ({ data }) => !data.draft);

単一エントリを取得する:getEntry()

---
import { getEntry } from "astro:content";

const post = await getEntry("blog", "hello-world");
---

本文をHTMLにする:render()

Markdown本文をHTMLとして描画するには render() を使います。

---
import { getEntry, render } from "astro:content";

const post = await getEntry("blog", "hello-world");

if (!post) {
  return Astro.redirect("/404");
}

const { Content, headings } = await render(post);
---

<h1>{post.data.title}</h1>
<Content />

render() は本文コンポーネント(Content)に加えて、見出し一覧(headings)も返します。これを使えば、目次を自前で組み立てられます。

3つの関数の役割を並べると、次のようになります。

コレクションから一覧を取り出すgetCollectionと1件を取り出すgetEntry、取り出したエントリの本文をHTMLにするrenderが、それぞれ何を返し何に使うのかを左から右への流れで示した図

記事ページを生成する

一覧と詳細をつなぐ定番の書き方です。動的ルートと組み合わせます。

---
// src/pages/blog/[...slug].astro
import { getCollection, render } from "astro:content";
import BlogPostLayout from "../../layouts/BlogPostLayout.astro";

export async function getStaticPaths() {
  const posts = await getCollection("blog", ({ data }) => !data.draft);

  return posts.map(post => ({
    params: { slug: post.id },
    props: { post },
  }));
}

const { post } = Astro.props;
const { Content } = await render(post);
---

<BlogPostLayout title={post.data.title} pubDate={post.data.pubDate}>
  <Content />
</BlogPostLayout>

ここまでで、「Markdownを1本追加すればページが1つ増える」状態が完成します。オンデマンドレンダリングを使う場合は getStaticPaths() の代わりに getEntry() でリクエスト時に取得します(09. Astroのレンダリングモードとアダプター参照)。

ライブコレクション

Astro 5以降には、リクエストのたびに最新データを取りに行く「ライブコレクション」もあります。src/live.config.ts で定義し、getLiveCollection() / getLiveEntry() で取得します。

在庫数や価格のように、ビルド時点の値では古くなるデータに向いています。記事のようにビルド時に確定するものなら、通常のコレクションのほうが速いのでそちらを使ってください。

両者の違いは次のとおりです。

通常のコレクションはビルド時にデータが確定して高速に配信されるのに対し、ライブコレクションはリクエストのたびに最新データを取りに行くという違いを、定義ファイル・取得タイミング・取得関数・向くデータの4点で比較した図

型定義を更新する

コレクションの定義を変更した直後は、エディタの型が古いままになることがあります。その場合は次を実行してください。

npx astro sync

astro:content の型定義が再生成され、補完が最新の状態になります。

要点

参考資料


シリーズ目次前章: 05. Astroのファイルベースルーティング次章: 07. AstroでMarkdownとMDXを使いこなす


Share this post:

Previous Post
07. AstroでMarkdownとMDXを使いこなす
Next Post
05. Astroのファイルベースルーティング