シリーズ目次 / 前章: 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のデータも同じ仕組みで扱えるようになりました。
全体の流れは次のようになります。
検証に失敗するとビルドが止まり、どのファイルのどの項目が問題かを教えてくれます。公開する前に止まる。この一点に尽きます。
コレクションを定義する
定義は 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つだけです。
loader: どこからデータを読み込むかschema: 読み込んだデータがどんな形をしているべきか
最後に 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.pubDate が Date 型として扱えるのは、スキーマで 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つの関数の役割を並べると、次のようになります。
記事ページを生成する
一覧と詳細をつなぐ定番の書き方です。動的ルートと組み合わせます。
---
// 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() で取得します。
在庫数や価格のように、ビルド時点の値では古くなるデータに向いています。記事のようにビルド時に確定するものなら、通常のコレクションのほうが速いのでそちらを使ってください。
両者の違いは次のとおりです。
型定義を更新する
コレクションの定義を変更した直後は、エディタの型が古いままになることがあります。その場合は次を実行してください。
npx astro sync
astro:content の型定義が再生成され、補完が最新の状態になります。
要点
- コンテンツコレクションは、コンテンツの形式をビルド時に検証し、型安全に扱うための仕組みである。
- 定義は
src/content.config.tsに書き、loader(どこから)とschema(どんな形か)を指定する。 - スキーマはZodで書き、
.optional()や.default()で省略可否と既定値を表現できる。 getCollection()/getEntry()で取得し、render()で本文をHTML化する。- 定義を変えたら
astro syncで型定義を更新する。
参考資料
- Content Collections — Astro Docs
- Content Collections API Reference — Astro Docs
- CLI Reference — Astro Docs
シリーズ目次 / 前章: 05. Astroのファイルベースルーティング / 次章: 07. AstroでMarkdownとMDXを使いこなす