はじめてのAstro - ブログサイトを再構築する

賢明な皆様はお気づきかもしれませんが、このブログは先日フルリニューアルされました。リリースは確か2023年10月20日だったと思います。

デザインが変わっているのは実は副次的なもので、メインは Nuxt Content から Astro への移行でした。フレームワークから変わってしまうため旧スタイルを移設するのに非常に手間がかかります。ならばせっかくなのでデザインも刷新してしまおう!というのがおおまかな経緯です。

今日のテーマは、 Astro の紹介と Astro 上でのブログの構築方法についてです。

Astro とは

まずは Astro を簡単に紹介します。

Astroは、コンテンツにフォーカスした高速なWebサイトを構築するためのオールインワンWebフレームワークです。 https://docs.astro.build/ja/getting-started/

Astro はコンテンツに焦点を絞った2021年生まれの若いWebフレームワークです。最も得意とするのは静的サイトの構築なので「静的サイトジェネレーター」と紹介される事も多いですが、アプリケーションの開発環境としても有用です。

ハイパフォーマンス

Astro はポリシーとして Zero JS, by default を掲げており、基本としてクライアントサイドでJavaScriptを実行させない作りになっています。そのため極めてパフォーマンスの高い静的サイトの構築が可能になっているのです。

公式ドキュメントでは、 Astroで遅いWebサイトを構築することは不可能 とまで書かれています。すごい自信だ!

とっても簡単

Astro は難解なプログラミングを必要としません。HTML/CSS と簡単な JavaScript がわかればサイトを作り上げる事ができます。そしてファイルベースの分かりやすいルーティングと、シンプルな構成の .astro ファイルや Markdown/MDX によるコンテンツ編集で、少ない学習コストで誰でもWebサイト編集に参加することができます。

.astro ファイルの構成は Vue のSFC(単一ファイルコンポーネント)にとても良く似ています。

---
// JavaScript 
---
<html lang="en">
  <head>
    <title>My Homepage</title>
  </head>
  <body>
    <div class="Container">
      <h1>Welcome to my website!</h1>
    </div>
  </body>
</html>

<style>
.Container {
  width: 50vw;
  padding: 1rem;
  margin: 0 auto;
}
</style>

さらに素晴らしいのは、難解なプログラミングを駆使する事でより高機能なWebアプリケーションをも開発できる事です。ハードルは低く、それでいて可能性の振り幅が非常に広いのが特徴です。

多彩なインテグレーション

Astroは自身のコンポーネントだけでなく、React や Vue、 SolidJS など様々なフレームワークをインテグレーションとして利用する事ができます。これらをフレームワークコンポーネントと呼びます。

前述の通りAstroコンポーネントはクライアント側でJavaScriptを実行させる事ができないので、例えばクリックなどのユーザーアクションに紐づけて処理を行ったり、非同期通信などをさせたい場合は、このフレームワークコンポーネントを活用して実装します。このように動的な機能を自身で持たないからこそ、スリムで高速なWebサイトが実現できるのでしょう。

フレームワークは複数利用することができるので、React と Vue を悪魔合体させたキメラアプリケーションなんかも作れちゃうのがAstroの面白いところです。

ブログサイトを構築してみよう

それでは実際に Astro を使ってブログサイトを構築してみましょう。静的でシンプルなWebサイトと違ってブログサイトのセットアップは少々骨がありますので、咀嚼しながら前進しましょう。

Astro セットアップ

まずは Astro をセットアップしましょう。コマンド一発で簡単に導入できます。

$ npm create astro@latest

いくつか質問されますので、適当に答えましょう。以下は回答の一例です。1,2番目の質問を除いて、ほぼデフォルトで良いでしょう。

QuestionAnswer
Where should we create your new project?./astro-sandbox
How would you like to start your new project?Empty
Install dependencies?Yes
Do you plan to write TypeScript?Yes
How strict should TypeScript be?Strict (recommended)
Initialize a new git repository?Yes

How would you like to start your new project? の質問で Use blog template を選択すると一瞬でブログサイトの構築は完了してしまいますので、ここは構築のステップを理解するために Empty を選びます。

インストールが終わったら開発サーバーを立ち上げてみましょう。

$ cd astro-sandbox
$ npm run dev

http://localhost:4321 でAstroが立ち上がります。ブラウザで確認してみましょう。

コンテンツコレクションのセットアップ

ブログは Astro@2.0.0 で追加された Content Collections という機能で実装します。これはその名の通りコンテンツのコレクション(例えばブログ記事やニュースコンテンツのような)を管理する為の機能です。

管理するドキュメントは md/mdxで記述し、 src/content ディレクトリに配置していきます。こんなイメージです。

src/
└── content/
    └── blog/
        ├── 001.md
        ├── 002.md
        └── 003.md

