ブログをAstro に移行しました

式年遷宮の様な感じですが、数年おきにブログを作り直してます(前回)。今回は Gatsby でデザインした UI をほぼそのままに、フレームワークを Astro に移行しました。静的サイトの作成では Astro の開発者体験が最高に優れているので、2 年間ほぼ塩漬けにしてしまっていた Gatsby のコードを無事に移行できてよかったです。

Astro とは?

Astro は 一言で言うと、Better HTML です。Astro というフォーマットでサイトが記述できるのですが、普通の(素の)HTML も Astro としてそのまま使えます。厳密には違いますが、HTML のスーパーセットみたいな感じです。その HTML の要素群を component としてまとめることで関心を分離できて(この辺は Web Components でも実現できます)、必要に応じてビルド時にロジックも走らせることができますがロジックを Frontmatter という形で書けるので cohesive でもあります。以下が Astro component の例です:

---
const generator = Astro.generator;
---

<html lang="en">
  <head>
    <meta charset="utf-8" />
    <link rel="icon" type="image/svg+xml" href="/favicon.ico" />
    <meta name="viewport" content="width=device-width" />
    <meta name="generator" content={generator} />
    <title>Astro</title>
  </head>
  <body>
    <h1>Astro</h1>
  </body>
</html>

Frontmatter (ファイル先頭の---に挟まれた部分)は、ビルド時に TypeScript として処理されます。その下の部分は HTML のテンプレートなので PHP みたいなノリです。{...}の中では Frontmatter で宣言した変数が使えるので、動的にテンプレートを変更させられます。この例ではgeneratorという変数を埋め込んでいるだけですが、ループさせたり他の component を埋め込むこともできます。

Astro は異なる UI フレームワークを共存させられる

Astro component だけでサイトを作ることはもちろん可能ですが(このブログは 100% Astro component)、既存の UI フレームワーク(React, Vue, Svelte 等)の component を利用したいこともあります。例えば、僕の会社のサイト(OpsBR)では、 右上の select menu を出すのにHeadless UI の React component を使っていますが、それ以外は Astro component です。

Astro が驚異的なのは、これらの異なる UI フレームワークを一緒に使うことができる点です。クライアント側で動きのあるようなものはすでに React や Vue で良い component が存在することが多いので、わざわざそれを Astro で再開発する必要はなく、import してあげるだけでよいのです。なお、Astro は何も設定しなければこうしたフレームワークの吐き出す JavaScript をクライアントに渡しません。なので、どの JavaScript をクライアントに渡すかを調整することまでできます。

コード例はこんな感じ:

LanguageSelector (React)

普通の TSX のコードです。

import { Listbox } from "@headlessui/react";

interface Props {
  languages: Language[];
}

export default function LanguageSelector({ languages }: Props) {
  const currentLanguage = languages[0];
  return <Listbox value={currentLanguage}></Listbox>;
}

Index (Astro)

ポイントはclient:load (client:onlyでも良い)です。この指定で、ビルド時に React のコードが JavaScript に吐き出されます。

---
import LanguageSelector from "@components/LanguageSelector";
const languages = ["en", "ja"];
---

<div>
  <LanguageSelector client:load languages={languages} />
</div>

この様に恐ろしく単純に、恐ろしく難しいことができてしまいます。何なら 100% React component で作ることもできます1

この考え方の裏には Astro Islands というパターンがあります。以下のドキュメントを読むとよくわかります。

Astro はファイルベースの MPA が第一選択肢なので、SSG と相性が良い

ここは、Next.js と Gatsby のいいとこどりな感じです。Next.js はファイルベースでルーティングしてくれます。なので、URL のパスと同じようにページを作ると、そのままルーティングされるので直観的です。しかし、Next.js は SSR (Server-Side Rendering) 指向なので、SSG (Static Site Generation) しようとするといろいろと制約がついてきたり、そもそも注力されていくのかどうかが不明です。また、Markdown や MDX を使うには追加の努力が必要になります。

Gatsby は SSG 指向で MPA (Multi-Page Application) にビルドするのが普通です。Markdown や MDX のサポートも手厚いです。しかし、Gatsby は GraphQL を使ってルーティング等の情報を扱うので、無駄に複雑で直観的ではありません。

