Astro.js と Movable Type Data API を使用して Jamstack な Blog を作ってみる

Movable Type をヘッドレス CMS として使用し、Data API から取得したデータで Astro.js による Jamstack なサイトを作成してみようというお話。以前書いた Next.js を使用した同趣旨記事の Astro.js 版です。

この記事は 「Movable Type Advent Calendar 2022」、1 日目の記事です。

2年前、「Movable Type Advent Calendar 2020」 の記事として、Movable Type をヘッドレス CMS のように利用しつつ、Next.js の SSG (Static Site Generation) による、所謂 Jamstack な Blog サイトを作る件について書いたのですが (下記参照)、今回はそれの Astro.js 版です。

今回は、CSS フレームワークとして Tailwind CSS を使用し、短時間で簡単にとりあえず動くところまで持って行くサンプルを作ってみます。

操作サンプルとソースコード

ちなみに、CodeSandbox に動作するサンプルを作っておきましたので、実際のソースコードや動作はそちらで確認してください。

また、サンプルのソースコードは GitHub にまとめてありますのでどうぞ。

前提

今回使用した環境は下記の通りです。基本的には本記事用のサンプルを書いていた、11月中旬時点で最新。

  • Movable Type 7 r.5301
  • Data API v4
  • Node.js 18.12.1 LTS
  • Astro.js 1.6.10
  • Tailwind CSS 3.2.4

稼働している Movable Type がすでに存在し、Node.js はすでに導入済みの前提になります。また、私は普段、npm を使用しているのでその前提で書いていますが、パッケージマネージャに yarn を使用している場合は読みかえてください。

なお、Astro.js、および Tailwind CSS の公式ドキュメントは下記です。

必要なパッケージのインストール

今回のサンプルで使用したパッケージを入れていきます。

Astro.js のインストール

まずは Astro.js をインストールしましょう。最初に作業用のディレクトリに移動して、

npm create astro@latest

しましょう。あとは質問に答えていくだけで OK です。今回はプロジェクト名を 「blog」 にする前提で進めます。これで blog という名前でディレクトリが作成され、必要なパッケージや Astro.js のサンプルページが格納されます。

cd blog

上記コマンドで作成されたディレクトリに移動して、下記コマンドを入力すれば、開発サーバが立ち上がり localhost:3000 に Astro.js のサンプルページが表示されれば OK です。

npm run dev

確認できたら一旦 Ctrl + C で開発サーバを停止し、今回のサンプルにおいて追加で必要なパッケージをインストールしていきましょう。

Tailwind CSS のインストール

Tailwind CSS、および今回のサンプルで使用した Tailwind CSS Plugin、さらに PostCSS などをインストールします。

npx astro add tailwind

上記コマンドを実行し、Y を押していけば Tailwind CSS のインストールは完了、tailwind.config.cjs が自動的に作成されます。

次に PostCSS などをインストール。

npm install -D @tailwindcss/typography postcss autoprefixer cssnano

tailwind.config.cjs をエディタで開いて、下記のように設定を追加し、@tailwindcss/typography を有効にします。

module.exports = {
  theme: {
    // ...
  },
  plugins: [
    require('@tailwindcss/typography'),
    // ...
  ],
}

さらに、postcss.config.cjs を作成し、下記の設定を加えます。cssnano は書き出される CSS を Minify するために使用しています。

module.exports = {
    plugins: [
        require('tailwindcss'),
        require('autoprefixer'),
        require('cssnano')({
            preset: 'default',
        }),
    ],
};

カスタムフォントのインストール

これは必須じゃないんですが、フォントに Noto Sans JP を使ってみましょう。

Fontsource を使用すると、Astro.js があとはよしなにやってくれます。とても便利。

Fontsource から Noto Sans JP をインストールします。

npm install @fontsource/noto-sans-jp

今回はこのフォントを、Tailwind CSS のユーティリティクラスとして使用したいので、tailwind.config.cjs に下記のように設定を追加します。

