Astro と Movable Type Data API でページネーション (ページ分割) を実装する

Movable Type をヘッドレス CMS として使用し、Data API から取得したデータで Astro による Jamstack なサイトを制作する際にありがちな、記事一覧ページなどにおけるページネーション (ページ分割) の実装例。

この記事は 「Movable Type Advent Calendar 2023」 7日目の記事です。

ちょっと仕事が立て込んだ関係で、今年の Movable Type Advent Calendar は書くのを諦めかけていたんですが、先日に同 Advent Calendar を見ていたら遠慮のかたまりのように、この日だけ書く人が決まってなかったので、とりあえず埋めておこうということで急遽ネタを探しました。去年もそんな感じで書いた気もしますが・・・・・・

さて、この Blog では何度も書いているのですが、Movable Type をヘッドレス CMS の様に使用して、Astro や Next.js などのフレームワークを使用し、所謂 Jamstack な実装をするというのはよくやる (といいますか、Movable Type のテンプレートは Git ワークフローに組み込むのがめんどくせぇので、できればもうこの実装方法しかやりたくないくらいなんですけども) 手法です。

で、今回は Movable Type Data API から取得したデータで、記事一覧やカテゴリ別一覧ページのページネーション (ページ分割ですね) を実装する例を簡単に取り上げてみたいと思います。

ソースコード全体

最初にサンプルのソースコードは GitHub にまとめてありますのでどうぞ。これ見てもらえれば記事を読まなくても終わるかもしれません。

こちらのリポジトリを clone してきて、.env.sample.env にリネーム、設定値だけ書き換えてもらえれば、お手元の環境で動作確認できると思いますので参考まで (簡単な説明は README.md に書いてあります)。

前提

今回使用した環境は下記の通りです。基本的には本記事用のサンプルを書いていた、12月3日時点で最新 (Movable Type はまだ 7系)。

  • Movable Type 7 r.5501
  • Data API v6 (Documentation
  • Node.js 20.10.0 LTS
  • Astro.js 3.6.4 (Static (SSG) Mode)
  • Tailwind CSS 3.3.5

例 1) 全記事一覧のページネーション

まず、すべての記事の一覧をページ分割する実装をやってみます。ドメインの直下に、/1/ ... /5/ のような感じでページを作る前提です。

これには、

sites/{site_id}/entries

形式のリクエストを API に投げることで取得できる 「トータル記事数」 と 「記事データ」 を使用しますが、実装自体はそんなに複雑ではありません。

まず、src/pages ディレクトリ内に [page].astro を作成しましょう。

ページ分割に必要なルーティングを行うため、getStaticPaths() を使用します。当該ソースコード例は下記の通り。

export async function getStaticPaths() {
  // とりあえず1件分記事データを取得(トータル記事数を得るため)
  const getTotalResults = await fetch("https://example.com/mt/mt-data-api.cgi/v6/sites/1/entries?status=Publish&limit=1");
  const totalResultsJson = await getTotalResults.json();
  const totalResults = totalResultsJson.totalResults;

  // 1ページあたりの記事数
  const resultsPerPage = 60;

  // 必要なページ数を算出
  const totalPages = Math.ceil(totalResults / resultsPerPage);

  // 算出したページを連番にして配列に
  const pages = Array.from({ length: totalPages }, (_, i) => i + 1);

  // params を返す
  return pages.map((page) => {
    return {
      params: { page: page.toString() },
    };
  });
}

とりあえず 1 件分の公開済み記事のリストをリクエストします。そうすると totalResults に公開済みのトータル記事数が入ってきますので、それを取得。

1 ページ当たりに表示させたい記事数を今回は 60 件にしていますが、その数値と先ほどのトータル記事数から必要なページ数を算出します。

仮に総ページ数が 5 ページ必要なのであれば、1 ~ 5 までの数値を配列にし、params プロパティに突っ込めば、これで /1/ のようなパスが設定できます。

