AstroをCMS化してブログ記事を効率よく管理する方法

ブログ記事が増えてくると、管理が大変になりますよね。Astroの「Content Collections API」を使えば、記事を `src/content` ディレクトリにまとめて、安全かつ効率的に管理できます。ここではその基本的な手順を紹介します。

## Content Collections を使うメリット

* **型安全性:** 記事のフロントマター（タイトル、日付など）の型を定義でき、間違いを防げます。
* **管理のしやすさ:** 記事データを `src/pages` から分離し、`src/content` に集約できます。
* **URLの柔軟性**: ファイルパスに縛られず、記事ごとに任意のURLを設定できます。
* **強力なAPI:** 記事データの取得や処理が簡単になります。

## 基本的な手順

### 1.コレクションの設定 (`src/content/config.ts`)

まず、どのような記事データ（コレクション）を管理するか定義します。`src/content/` ディレクトリ（なければ作成）に `config.ts` というファイルを作成し、以下のように記述します。

```typescript
// src/content/config.ts
import { defineCollection, z } from 'astro:content';

// 'blog' という名前のコレクションを定義
const blogCollection = defineCollection({
  type: 'content', // Markdown や MDX ファイルの場合
  schema: z.object({ // フロントマターの型定義 (Zodを使用)
    title: z.string(),
    pubDate: z.coerce.date(), // 文字列を日付型に変換
    description: z.string().optional(), // オプショナルな項目
    tags: z.array(z.string()).optional(), // 文字列の配列 (オプショナル)
    url: z.string().optional(), // ← 追加: カスタムURL用のフィールド (オプショナル)
  }),
});

// 定義したコレクションをエクスポート
export const collections = {
  'blog': blogCollection,
};
```

* `defineCollection` で `blog` という名前のコレクションを定義します。
* `schema` で、各記事のMarkdownファイルに記述するフロントマターの項目（`title` や `pubDate` など）とその型を `zod` を使って指定します。これにより、型が間違っているとエラーで教えてくれます。
* `url: z.string().optional()`はoptional() なので、フロントマターに url を書かなくてもエラーにはなりません。

### 2.記事ファイルの配置 (`src/content/blog/`)

次に、Markdown記事ファイルを配置する場所を作成します。`src/content/` ディレクトリ内に、コレクション名と同じ名前のディレクトリ（この例では `blog`）を作成します。

```
your-project/
└── src/
    ├── content/
    │   ├── config.ts      <-- ステップ1で作成
    │   └── blog/          <-- コレクション名のディレクトリを作成
    │       └── my-first-post.md <-- ここに記事Markdownを置く
    │       └── another-post.md
    └── pages/
    └── layouts/
```

`src/content/blog/` ディレクトリ内に、Markdownファイル（例: `my-first-post.md`）を作成または移動します。この際、ファイルのフロントマターは、ステップ1で定義した `schema` に従っている必要があります。

```markdown
---
# src/content/blog/my-first-post.md
title: '私の最初の投稿 (Content Collections版)'
pubDate: 2025-04-14
description: 'Content Collections を使って記事を追加しました。'
tags: ["Astro", "Blog"]
url: my-first-post
---

Content Collections を使うと、記事管理が楽になりますね。
```

**注意:** `src/content` 内のMarkdownファイルでは、以前の方法で使った `layout:` フロントマターは **使用できません**。レイアウトは後述するページコンポーネント側で適用します。

### 3.記事一覧ページの作成 (`src/pages/blog/index.astro`)

`blog` コレクションの記事一覧を表示するページを作成します。`src/pages/blog/` ディレクトリ（なければ作成）に `index.astro` ファイルを作成します。

```astro
---
// src/pages/blog/index.astro
import { getCollection } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro'; // 例: 基本レイアウト

const posts = await getCollection('blog');
posts.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());

// URLを決定するヘルパー関数 (例)
function getPostUrl(post) {
  if (post.data.url) {
    // フロントマターにurlがあれば、ルートからのパスを返す
    return `/${post.data.url}/`;
  }
  // なければファイルパスに基づくデフォルトのパスを返す
  // post.slug は 'html/html-custom' のようになる
  return `/blog/${post.slug}/`;
}
---
<BaseLayout title="ブログ一覧">
  <h1>ブログ記事一覧</h1>
  <ul>
    {posts.map((post) => (
      <li>
        {/* getPostUrlヘルパーを使ってリンク先を決定 */}
        <a href={getPostUrl(post)}>{post.data.title}</a>
        <p>公開日: {post.data.pubDate.toLocaleDateString()}</p>
        {post.data.description && <p>{post.data.description}</p>}
      </li>
    ))}
  </ul>
</BaseLayout>
```