module.exports = {
  theme: {
    fontFamily: {
      'body': ['"Noto Sans JP"', 'sans-serif'],
    },
    extend: {},
  },
  plugins: [
    require('@tailwindcss/typography'),
    // ...
  ],
}

これで、font-body というユーティリティクラスが使えるようになります。

Movable Type Data API について

Movable Type から記事データを取得するため、Data API のエンドポイントについて確認しましょう。Data API は Movable Type 7 で利用可能な v4 を使用します。

公式リファレンスは下記にありますが、今回行いたいのは 「記事一覧の取得」 ですので、「Entry Collection」 内の、「Get」 で説明されている方法に基づいてリクエスト URL を決めていきます。

まず、Data API のエンドポイントの基本となる URL は下記の通り。[mt-path] は Movable Type のインストールディレクトリです。

https://example.com/[mt-path]/mt-data-api.cgi/v4/

これをベースに、各データをリクエストするためのパスを足していきます。下記のような感じですね。

https://example.com/[mt-path]/mt-data-api.cgi/v4/sites/[siteid]/

[siteid] には、記事一覧を取得したい対象ブログの blog_id が入ります。

blog_id が 「1」 であれば、下記のように。

https://example.com/[mt-path]/mt-data-api.cgi/v4/sites/1/

上記がエンドポイントの基本 URL になり、あとは取得したいデータに合わせて少し条件を足します。例えば公開済みの記事だけを最新 5 件分取得したい場合は、記事をリクエストする entries に続けて、status パラメータと、limit パラメータを付け加えましょう。

https://example.com/[mt-path]/mt-data-api.cgi/v4/sites/[siteid]/entries?limit=5&status=Publish

もし、「mt」 というディレクトリに Movable Type がインストールされていて、対象としたいサイトの blog_id が 「1」 であれば、リクエスト URL は下記のようになります。

https://example.com/mt/mt-data-api.cgi/v4/sites/1/entries?limit=5&status=Publish

Data API へのリクエスト URL を確認したら、プロジェクトのルートディレクトリ直下に .env ファイルを作成し、下記のように Astro.js の環境変数として指定しておきます。

SECRET_MT_ENDPOINT_URL=https://example.com/mt/mt-data-api.cgi/v4/sites/1/

なお、先ほどの例でリクエスト URL 末尾に付けた entries やパラメータ (下記の部分) は、後述するファイル内で後から指定するので一旦保留しておいてください。

entries?limit=5&status=Publish

Astro.js の環境変数に関しては、下記に公式ドキュメントがあります。

設定した環境変数は下記のように呼び出すことができます。

const ENDPOINT = import.meta.env.SECRET_MT_ENDPOINT_URL

ここまで来たら下準備は完了です。実際のページを作っていきましょう。

各ページの作成

Astro.js では src/ 配下に各ページを作成していきます。

共通レイアウト

まずは各ページの作成を楽にするため、全体の共通レイアウトを作ってしまいましょう。全ページ共通のヘッダやフッタはここに記述します。

プロジェクトのルートディレクトリ (blog) に src/layouts/ ディレクトリを作成し、Layout.astro ファイルを新規作成します。

.astro ファイルは、コードフェンス --- で囲んだ部分にコンポーネントスクリプト、要するに JavaScript (TypeScript 使用可能) を記述し、それ以外の部分にはコンポーネントテンプレート、これは HTML と JSX っぽい記述で実際に Web ページとして書き出される内容を記述するのが基本になります。

例えば今回のサンプルにおける Layout.astro ファイルのコンポーネントスクリプトは下記のような感じになります。

---
import "@fontsource/noto-sans-jp";

export interface Props {
  title?: string;
  description?: string;
  url?: string;
  image?: string;
  type?: string;
}

const siteName: string = "Astro.js Sample Blog";
const { title, description, url, image, type } = Astro.props;
---

冒頭に import "@fontsource/noto-sans-jp"; の記述がありますが、これが、前のセクションで導入したカスタムフォントの読み込みになります。その他、Web サイト全体で読み込みたい JavaScript などもここに記述しておくと便利です。