次に、各ページごとに必要な記事データを取得しますが、limitoffset パラメータを使用して、各ページ、該当する記事 60 件を取得してくればよいので簡単です。

当該ソースコード例は下記の通り。

const param = Astro.params;

/*-- 記事データの取得関連 --*/

// 今のページを取得(Astro.params を number に変換)
const page = Number(param.page);

// 1ページあたりの記事数
const resultsPerPage = 60;

// offset を計算
const offset = (page - 1) * resultsPerPage;

// APIから記事データを取得
const getEntries = await fetch(`https://example.com/mt/mt-data-api.cgi/v6/sites/1/entries?status=Publish&limit=${resultsPerPage}&offset=${offset}`);
const entriesJson = await getEntries.json();
const entries = entriesJson.items;

あとは、title 要素や見出しなどで使ったり、前後ページへのリンク生成のために必要なデータなどを作れば、ほぼ終わりです。

// 総ページ数を計算
const totalPages = Math.ceil(entriesJson.totalResults / resultsPerPage);

// 前後のページを算出
const nextPage = page + 1;
const PrevPage = page - 1;

// title や 見出しで使用する各データ
const title = `記事一覧(${page}ページ / ${totalPages}ページ)`;
const description = `記事一覧。${page}ページ / ${totalPages}ページ`;

実際のページはサンプルなので適当ですが、下記のように。

<Layout title={title} description={description}>
  <main class="pt-6 pb-16 bg-white">
    <div class="max-w-7xl mx-auto px-4">
      <div class="border-b border-gray-100 pb-2">
        <h2 class="text-lg font-bold sm:text-xl">{title}</h2>
      </div>
      <div class="mt-6">
        <ul class="list-disc list-outside ml-4 space-y-4">
          {entries.map((entry: entryType) => <li>{entry.title}</li>)}
        </ul>
      </div>
      <div class="mt-16">
        <ul class="flex items-center justify-center space-x-8">
          {
            page >= 2 && (
              <li>
                <a href={`/${PrevPage}/`} class="flex h-10 w-10 items-center justify-center rounded-md border border-gray-300 bg-white text-gray-700 transition-colors hover:bg-sky-50 focus:bg-sky-50">
                  <svg class="shrink-0 h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-label="前のページへ" role="img">
                    <title>前のページへ</title>
                    <path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
                  </svg>
                </a>
              </li>
            )
          }
          <li>
            <span class="font-en flex h-10 w-10 items-center justify-center rounded-md border border-sky-600 bg-sky-50 font-bold text-sky-700">
              {page}
            </span>
          </li>
          {
            page < totalPages && (
              <li>
                <a href={`/${nextPage}/`} class="flex h-10 w-10 items-center justify-center rounded-md border border-gray-300 bg-white text-gray-700 transition-colors hover:bg-sky-50 focus:bg-sky-50">
                  <svg class="shrink-0 h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-label="次のページへ" role="img">
                    <title>次のページへ</title>
                    <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
                  </svg>
                </a>
              </li>
            )
          }
        </ul>
      </div>
    </div>
  </main>
</Layout>

例 2) カテゴリ別記事一覧のページネーション

カテゴリ別の記事一覧をページ分割して、例えば /[category_name]/1/ ... /[category_name]/5/ みたいな URL を生成するのは少し複雑です。

これには、

sites/{site_id}/categories

形式のリクエストを API に投げることで取得できる 「カテゴリ一覧」 と、「カテゴリごとの記事データ」 を使用します。

まず、src/pages ディレクトリ内に [...slug].astro を作成しましょう。

先ほど同様、ページ分割に必要なルーティングを行うため、getStaticPaths() を使用します。当該ソースコード例は下記の通り。