今回は blog という名前のコレクションを作ります。

ここに保存する Markdown ファイルに記事コンテンツを書いていくのですが、そのヘッダ部分には、タイトルや投稿日時等のドキュメントに関するメタ情報を記載しておきます。

---
title: ポラーノの広場
date: 2023-10-30 00:00
description: あのイーハトーヴォのすきとおった風、夏でも底に冷たさをもつ青いそら
tags:
  - 宮沢賢治
  - モリーオ市
  - イーハトーヴォ
---

そのころわたくしは、モリーオ市の博物局に勤めて居りました。
十八等官でしたから役所のなかでも、ずうっと下の方でしたし俸給ほうきゅうもほんのわずかでしたが、受持ちが標本の採集や整理で生れ付き好きなことでしたから、わたくしは毎日ずいぶん愉快にはたらきました。殊にそのころ、モリーオ市では競馬場を植物園に拵こしらえ直すというので、その景色のいいまわりにアカシヤを植え込んだ広い地面が、切符売場や信号所の建物のついたまま、わたくしどもの役所の方へまわって来たものですから、わたくしはすぐ宿直という名前で月賦で買った小さな蓄音器と二十枚ばかりのレコードをもって、その番小屋にひとり住むことになりました。わたくしはそこの馬を置く場所に板で小さなしきいをつけて一疋の山羊を飼いました。毎朝その乳をしぼってつめたいパンをひたしてたべ、それから黒い革のかばんへすこしの書類や雑誌を入れ、靴もきれいにみがき、並木のポプラの影法師を大股にわたって市の役所へ出て行くのでした。

このヘッダー部分はフロントマター( front-matter )と呼ばれ、yaml で記述します。フロントマターの項目は自由に定義する事ができます。

記事を一つ書いて保存をしたら、一度ビルドをしましょう。Astro のビルドプロセスの中で astro:content の型定義をやってくれます。

$ npm run build

ビルドをすると .astro/types.d.ts というファイルが生成され、 astro:content の機能が認識されるようになります。うまくワークしない場合もあるので、その時はエディタの再読み込みなどをしてみましょう。

コレクションの型定義をする

これは任意なのですが、やっておくとフロントマターの型検証などをしてくれて型安全に制作を進めることが出来ます。 先程作った content ディレクトリに config.ts を追加し、その中で zod を使用したコレクションの型定義を行います。

src/
└── content/
    ├── blog/
    │   ├── 001.md
    │   ├── 002.md
    │   └── 003.md
    └── config.ts
import { defineCollection, z } from "astro:content";

export const collections = {
  blog: defineCollection({
    type: "content",
    schema: z.object({
      title: z.string(), // title は文字列
      date: z.string(), // date は文字列
      description: z.string(), // description は文字列
      tags: z.array(z.string()), // tags は文字列で構成された配列( Array<string> )
    })
  })
};

これが基本形です。 schema の中でフロントマターの各フィールドの型を定義しています。型の表現には zod ライブラリを使用しているので、書き方に迷ったらそちらのドキュメントを参照すると良いでしょう。

colinhacks/zod: TypeScript-first schema validation with static type inference

type の値は基本的に content でOKです。JSON や yaml でデータを扱う際には data を指定する事ができます。

記事詳細画面をつくる

さて、いよいよ記事詳細画面を作ります。まずは pages ディレクトリの中にページファイルを設置します。

src/
└── pages/
    └── blog/
        └── [...slug].astro

[...slug] にはブログ記事のスラグ(デフォルトではファイル名)が展開され、ルーティングされます。この場合、例えば記事を my-first-post.md という名前で保存したならば、 /blog/my-first-post というパスでアクセスできるようになります。

で、アクセスできるようにするためのスクリプトを [...slug].astro に記述するわけです。

---
import type { GetStaticPathsResult } from "astro";
import { getCollection, type CollectionEntry } from "astro:content";

// エントリーごとにパスを生成するために getStaticPaths を使う
export async function getStaticPaths ():Promise<GetStaticPathsResult> {
  // getCollection() で blog コレクションのエントリーをすべて取得
  const entries = await getCollection("blog");
  return entries.map((entry) => {
    return {
      params: { slug: entry.slug },  // params に slug を返す事でパスを生成する
      props: { entry } // ページ描画のためのデータを props にわたす
    }
  });
}

// Props の型を定義しておく
interface Props {
  entry: CollectionEntry<"blog">
}

const { entry } = Astro.props;
const { Content } = await entry.render();
---
<h1>{entry.data.title}</h1>
<Content />