コンポーネントテンプレートは下記のように記述しました。ほぼ HTML なのでわかりやすいと思います。

<!DOCTYPE html>
<html lang="ja" class="scroll-smooth">
  <head>
    <meta charset="utf-8" />
    <title>{title ? `${title} - ${siteName}` : `${siteName}`}</title>
    <meta name="viewport" content="width=device-width" />
    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
    {
      description && (
        <meta name="description" content={description} />
        <meta property="og:title" content={title ? `${title}` : siteName} />
        <meta property="og:site_name" content={siteName} />
        <meta property="og:url" content={url} />
        <meta property="og:description" content={description} />
        <meta property="og:image" content={image} />
        <meta property="og:image:secure_url" content={image} />
        <meta property="og:type" content={type} />
        )
    }
  </head>
  <body class="font-body">
    <header class="py-4 bg-gray-50">
      <div class="max-w-7xl mx-auto px-4">
        <h1>
          <a href="/">サンプルブログ</a>
        </h1>
      </div>
    </header>
    <slot />
    <footer class="py-8 bg-gray-900">
      <div class="max-w-7xl mx-auto px-4">
        <p class="text-center text-white">
          <small class="text-sm">Sample blog</small>
        </p>
      </div>
    </footer>
  </body>
</html>

Layout.astro 内の <slot /> 部分に、別途作成する各ページの内容が挿入されます。

Data API からデータを取得するための処理を実装

次に Movable Type の Data API からデータを取得してくる部分を作っておきましょう。

src/lib/ ディレクトリを作成し、api.js ファイルを新規作成します。

const ENDPOINT = import.meta.env.SECRET_MT_ENDPOINT_URL;

const fetchAPI = async (param) => {
    const res = await fetch(`${ENDPOINT}${param}`).catch((err) => { console.error(err) });
    const json = await res.json();
    return json;
}

まず、.env ファイルに環境変数として設定したエンドポイント URL のベースを読み込みます。

const ENDPOINT = import.meta.env.SECRET_MT_ENDPOINT_URL;

エンドポイントに対して、データをリクエストする部分を下記のように。

const fetchAPI = async (param) => {
    const res = await fetch(`${ENDPOINT}${param}`).catch((err) => { console.error(err) });
    const json = await res.json();
    return json;
}

で、今回のサンプルで使用したい、いくつかのデータ取得パターンにあわせて、あとで使いやすいように JavaScript モジュールを作成しておきます。

// 記事一覧取得
const fetchAllPosts = (num) => {
    const params = [['limit', num], ['status', 'Publish']];
    const param = new URLSearchParams(params).toString();

    return fetchAPI(`entries?${param}`);
}

export const getAllPosts = async (num) => {
    const data = await fetchAllPosts(num);
    return data;
}

↑とりあえず記事の一覧を取得するやつ。引数で件数を指定可能に。

// カテゴリ一覧取得
const fetchAllCategories = () => {
    const params = [['limit', '0'], ['sortBy', 'basename']];
    const param = new URLSearchParams(params).toString();

    return fetchAPI(`categories?${param}`);
}

export const getAllCategories = async () => {
    const data = await fetchAllCategories();
    return data;
}

↑カテゴリの一覧を取得するやつ。

// カテゴリ別の記事一覧取得
const fetchCategoriesPost = (id, num) => {
    const params = [['limit', num]];
    const param = new URLSearchParams(params).toString();

    return fetchAPI(`categories/${id}/entries?${param}`);
}

export const getCategoriesPost = async (id, num) => {
    const data = await fetchCategoriesPost(id, num);
    return data;
}

↑カテゴリ ID と件数を引数にして、カテゴリ別の記事一覧を取得するやつ。

こうしておけば、あとで下記のようにして簡単に使い回せます。

import { getAllPosts } from "lib/api.js";

トップページ(新着記事一覧)

まずはトップページを作ってみましょう。簡単な記事一覧ページです。