export async function getStaticPaths() {
  // カテゴリの一覧を取得
  const getCategories = await fetch("https://example.com/mt/mt-data-api.cgi/v6/sites/1/categories?limit=0");
  const categoriesJson = await getCategories.json();
  const categories = categoriesJson.items;

  // 1ページあたりの記事数
  const resultsPerPage = 60;

  // 各カテゴリとページの組み合わせなどを param に返す
  let paths: pathsType[] = [];
  for (const category of categories) {
    // ソースコードが煩雑になるのでサブカテゴリは一旦考えない仕様(`parent: 0` はトップレベルカテゴリ)
    if (category.parent === 0) {
      // とりあえず各カテゴリから1件分記事データを取得(トータル記事数を得るため)
      const getTotalResults = await fetch(`https://example.com/mt/mt-data-api.cgi/v6/sites/1/categories/${category.id}/entries?status=Publish&limit=1`);
      const totalResultsJson = await getTotalResults.json();
      const totalResults = totalResultsJson.totalResults;

      // 必要なページ数を算出
      const totalPages = Math.ceil(totalResults / resultsPerPage);

      // 算出したページなどを連番にして配列に
      const categoryPages = Array.from({ length: totalPages }, (_, i) => {
        return {
          params: { slug: `${category.basename}/${i + 1}` },
          props: { page: `${i + 1}`, categoryID: `${category.id}`, categoryLabel: `${category.label}` },
        };
      });
      paths = paths.concat(categoryPages);
    }
  }

  return paths;
}

まず最初にカテゴリの一覧を取得し、そこで得られたカテゴリ ID を使用して、ページのパスに使用するデータを配列にします。

あわせて、title 要素や見出しなど、ページ内で使用するデータを props プロパティに格納しておきます。

ここまでくれば、あとは先ほどの記事一覧同様、各ページに必要な記事データを取得してきてページを構成すれば終わりです。

const props = Astro.props;
const params = Astro.params;

/*-- 記事データの取得関連 --*/

// 今のページを取得(Astro.props からカテゴリIDとページ数を取得)
const page = Number(props.page);
const categoryID = props.categoryID;

// 1ページあたりの記事数
const resultsPerPage = 60;

// offset を計算
const offset = (page - 1) * resultsPerPage;

// APIから記事データを取得
const getEntries = await fetch(`https://example.com/mt/mt-data-api.cgi/v6/sites/1/categories/${categoryID}/entries?status=Publish&limit=${resultsPerPage}&offset=${offset}`);
const entriesJson = await getEntries.json();
const entries = entriesJson.items;

// 総ページ数を計算
const totalPages = Math.ceil(entriesJson.totalResults / resultsPerPage);

// 前後のページを算出
const nextPage = page + 1;
const PrevPage = page - 1;

// title や 見出しで使用する各データ
const categoryLabel = props.categoryLabel;
const title = `${categoryLabel} カテゴリの記事一覧(${page}ページ / ${totalPages}ページ)`;
const description = `${categoryLabel} カテゴリの記事一覧。${page}ページ / ${totalPages}ページ`;

ただし、今回はソースコードが煩雑になるのを防ぐため、カテゴリは 1 階層、つまりトップレベルカテゴリしかない前提で実装しました。サブカテゴリがあったり、さらにカテゴリの階層が複雑な場合は、それに合わせて getStaticPaths() 内を改修する必要があります。

サブカテゴリの場合は、category.parent 内に親となるカテゴリの ID が入ってきます。逆に言うと、トップレベルカテゴリの場合はこの数値が 0 になるので、0 以外であれば、サブカテゴリだということがわかります。

よって、2 階層目以降のカテゴリに関しては、category.parent の値から親のカテゴリの category.basename を引っぱってきて、params 内で連結するみたいな処理を加えてあげればよいということになります。とはいえ、階層が深いとめちゃくちゃ面倒くさそうなので個人的にはあまりやりたくない。

関連エントリー

記事をここまで御覧頂きありがとうございます。
この記事が気に入ったらサポートしてみませんか?