Astro は後発なこともあり、うまくいいとこどりをしています。src/pages以下に置いたファイルがそのまま 1:1 で HTML に変換されるので、HTML 手書きしていたおじさんからすると、最も直観的に動いてくれます。それだけでなく、Markdown や MDX が標準でサポートされていて、それらのファイルを置いておくだけで HTML への変換を自動でやってくれます。

なので、SSG をしたい場合には非常に優位性があります2。また、SSR もサポートしているので、Dynamic routing や API routing、Data fetching なども使いたければ使えます。SSR は僕はそんなに経験がないのでよく分からないですが、見た感じは最低限の機能はそろっている感じがします。

Astro は integration もシンプル

Gatsby の plugin は書くため/読むために非常にたくさんのことを学ばないといけなくてすごい苦労しました。Astro の integration はまだできたばかりということもあってシンプルですが、非常に強力ですし、読み書きにそこまで知識が必要ないです。ビルド周りのものであれば、単にフックを書くだけでよいです。OG image の生成は大した処理じゃないので自前で integration を書きましたが、 50 行程度です。

Astro は既にドキュメントやサポートが充実

Astro 1.0 が出たのは 2022 年 8 月です。しかし、その時点で既にドキュメントは今あるレベルに充実していて、驚きました(そもそも生まれてから 2 年も経ってない)。

また Discord もかなり活発で、困った時は Discord を検索すると同じ様なことを言っている人が見つかったりします。サポートの問い合わせも Discord でできてとても開発体験が良いです。

移行に際してやったこと

元々の Gatsby を作った時には React 何それ?ってレベルから始まったので、いろいろ手探りで作ってました。ただ、component の切り方は大体そのまま持ってこれてます。ただし、React は捨てたので全て Astro で実装しなおしていますが、見ての通りクライアントでの動きのないサイトなので、JSX を HTML (Astro) に直すだけで、ほぼコピペです。

CSS は Tailwind を CSS modules から使っていたんですが、結局実装からスタイルを切り離すメリットは何もなく無駄だったので、今は Astro の HTML にclassをべた書きしています。この方が Cohesive だし、Tailwind を普通に何の変哲もなく使うだけなので、躓くところもなく良いです。

ブログ投稿のページ生成は、開発やテスト用のデモ投稿を使いたいケースがあるので、src/pagesに直に置くのはやめて、src/postsにあるファイルを動的に集めて dynamic routing (build time) にしています。細かい処理は@utils/posts にありますが、ざっくりこんな感じです:

---
import BlogPostLayout from "@layouts/BlogPostLayout.astro";
import { getPostParams, getPosts } from "@utils/posts";

export async function getStaticPaths() {
  const posts = getPosts();
  return posts.map((post) => {
    const params = getPostParams(post);
    return {
      params,
      props: {
        post,
      },
    };
  });
}

const { Content, frontmatter } = Astro.props.post;
---

<BlogPostLayout frontmatter={frontmatter}>
  <Content />
</BlogPostLayout>

めっっっっっちゃ簡単です。同じのを Gatsyby で書こうと思うとあの GraphQL と Plugin 毎の仕様と格闘しないといけないので、不毛です。

あと、投稿の一番下の Twitter 等の共有ボタン、以前はあれを足すのに plugin の仕様やら Gatsby の仕様やらをあれこれ調べてようやくなんとか動く方法を見つけた気がするんですが、Astro であれば HTML を一式コピペするだけで瞬殺でした(Frontmatter 外の JavaScript は自動的にクライアントに送られるので)。

前回の最大の問題がテストを全く書いてなかったことなので、今回は以下のテストを書いてます:

  • distに対するテスト(SEO がちゃんと入ってるか、favicon があるか、404 や RSS、Sitemap は大丈夫か)
  • E2E テスト(デモ投稿でスクリーンショットの比較や、シェアボタンが表示されてるかのテスト)