src/pages/ ディレクトリを作成し、index.astro ファイルを新規作成します。なお、トップページ以下、各ページは src/pages/ ディレクトリ内に作っていくことになります。

src/pages/index.astro のソースコードは下記の通りです。

---
import Layout from "../layouts/Layout.astro";
import { getAllPosts, getAllCategories } from "../lib/api.js";

const entries = await getAllPosts(5);
const categories = await getAllCategories();

const description: string = "サンプルブログのトップページです。";
const url: string = "https://example.com/";
const image: string = "https://example.com/img/ogp.png";
const type: string = "website";
---

<Layout description={description} url={url} image={image} type={type}>
  <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">新着記事</h2>
      </div>
      <div class="mt-6">
        <ul class="list-disc list-outside ml-4 space-y-4">
          {
            entries.items.map((item: any) => (
              <li>
                <a class="underline hover:text-red-800" href={`/articles/${item.basename}`}>
                  {item.title}
                </a>
              </li>
            ))
          }
        </ul>
      </div>
      <div class="mt-16 border-b border-gray-100 pb-2">
        <h2 class="text-lg font-bold sm:text-xl">カテゴリ一覧</h2>
      </div>
      <div class="mt-6">
        <ul>
          {
            categories.items.map((item: any) => (
              <li class="inline-block">
                <a class="inline-block p-2 mr-2 mb-2 border-gray-100 bg-gray-50 rounded-md hover:border-indigo-800 hover:bg-indigo-600 hover:text-white" href={`/archives/${item.basename}`}>
                  {item.label}
                </a>
              </li>
            ))
          }
        </ul>
      </div>
    </div>
  </main>
</Layout>

先ほどエクスポートしておいた getAllPosts()getAllCategories() 関数を使用して、最新記事 5 件分のデータと、カテゴリの一覧を取得し、それらデータを HTML に反映します。やってることは単純名のでわかりやすいと思いますが、これでトップページは完成です。

カテゴリ別記事一覧ページ

次にカテゴリごとの記事一覧ページを作ってみましょう。

カテゴリページの URL は /archives/[カテゴリ名(mt:CategoryBasename)]/index.html になるようにしたいので、

src/pages/ 内に archives ディレクトリを作成、さらにその中に [category] ディレクトリを作成したら、index.astro ファイルを新規作成します。

Astro.js の動的ルーティングを使用するため、動的にディレクトリ名を生成したい部分のディレクトリ名を [ブラケット]記法 で作成しておきます。

src/pages/archives/[category]/index.astro のソースコードは下記の通りです。

---
import Layout from "../../../layouts/Layout.astro";
import LinkButton from "../../../components/LinkButton.astro";
import { getAllCategories, getCategoriesPost } from "../../../lib/api.js";

export async function getStaticPaths() {
  const categories = await getAllCategories();
  return categories.items.map((item: any) => {
    return {
      params: {
        category: item.basename,
      },
      props: {
        categories: categories,
        categoryID: item.id,
        categoryLabel: item.label,
        categoryDescription: item.description,
      },
    };
  });
}

const { category } = Astro.params;
const { categories, categoryID, categoryLabel, categoryDescription } = Astro.props;

const entries = await getCategoriesPost(categoryID, 5);

const title: string = categoryLabel;
const description: string = categoryDescription;
const url: string = `https://example.com/archives/${category}`;
const image: string = "https://example.com/img/ogp.png";
const type: string = "website";
---

<Layout title={title} description={description} url={url} image={image} type={type}>
  <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.items.map((item: any) => (
              <li>
                {/* 本来は→のようにリンクを指定するが今回はサンプルで記事ページを全部書き出していないため仮 <a class="underline hover:text-red-800" href={`/articles/${item.basename}`}> */}
                <a class="underline hover:text-red-800" href="#">
                  {item.title}
                </a>
              </li>
            ))
          }
        </ul>
      </div>
      <div class="mt-10 text-center">
        <LinkButton label="トップページに戻る" href="/" />
      </div>
      <div class="mt-16 border-b border-gray-100 pb-2">
        <h2 class="text-lg font-bold sm:text-xl">カテゴリ一覧</h2>
      </div>
      <div class="mt-6">
        <ul>
          {
            categories.items.map((item: any) => (
              <li class="inline-block">
                <a class="inline-block p-2 mr-2 mb-2 border-gray-100 bg-gray-50 rounded-md hover:border-indigo-800 hover:bg-indigo-600 hover:text-white" href={`/archives/${item.basename}`}>
                  {item.label}
                </a>
              </li>
            ))
          }
        </ul>
      </div>
    </div>
  </main>
