Astro / microCMSで作成したブログにカテゴリ機能を実装した話

# はじめに
はじめまして！今年から[株式会社アンティー・ファクトリー](https://www.un-t.com/)に入社しました[ウッディー](https://qiita.com/ueno-unt)と申します。12月から開催する[un-T factory! XA Advent Calendar 2024](https://qiita.com/advent-calendar/2024/unt-xa)、その初日を担当します。

案件を通してJamstackの手法でブログページを作成することになり、Jamstackアーキテクチャの概念から学習を始め、その素晴らしさを目の当たりにしました。自身の備忘録として記事に残すとともに、意外にもAstro / microCMSでのブログ記事作成フローがなかったのでまとめました！

# こんな方に見てほしい！
- Jamstackはある程度理解し、実際にサイトを作ってみようと考えている方
- Astro / microCMSでJamstackなブログサイトを作成したい方
- 「すべての記事」タグを含むカテゴリ別一覧ページを作成したい方

# 前提
- Jamstackについては以下サイトを参考に学習しました。
参考:[Jamstackとは何か？非エンジニアにもわかりやすく基本を解説！](https://blog.microcms.io/jamstack-introduction/)
↑ "JAMstack"から"Jamstack"に変わった歴史も興味深いですよね🤔

# 完成イメージ
![qiita-image_01.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3828182/c5af6beb-9b91-0a41-18b8-600ed4813985.jpeg)

# 経緯
静的サイトジェネレーターとしてAstroを選択した理由は以下の通りです。

- **異なるフレームワークの統合がしやすい**
Astroは異なるフレームワークを簡単に統合できます。
- **軽量で高速なWebサイト生成が可能**
Astroの「Island Architecture」によって、コンポーネント単位でフレームワークとの組み合わせが可能、結果パフォーマンス向上につながります。今回の記事では明記していませんがプレビュー機能実装にはPreactを使用しています。
参考: [Astroアイランド | Docs](https://docs.astro.build/ja/concepts/islands/)
- **開発体験の向上**
シンプルな構文と柔軟性のある環境

# 実際にブログページを作る
ここからは順を追って説明します。

## API設計と呼び出し
APIはmicroCMS側で準備します。公式サイトを参考にしました。

参考: [AstroとmicroCMSでつくるブログサイト](https://blog.microcms.io/astro-microcms-introduction/)

APIスキーマを作成するコツとして、ネストが深くなりすぎないように心がけましょう。
ネストが深くなると型定義の際に取り回しづらくなるといった複雑性が生じます。**必要最低限のAPI設計**が後のサイト実装の快適性につながるのです！また各フィールドIDについても、**一目でそれがどのようなデータを持つのか理解できる名前をつける**ことが大切です。

今回は「ブログ記事」「カテゴリ」の情報を呼び出したため型定義は以下のようになります。
```microcms.ts
// 「カテゴリ」情報のレスポンスデータ型
export type CategoryResponse = {
  totalCount: number;
  offset: number;
  limit: number;
  contents: Category[]; // 個々のカテゴリ情報の配列
};
// 1つのカテゴリが持つデータ型を定義
export type Category = {
  id: string;
  name: string;
  slug: string;
};

// 「ブログ」情報のレスポンスデータ型
export type BlogResponse = {
  totalCount: number;
  offset: number;
  limit: number;
  contents: BlogType[]; // 個々のブログ情報の配列
};
// 1つのブログが持つデータ型を定義
export type BlogType = {
  id: string;
  info: {
    fieldId: string;
    title: string;
    eyeCatch: {
      url: string;
      width: number;
      height: number;
    };
    date: string;
    url: string;
    overview: string;
  };
  richEditor: string;
  category: Category;
};
```

型定義に加えてブログとカテゴリの全体のデータを非同期で取得する関数、ブログ詳細を取得する関数を実装します。  

```microcms.ts
import type { MicroCMSQueries } from "microcms-js-sdk";

// ブログを取得する関数
export const getBlog = async (queries?: MicroCMSQueries) => {
  return await client.get<BlogResponse>({ endpoint: "blog", queries });
};

// カテゴリを取得する関数
export const getCategory = async (queries?: MicroCMSQueries) => {
  return await client.get<CategoryResponse>({
    endpoint: "categories",
    queries,
  });
};

// ブログの詳細を取得する関数
export const getBlogDetail = async (
  contentId: string,
  queries?: MicroCMSQueries,
) => {
  return await client.getListDetail<BlogType>({
    endpoint: "blog",
    contentId,
    queries,
  });
};
```

ここまでは参考サイトにも記載があるので合わせてご確認ください。

## カテゴリページの作成
今回の課題である「すべての記事を表示する"ALL"カテゴリを含むカテゴリ一覧ページ」の作成です。「すべてのカテゴリ」と「各種カテゴリ」に基づくページはデータ取得処理が異なります。  
以下のようなディレクトリ構成となります。
```
src/
└── pages/
    ├── [categorySlug]/
    │   └── [blogId].astro ← 記事詳細ページ
    └── category/
    │     └── [categoryId].astro ← カテゴリ別記事一覧ページ
    └── works/
        └── index.astro ← 記事一覧ページ（"ALL"）
```
"ALL"の一覧ページを"/works"、カテゴリ別一覧ページを"/category/[categoryId]"とすることで、データに応じた動的ページと独立させています。

### 記事詳細ページ
ディレクトリ構成に基づいたパス生成とデータ取得が必要です。
```ts:[categorySlug]/[blogId].astro
---
import { getBlog, getBlogDetail } from "@/libs/microcms";

export const getStaticPaths = async () => {
  const blogs = await getBlog();
  return blogs.contents.map((blog) => ({
    params: { 
      blogId: blog.id,
      categorySlug: blog.category.slug
    },
  }));
};

const { blogId } = Astro.params;

// 記事詳細を取得
const blog = await getBlogDetail(blogId as string);
---
```
getStaticPathsにより動的なパスパラメータを定義します。
- **blogId**: 記事の一意なID
- **categorySlug**: 記事の所属カテゴリを示すスラッグ

そしてAstro.paramsでblogIdを元に特定の記事データを取得・表示します。
```ts:[categorySlug]/[blogId].astro
// タイトルが欲しい場合
<h1 class="title">{blog.info.title}</h1>
```

### 全記事一覧ページ（"ALL"）
すべての記事一覧ページでは記事のデータ取得時、動的ルーティングに依存したデータ参照は不要です。よってmicrocms.tsで実装した関数がそのまま利用できます。
```ts:works/index.astro
---
import { getBlog } from "@/libs/microcms";
const responseInfo = await getBlog();
---

// 使用例
<ul>
  {
    responseInfo.contents.map((content: any) => (
      <li>
        <a href={`/${content.category.slug}/${content.id}`} >
          <h2>{content.info.title}</h2>
          <span>{content.category.name}</span>
        </a>
      </li>
    ))
  }
</ul>
```

### カテゴリ別一覧ページ
```ts:category/[categoryId].astro
---
import { getBlog, getCategory } from "@/libs/microcms";

export async function getStaticPaths() {
  const response = await getCategory({ fields: ["id"] });
  return response.contents.map((content) => ({
    params: { categoryId: content.id },
  }));
}

const { categoryId } = Astro.params;

const blogs = await getBlog({
  filters: `category[equals]${categoryId}`,
});
---

// 使用例
<ul>
  {
    blogs.contents.map((content: any) => (
      <li>
        <a href={`/${content.category.slug}/${content.id}`} >
          <h2>{content.info.title}</h2>
          <span>{content.category.name}</span>
        </a>
      </li>
    ))
  }
</ul>
```

カテゴリ別にページを分ける必要があるため、ここでは各カテゴリがもつ一意なID（categoryId）を取得してパスを定義します。
```ts:
const blogs = await getBlog({
  filters: `category[equals]${categoryId}`,
});
```
他のページと異なるのは上記コードのfiltersパラメータです。このパラメータは特定の条件で絞りこみを行いたい場合に使用します。公開状態にあるコンテンツのみ指定可能、パラメータによって使用できる条件が異なるなど、いくつか制約があります。
参考：[filters: コンテンツを絞り込んで取得できるようになりました](https://blog.microcms.io/filters-parameter/)

今回の場合だとcategoryIdという単一の値を参照するため[equals]を使用しています。

### カテゴリ遷移ボタン
記事一覧ページに他のカテゴリページに遷移させるタブを実装しました。

```ts:CategoryTab.astro
---
import { getCategory } from "@/libs/microcms";
const responseCategory = await getCategory({ fields: ["id", "name", "slug"] });

const currentPath = Astro.url.pathname;
---

<ul>
  <li>
    <a href="/works" class="category-tag__link" id="category-all">ALL</a>
  </li>
  {
    responseCategory.contents.map((content) => {
      const isActive = `/category/${content.id}` === currentPath;
      return (
        <li>
          <a
            href={`/category/${content.id}`}
            class={`${isActive ? "is-active" : ""}`}
          >
            {content.name}
          </a>
        </li>
      );
    })
  }
</ul>
```
作成したカテゴリデータ分のリンクボタンが必要なので、ループ処理を行います。  
また、カレントページでボタンのスタイルを変更するため、Astroが提供する'Astro.url.pathname'というグローバルオブジェクトを用いて現在のURLパスとカテゴリURLが一致するか判定、一致した場合に"category-tag__link--active"というクラス名の付与が適用されます。
参考: [Astro.url](https://docs.astro.build/ja/reference/api-reference/#astrourl)

# まとめ
microCMSで作ったブログにカテゴリ機能を実装するにあたり、参考サイトのほとんどがAstroではなくNext.jsをフレームワークとして採用しているものばかりでした。加えて"ALL"カテゴリを含む遷移ボタンコンポーネントの実装まで説明のあるサイトはほとんどなく、この記事をもって同じ悩みに直面した方の一助を担えたら嬉しいです！

# 参考
- [microCMSにカテゴリAPIを作成しカテゴリ一覧ページとカテゴリ一覧ボタンのサイドバーを表示する](https://841-biboroku.info/entry/add-category-list/)
- [Next.jsでカテゴリー機能を実装してみよう](https://blog.microcms.io/next-category-page/)