全然足りてはいないですが、無いよりははるかにましですね。正直、テストを書く(およびテストできる形にする)のに一番時間を使いました。なので、移行そのものは元々 React を単に component 分離のためにしか使ってなかったのと、デザインは Tailwind でやっていたので、ほとんどコピペのようなものでした。

あと、記事の中に Tweet のリンクや YouTube のリンクがあると自動で埋め込んでくれる plugin を使ってたんですが、同じことを Astro で remark plugin を使って試してはみたものの、正直それより普通に埋め込み HTML をコピペした方が速いしメンテが楽ということに落ち着きました。

ただ、Chart.js を埋め込んでる独自 component だけは作り直しが必要だったので、React から Astro に移植しました。実装結果は以下の通り。これは npm に出してもいいかもしれない、テスト何も書いてないけど。。。Web components を使うことで実装がシンプルになっています。

---
import type { ChartType } from "chart.js";
import { fromHtml } from "hast-util-from-html";
import { select, selectAll } from "hast-util-select";
import { toText } from "hast-util-to-text";
import RandomColor from "randomcolor";

const table = await Astro.slots.render("default");
const doc = fromHtml(table, { fragment: true });
const headerRow = select("thead tr", doc);
const dataRows = selectAll("tbody tr", doc);
if (!headerRow) throw new Error(`No header: ${table}`);
if (dataRows.length === 0) throw new Error(`No data: ${table}`);
const labels = headerRow.children.map((th) => toText(th)).slice(1);
const datasets = dataRows.map((tr) => {
  const [label, ...dataText] = tr.children.map((td) => toText(td));
  const color = RandomColor({ seed: label });
  const data = dataText.map(parseFloat);
  return {
    label,
    data,
    backgroundColor: color,
    borderColor: color,
    fill: false,
  };
});

interface Props {
  type: ChartType;
}

const { type } = Astro.props;
---

<chart-js
  data-type={type}
  data-labels={JSON.stringify(labels)}
  data-datasets={JSON.stringify(datasets)}
>
  <div class="relative">
    <canvas></canvas>
  </div>
</chart-js>

<script>
  import type { ChartType } from "chart.js";
  import Chart from "chart.js/auto";

  class ChartElement extends HTMLElement {
    constructor() {
      super();
      const ctx = this.querySelector("canvas") as HTMLCanvasElement;
      const type = this.dataset.type as ChartType;
      const labels = JSON.parse(this.dataset.labels!);
      const datasets = JSON.parse(this.dataset.datasets!);
      new Chart(ctx, {
        type,
        data: {
          labels,
          datasets,
        },
        options: {
          responsive: true,
        },
      });
    }
  }

  customElements.define("chart-js", ChartElement);
</script>

デプロイ先を AWS Amplify Hosting から Cloudflare Pages へ

最後にデプロイ先も変更しました。Amplify Hosting はそんなに悪くはなかったんですが、OpsBR のサービスの中で Cloudflare Pages および Functions を使ってみたかったので、先行して試してみました。もちろんブログでは Functions は一切使ってはいないんですが、Preview URL に対して Cloudflare Access で制限をかけれるのは非常に高感度高いですね。今時 Basic auth とか嫌です。Cloudflare Pages は個人利用なら Free plan でも十分なのがすごいですね。

ビルド環境の Node.js がデフォルトで 12 なのはビビりましたが、近々イメージ更新の予定があるようで頑張って欲しいです。

まとめ

本業でフロントエンドを実装する機会は 10 年以上働いてもまだないんですが、こうして定期的に自分のサイトで手を動かせているので最低限の知識はつけられていて、副業のサービス開発でいよいよ発揮する機会が出てきているので楽しみです。このブログもずっと更新が億劫だったのは Gatsby を塩漬けしたままにしてた部分もあるので、これからはテストもあるし依存パッケージもちゃんと更新しつつ記事ももっと書いていこうと思います。

Footnotes

  1. 実は OpsBR のサイトを作ってた時は、便利な CRA の代わり程度で Astro を捉えていたのでこの方針でした。

  2. これを使えば React で routing を考える必要がないことに気づいて、OpsBR のサイトは全面的に Astro 中心で構築することにしました。もちろんこのブログもその延長。