</Layout>

動的ルーティングを使用するため、まず getStaticPaths() 関数をエクスポートしてパスを指定していきます。

export async function getStaticPaths() {
  const categories = await getAllCategories();
  return categories.items.map((item: any) => {
    return {
      params: {
        category: item.basename,
      },
      props: {
        categories: categories,
        categoryID: item.id,
        categoryLabel: item.label,
        categoryDescription: item.description,
      },
    };
  });
}

getAllCategories() 関数でカテゴリ一覧を Data API から取得し、mt:CategoryBasename の値を params にセットしておきます。

また、propsmt:CategoryID をはじめ、このページで使用するページタイトルなどの情報もセットしておきましょう。

mt:CategoryID のデータを使用して、getCategoriesPost() 関数を実行し、カテゴリ別の記事一覧を最新 5 件だけ取得します。

const entries = await getCategoriesPost(categoryID, 5);

これで、カテゴリ別の一覧ページも完成です。

個別記事ページ

ここまできたらあとは記事の個別ページを作成すればほぼ終わりです。

個別記事ページの URL は /articles/[記事URL(mt:EntryBasename)]/index.html になるようにしたいので、src/pages/ 内に articles ディレクトリを作成、さらにその中に [slug].astro ファイルを新規作成します。

src/pages/articles/[slug].astro のソースコードは下記の通りです。

---
import Layout from "../../layouts/Layout.astro";
import LinkButton from "../../components/LinkButton.astro";

import { getAllPosts } from "../../lib/api.js";

export async function getStaticPaths() {
  // サンプルのためとりあえず最新5件分の記事ページのみ生成している(本来は何かしら別の方法で個別記事のパス一覧を取得して、それを基に個別記事データを取りに行くなどする方がよい)
  const entries = await getAllPosts(5);
  return entries.items.map((item: any) => {
    return {
      params: {
        slug: item.basename,
      },
      props: {
        title: item.title,
        excerpt: item.excerpt,
        body: item.body,
        more: item.more,
        image: item.customFields[0].value,
      },
    };
  });
}

const { slug } = Astro.params;
const { title, excerpt, body, more, image } = Astro.props;

const url = `https://example.com/articles/${slug}`;
---

<Layout title={title} description={excerpt} url={url} image={image} type="Article">
  <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">
        <div class="prose prose-a:underline hover:prose-a:text-red-800 max-w-full">
          <div set:html={body} />
          <div set:html={more} />
        </div>
      </div>
      <div class="mt-10 text-center">
        <LinkButton label="トップページに戻る" href="/" />
      </div>
    </div>
  </main>
</Layout>

カテゴリ一覧の時と同様、getStaticPaths() を使用して動的ルーティングを行います。今回はサンプルなので、とりあえず最新 5 件分の記事しか個別ページを生成しないようにしていますが、実際には全ページリストを取得して、パスを指定しないといけません。ソースコードはあくまで参考として。

ページ分割

Astro.js はページ分割の仕組みも用意されているので、比較的簡単に実装できます。

src/pages/archives/[page].astro をサンプルソースに含めておきました。

---
import Layout from "../../layouts/Layout.astro";
import Pagination from "../../components/Pagination.astro";
import { getAllPosts, getAllCategories } from "../../lib/api.js";

export async function getStaticPaths({ paginate }: any) {
  const entries = await getAllPosts(5);
  return paginate(entries.items, { pageSize: 2 });
}

