Astro Content Loader APIを使って外部データをコンテンツコレクションとして扱えるように

Astro

Astro 5.0以降でアップデートされたContent collectionsを使用して、ローカルのmdファイルに加え、microCMSのデータも同じようにコンテンツとして扱えるようにしました

定義ファイルの作成

コンテンツコレクションを定義するためには、src/content.config.tsファイルに設定を記述していく必要があります

以下の例では、glob()ローダーを使用して、ローカルに配置されているmdファイルをコンテンツコレクションとして読み込んでいます

import { defineCollection } from "astro:content";
import { glob } from "astro/loaders";
import { z } from "astro/zod";

// スキーマを定義
const blogSchema = z.object({
  title: z.string(),
  description: z.string().optional(),
  date: z.coerce.date(),
  updatedDate: z.coerce.date().optional(),
  tags: z.array(z.string()).default([]),
  draft: z.boolean().default(false),
});

// ローカルのmdファイルを読み込む
const blog = defineCollection({
  loader: glob({ pattern: "**/*.md", base: "./src/content/blog" }),
  schema: blogSchema,
});

microCMS用のローダーを作成する

外部のデータを読み込む場合は、独自のカスタムローダーを作成する必要があります。

Astroでは、独自ローダー用のローダーオブジェクトが用意されているので、そちらを使ってローダーを作成していきます。

基本的にローダーは、エンドポイントなどの設定情報を受け取り、ローダーオブジェクトを返す関数をエクスポートします。

ローダーオブジェクトは、nameプロパティとload()メソッドを含んでいる必要があります。

import type { Loader } from "astro/loaders";
import { createClient } from "microcms-js-sdk";
import type { MicroCMSBlog } from './type'

export function microcmsLoader(options: {
  endpoint: string;
}): Loader {
  return {
    name: "microcms-blog-loader",
    async load({ store, logger }) {
      const serviceDomain = import.meta.env.MICROCMS_SERVICE_DOMAIN;
      const apiKey = import.meta.env.MICROCMS_API_KEY;

      if (!serviceDomain || !apiKey) {
        logger.warn(
          "MICROCMS_SERVICE_DOMAIN or MICROCMS_API_KEY is not set. Skipping microCMS blog loading.",
        );
        return;
      }

      const client = createClient({ serviceDomain, apiKey });

      let response: MicroCMSBlog[];
      try {
        response = await client.getAllContents<MicroCMSBlog>({
          endpoint: options.endpoint,
        });
      } catch (e) {
        logger.warn(
          `Failed to fetch from microCMS endpoint "${options.endpoint}". Skipping. (${e instanceof Error ? e.message : e})`,
        );
        return;
      }

      store.clear();

      for (const item of response) {
        store.set({
          id: item.id,
          data: {
            title: item.title,
            body: item.body,
            date: new Date(item.publishedAt || item.createdAt),
            updatedDate: item.updatedAt
              ? new Date(item.updatedAt)
              : undefined,
            tags: item.tags?.map((t) => t.name) ?? [],
            eyecatch: item.eyecatch,
          },
        });
      }

      logger.info(
        `Loaded ${response.length} blog posts from microCMS`,
      );
    },
  };
}
  • load()関数では、コンテンツがどのように取得、解析、検証、更新されるかを定義していきます。
  • この関数は、ビルド時に実行され、さまざまな方法でデータ処理をカスタマイズし、データストアとやり取りできるコンテキストオブジェクトを受け取ります。
  • 今回の例で使用しているコンテキストオブジェクトに含まれるプロパティは以下になります
    • store:実際のデータを保存するデータベース。これを使用して、microCMSのデータをエントリとして保存する
    • logger:コンソールにメッセージをログ出力するために使用できるロガー

定義ファイルでローダーを読み込む

作成したカスタムローダーを定義ファイルで使用します

import { microcmsLoader } from "./lib/microcms-loader";

// microCMS ブログ記事
const blogCms = defineCollection({
  loader: microcmsLoader({ endpoint: "blogs" }),
  schema: blogSchema
});

ブログページでコレクションを取得する

今回の場合はmdファイル、microCMSデータどちらとも同じブログページで使用したいので、どちらも読み込んで結合する処理をしていきます

ブログ一覧ページ

---
import type { GetStaticPaths } from "astro";
import Layout from "../../layouts/Layout.astro";
import { getCollection } from "astro:content";

export const getStaticPaths = (async ({ paginate }) => {
  const localPosts = (await getCollection("blog", ({ data }) => !data.draft))
    .map((post) => ({
      slug: post.id,
      source: "local" as const,
      data: post.data,
    }));

  const cmsPosts = (await getCollection("blogCms"))
    .map((post) => ({
      slug: post.id,
      source: "cms" as const,
      data: post.data,
    }));

  const posts = [...localPosts, ...cmsPosts]
    .sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());

  return paginate(posts, { pageSize: 12 });
}) satisfies GetStaticPaths;

const { page } = Astro.props;
const posts = page.data;
---

<Layou>
  <h1>Blog</h1>

  <div>
    {posts.map((post) => {
      return (
        <a href={`/blog/${post.slug}`}>
          <div>
            <h2>
              {post.data.title}
            </h2>
            <div>
              {post.data.tags.length > 0 && (
                <div>
                  {post.data.tags.map((tag) => (
                    <span>
                      {tag}
                    </span>
                  ))}
                </div>
              )}
            </div>
          </div>
        </a>
      );
    })}
  </div>

  {page.lastPage > 1 && (
    <nav>
      {page.url.prev ? (
        <a
          href={page.url.prev}
        >
          ← Prev
        </a>
      ) : (
        <span>← Prev</span>
      )}

      {Array.from({ length: page.lastPage }, (_, i) => i + 1).map((num) => (
        <a
          href={num === 1 ? "/blog" : `/blog/${num}`}
        >
          {num}
        </a>
      ))}

      {page.url.next ? (
        <a
          href={page.url.next}
        >
          Next →
        </a>
      ) : (
        <span>Next →</span>
      )}
    </nav>
  )}
</Layout>

ブログページ

---
import Layout from "../../layouts/Layout.astro";
import { getCollection, render } from "astro:content";

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

  return [
    ...localPosts.map((post) => ({
      params: { slug: post.id },
      props: { post, source: "local" as const },
    })),
    ...cmsPosts.map((post) => ({
      params: { slug: post.id },
      props: { post, source: "cms" as const },
    })),
  ];
}

const { post, source } = Astro.props;
const rendered = source === "local" ? await render(post) : null;
---

<Layout>
  <article">
    <header>
      <h1>
        {post.data.title}
      </h1>
      <div>
        {post.data.tags.length > 0 && (
          <div>
            {post.data.tags.map((tag) => (
              <span>
                {tag}
              </span>
            ))}
          </div>
        )}
      </div>
    </header>
    <div>
      {rendered ? <rendered.Content /> : <Fragment set:html={(post.data as { body: string }).body} />}
    </div>
  </article>
</Layout>

まとめ

  • ブログ記事に画像を載せる場合、mdファイルだと管理がしにくかったんですが、microCMSのデータをコレクションとして定義することで、画像の管理もmicroCMS側で完結できるのでシンプルな構成にすることができました。
    • テキスト中心の記事の場合は、mdファイルで作成
    • 画像を含めた記事を作成する場合は、microCMSで作成
  • Astro 6.0では、新たにLive Content Collectionsが導入され、リクエスト時にデータを取得することができるようになりました。更新されるデータに対して再ビルドなしで表示できるようになっているので、そちらの機能も試してみたいなと思っています
← 記事一覧に戻る