ポイントはこのあたり。

  • そのまま entry を参照しようとすると プロパティ 'entry' は型 'Props | undefined' に存在しません。 と怒られるので、コンポーネントPropsの型を定義してあげる必要があります。コンポーネントPropsについてはこちら。
  • コレクションエントリーには render() というメソッドが生えていて、これが返す <Content /> でエントリーの本文を描画する事ができます。 render() は Promise を返すので await を忘れないようにしましょう。
  • フロントマターの情報は entry.data に格納されています。

インデックスページをつくる

次は、記事一覧を表示する目次ページを作ります。こちらは詳細画面に比べると非常に簡単です。インデックスページは /blog のURLでアクセスしたいので、 /src/pages/blog/index.astro を追加して書いていきましょう。

---
import { getCollection } from "astro:content";

const entries = await getCollection("blog");
---
<ul>
  {entries.map(entry => (
    <li>
      <a href={`/blog/${entry.slug}`}>
        {entry.data.title}
      </a>
    </li>
  ))}
</ul>

記事詳細画面の getStaticPaths と同じ要領で getCollection() を使ってコレクションの記事リストを取得し、それを .map() で回すだけです。

.astro ファイルの構成が Vue の単一ファイルコンポーネントに似ているという話をしましたが、テンプレートの書き方はほぼ完全に React です。React メンは .map で返す要素に key を渡さないところに違和感を覚えるかもしれませんが、 .astro ワールドは完全に静的なHTMLを出力するためハイドレーションがないので key が必要ないのだという理解をしています。

これで詳細ページと目次ページが出来たので、ブログとしてミニマムの機能は備えられたと思います。

検索ページも、つくる?(フレームワークコンポーネントの利用)

他にあると嬉しいのは検索機能ですが、Astroだけでは実現が厳しいのでフレームワークコンポーネントの力を借ります。すでに長いエントリになってしまっているのでさわりだけ。

まず、React を使えるようにします。 astro add コマンドから簡単に追加できます。

$ npm run astro add react

そしてReactコンポーネントで検索フォームを簡易的に実装します。検索フォームに文字列を入れてサブミットすると検索結果がリストで表示される仕様です。

src/components/SearchForm.tsx として作成しました。

import { getCollection, type CollectionEntry } from "astro:content";
import { useCallback, useState } from "react";

export function SearchForm (): JSX.Element {
  const [query, setQuery] = useState<string>("");
  const [result, setResult] = useState<CollectionEntry<"blog">[]>([]);

  const search = useCallback(async () => {
    const entries = await getCollection("blog");
    setResult(
      entries.filter(entry => {
        return [
          entry.body, 
          entry.data.title, 
          ...entry.data.tags
        ].join(" ").includes(query)
      })
    );
  }, [query]);

  return (
    <>
      <form 
        onSubmit={e => {
          e.preventDefault();
          search();
        }}
      >
        <input 
          type="search" 
          value={query} 
          onChange={event => {
            setQuery(event.target.value)
          }} 
        />
      </form>
      <ul>
        {result.map(entry => (
          <li key={entry.id}>
            <a href={`/blog/${entry.slug}`}>
              <div>{entry.data.title}</div>
              <div>{entry.data.description}</div>
            </a>
          </li>
        ))}
      </ul>
    </>
  )
}

特段変わったことはしていないごく普通のReactコンポーネントです。ひとつ注目すべきなのは astro:contentgetCollection() がReactコンポーネント内でも利用できるという点で、これのおかげでクライアントサイドでコレクションのデータを参照してアレコレできるわけです。

このReactコンポーネントをAstroワールドに配置します。検索ページのファイルは src/pages/search.astro に作成しました。 /search のURLでアクセスできます。

---
import { SearchForm } from "../components/SearchForm.tsx"
---
<h1>検索</h1>
<SearchForm client:load />

import してテンプレートに設置するだけなので難しい事はありません。いえ、 ReactコンポーネントをAstroテンプレートにそのまま設置できるのはとってもすごい事なのですが、それを意識させずに簡単にできちゃうところがAstroの素晴らしさだと思います。

注意したいのは client:load ディレクティブです。Astroテンプレートではコンポーネントはすべて静的な要素として出力されるのですが、 client ディレクティブを付与することでハイドレーションを機能させ、クライアントサイドJSが働くようにしてくれるのです。

client ディレクティブには load の他にも idle visible media などがあり、クライアントJSがどのタイミングでブラウザに送信されるかを指定します。より詳しくはドキュメントにて。

まとめ

今現在このブログサイトは Astro で構築されています。

Astro は静的コンテンツが主軸となるWebサイトにはもちろん最適解となり得ますし、フレームワークコンポーネントとの連携で動的なアプリケーションも開発でき、技術的なスケーリングが可能です。そのうえ学習コストが安い。

今後チャンスがあったらぜひ採用してみたいフレームワークです。