const { page } = Astro.props;
const categories = await getAllCategories();

const title: string = `記事一覧(${page.currentPage} / ${page.lastPage})ページ`;
const description: string = `サンプルブログの記事一覧(${page.currentPage} / ${page.lastPage})ページです。`;
const url: string = `https://example.com/archives/${page.currentPage}`;
const image: string = "https://example.com/img/ogp.png";
const type: string = "website";
---

<Layout title={title} description={description} url={url} image={image} type={type}>
  <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">新着記事</h2>
      </div>
      <div class="mt-6">
        <ul class="list-disc list-outside ml-4 space-y-4">
          {
            page.data.map((item: any) => (
              <li>
                <a class="underline hover:text-red-800" href={`/articles/${item.basename}`}>
                  {item.title}
                </a>
              </li>
            ))
          }
        </ul>
      </div>
      <Pagination page={page} />
      <div class="mt-16 border-b border-gray-100 pb-2">
        <h2 class="text-lg font-bold sm:text-xl">カテゴリ一覧</h2>
      </div>
      <div class="mt-6">
        <ul>
          {
            categories.items.map((item: any) => (
              <li class="inline-block">
                <a class="inline-block p-2 mr-2 mb-2 border-gray-100 bg-gray-50 rounded-md hover:border-indigo-800 hover:bg-indigo-600 hover:text-white" href={`/archives/${item.basename}`}>
                  {item.label}
                </a>
              </li>
            ))
          }
        </ul>
      </div>
    </div>
  </main>
</Layout>

下記のように paginate() 関数を使用するだけです (例として、5 件分の記事を 2 件ごとにページ分割しています)。

export async function getStaticPaths({ paginate }: any) {
  const entries = await getAllPosts(5);
  return paginate(entries.items, { pageSize: 2 });
}

RSS フィード

RSS フィードも簡単に生成するための @astrojs/rss が用意されています。

npm install @astrojs/rss

src/pages/feed/rss.xml.js を下記のように作成します。

import rss from '@astrojs/rss';
import { getAllPosts } from '../../lib/api.js';

const entries = await getAllPosts(5);
const siteName = "Astro.js Sample Blog";
const description = "サンプルブログです。";
const url = "https://example.com/";

export const get = () => rss({
    title: siteName,
    description: description,
    site: url,
    customData: `<language>ja</language>`,
    items: entries.items.map((item) => ({
        link: `${url}articles/${item.basename}/`,
        title: item.title,
        pubDate: item.date,
    }))
});

これで、/feed/rss.xml に RSS フィードが生成されます。簡単ですね。

動作確認

さて、ここまできたら、再度開発サーバを立ち上げて、各ページが正しく表示されるかを確認してみましょう。

npm run dev

して、ブラウザで localhost:3000 にアクセスします。

トップページに記事一覧が表示され、記事をクリックした時にきちんと記事のページに遷移できれば成功です。エラーが出る場合はエラーメッセージを確認して修正しましょう。

astrojs-blog-sample.png

上記は、当 Blog の最新記事 5件を取得して作ってみた、トップページ表示例です

npm run build

することで、デフォルトでは dist/ ディレクトリに静的なファイルが書き出されます。

Netlify や Vercel など、各サービスへのデプロイガイドは下記に公式ドキュメントがありますので参考まで。SSR したい場合のアダプタも用意されています。

その他

ということで、簡単に説明してきましたが今回の例のような Blog をはじめ、静的な Web サイトを素早く、効率よく構築するなら Astro.js はかなり便利なのでオススメです。

最後に補足情報を少し。

404 ページ

404 ステータスに対して独自のエラーページを返したい場合は、src/pages/ 内に、404.astro、あるいは 404.md ファイルを作っておけばそれが利用されます。

sitemap.xml

sitemap.xml の生成が必要な場合は、@astrojs/sitemap が利用できます。詳しくは公式ドキュメントを参照してください。なお、SSR でサイトを公開する場合は使えないので、別の方法を考える必要があります。

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