* `getCollection('blog')` で `blog` コレクションの記事データをすべて取得します。
* 取得した `posts` は配列なので、`map` で各記事の情報を取り出し、リスト表示します。
* フロントマターのデータには `post.data.title` のようにアクセスします。
* 各記事へのリンクURLは `post.slug` (ファイル名から自動生成されるURLフレンドリーなID) を使って `/blog/${post.slug}/` のように生成します。
* レイアウトは通常のAstroコンポーネントとして `<BaseLayout>` のように適用します。

### 4.個別記事ページの作成 (`src/pages/[...slug].astro`)

個別記事を表示するページを src/pages/ ディレクトリの 直下 に作成し、ファイル名を`[...slug].astro`とします。Rest parameters`(...slug)`を使うことで、`/my-first-post` のようなシンプルなURLと`/blog/html/html-custom`のような階層のあるURLの両方をこのファイルで処理できます。

```astro
---
// src/pages/[...slug].astro (新規作成、src/pages/blog/[id].astro は削除)
import { getCollection, getEntry } from 'astro:content';
import BaseLayout from '../layouts/BaseLayout.astro'; // レイアウトへのパスが変わることに注意

export async function getStaticPaths() {
  const posts = await getCollection('blog');
  return posts.map((post) => {
    // params.slug に設定する値を決定
    let slugPath;
    if (post.data.url) {
      // フロントマターのurlがあれば、それをパスとする
      slugPath = post.data.url;
    } else {
      // なければファイルパスに基づくパス (例: 'blog/html/html-custom')
      // post.id は 'blog/html/html-custom.md' のようなので、
      // ディレクトリ部分 + ファイル名(拡張子なし) を使う
      slugPath = post.id.replace(/\.md$/, ''); // .mdを除去
    }

    return {
      // params.slug は Astro.params.slug でアクセスでき、
      // URL の '/.../' の部分の文字列になる
      params: { slug: slugPath },
      props: { post }, // 記事データを渡す
    };
  });
}

const { post } = Astro.props;
const { Content } = await post.render();
---
<BaseLayout title={post.data.title}>
  <article>
    <h1>{post.data.title}</h1>
    <p>公開日: {post.data.pubDate.toLocaleDateString()}</p>
    {post.data.tags && (
      <p>タグ: {post.data.tags.join(', ')}</p>
    )}
    <hr />
    <Content />
  </article>
</BaseLayout>
```

* `getStaticPaths` 関数は必須です。ここで `getCollection('blog')` を使って全記事を取得し、それぞれの記事に対して `params` (URLの一部になる) と `props` (ページコンポーネントに渡すデータ) を含むオブジェクトの配列を返します。
* ページコンポーネントのスクリプト部分 (`---` の中) で `Astro.props` から `post` データを受け取ります。
* `post.render()` を実行すると、Markdown本文をレンダリングするための `{ Content }` コンポーネントが得られます。
* HTMLテンプレート部分で `<Content />` を使うと、Markdownで書かれた本文が表示されます。
* * ここでもレイアウトは `<BaseLayout>` のように適用します。

### 5.開発サーバーで確認

ターミナルで開発サーバーを起動します。

```bash
npm run dev
# または pnpm dev, yarn dev
```

ブラウザで以下のURLにアクセスして確認してみましょう。

* **記事一覧ページ:** `http://localhost:4321/blog/`
* **個別記事ページ:** `http://localhost:4321/my-first-post/` (記事のファイル名が `my-first-post.md` の場合)

一覧ページが表示され、各記事へのリンクが正しく機能し、個別記事ページでMarkdownの内容とフロントマターの情報が表示されていれば成功です！

## まとめ
Content Collections APIを使うと、最初は設定が必要ですが、記事が増えても構造的に管理でき、型の恩恵も受けられるため、本格的なブログ運営には非常に便利です。ぜひ活用してみてください。