ブログをAstroからHonoのSSGに移行しました

このブログは元々Astroを使って生成していました。

Astroはサクッとコンテンツメインのサイトが作れて便利なんですが、Blogを作り、育み、慈しむ ~ Blog Hacks 2024というスライドを見て、もうちょっと「好きなもの」を使って作り変えるのも良いかなと思っていました。

同じくらいの時期にHonoのv4でSSGが可能になり、触ってみたい気持ちが高まっていたので移行してみました。

Honoは以前、CloudFlare WorkersでLINE botを作ったときに使ったんですが、かなり感触が良かったので定期的に状況をウォッチしていました。
Hono自体も良いですし、作者さん(@yusukebe)のブログがワクワクする内容で好きなんですよね。
Honoのv4が2月9日にリリースされますとか、OSSで世界と戦うために - ゆーすけべー日記とか。

移行作業について

移行と言いつつ既存のコードはほとんど残っておらず、記事以外はほぼ作り直しになりましたが、Honoはシンプルで公式ドキュメントもexampleが分かりやすく、先行事例もあったので、大きくハマって悩むこともなく作れました。
ざっくり以下のような流れで作りました。

  • yusukebe/hono-ssg-exampleを参考に最小構成で動くことを確認
  • remarkを使ってMarkdownからHTML生成→表示確認
  • CSSを適用
  • sitemapを追加
  • RSSフィード作成
  • デプロイして動作確認

やったこと

私が雑にお試しして試行錯誤しているリポジトリはGitHub - tkancf/sandboxです。

最小構成で動かしてみる

まずは、Hono作者さんのyusukebe/hono-ssg-exampleを参考に別のリポジトリでとりあえず生成できることを確認するだけのコードを用意してみました。
yusukebe/hono-ssg-exampleはHono v4公開前にpatchを適用して作成しているexampleだったので、改変しつつ動かしてみました。
この辺りのcommitが最小構成で動くことを確認できた所だと思います。

Hono v4紹介記事では、静的ページを生成するためにbuild.tsというファイルを作っていましたが、@hono/vite-ssgというViteのプラグインを使えばbuild.tsは不要でした。
細かい部分は該当commitをみてもらうと分かりますが、ポイントを一部抜粋して記載します。

以下がvite.config.tsです。
@hono/vite-ssgを使うと、vite build実行時に各ページを生成してくれます。

import { defineConfig } from "vite";
import ssg from "@hono/vite-ssg"; // 追加
import devServer from "@hono/vite-dev-server";
 
const entry = "src/index.tsx";
 
export default defineConfig(() => {
  return {
    plugins: [devServer({ entry }), ssg({ entry })], // ssg({entry})を追加
  };
});

src/index.tsxについてはほぼ見た通りで、該当コードはこちらです。
ssgParams(() => posts),の部分は、Path Parameterがついた各パスに静的に生成されるパスを割り当てるためのミドルウェアです。
これがないと、/posts/hogeみたいなページが生成されません。

私はNext.jsを触ったことがないので知りませんでしたが、Next.jsのgenerateStaticPathsと似た機能らしいです。

app.get(
  "/posts/:id",
  ssgParams(() => posts),
  (c) => {
    return c.render(<h1>{c.req.param("id")}</h1>);
  }
);

Markdownから記事生成

ここはHono関係なく、Astroのブログテンプレートだとデフォルトで用意されていたMarkdown→HTML変換を自前で用意する工程です。
Astroのドキュメントを見るとremarkというライブラリを使っていると書いていたので、同じライブラリを使って同様の機能を用意しました。
該当コードはsrc/lib/post.tsです。remarkを使えば簡単に用意できました。

こんな感じでパースできます。便利。

      const content = fs.readFileSync(filePath, { encoding: "utf-8" });
      const result = await remark()
        .use(remarkParse)
        .use(remarkFrontmatter, [
          { type: "yaml", marker: "-", anywhere: false },
        ])
        .use(remarkExtractFrontmatter, {
          yaml: yaml.parse,
          name: "frontMatter",
        })
        .use(remarkExpressiveCode, {
          theme: "github-light",
        })
        .use(remarkGfm)
        .use(remarkRehype, { allowDangerousHtml: true })
        .use(rehypeStringify, { allowDangerousHtml: true })
        .use(remarkGfm)
        .process(content);

sitemapの生成

こちらもAstroのブログテンプレートだとデフォルトで用意されていましたが、自前で生成してあげる必要があります。
とは言ってもコード書くまでもなかったです。

vite-plugin-sitemapを入れて、vite.config.tsに設定を書いてあげるだけで完了しました。

追加後のvite.config.ts

import { defineConfig } from "vite";
import ssg from "@hono/vite-ssg";
import devServer from "@hono/vite-dev-server";
import Sitemap from "vite-plugin-sitemap";  // vite-plugin-sitemapをいれて
import { baseURL } from "./src/lib/constants";
 
const entry = "src/index.tsx";
 
export default defineConfig(() => {
  return {
    plugins: [
      devServer({ entry }),
      ssg({ entry }),
      Sitemap({ hostname: baseURL, generateRobotsTxt: true }), // ←追加した設定はこの部分
    ],
  };
});

RSSフィードの生成

こちらもAstroのブログテンプレートだとデフォルトで用意されていたRSSフィードを生成してあげます。
記事一覧のデータさえ用意出来ていればrssを利用してサクッと作れます。

import RSS from "rss";
 
(中略)
 
const generateFeed = async () => {
  const rss = new RSS({
    title: siteName,
    site_url: baseURL,
    description: siteName,
    feed_url: baseURL + "/feed",
    generator: siteName,
  });
  posts.forEach(async (post: any) => {
    const url = baseURL + "/blog/" + post.slug;
    rss.item({
      title: post.title,
      url: url,
      date: new Date(post.pubDate),
      description: post.description,
    });
  });
 
  return rss.xml();
};
 
app.get("/feed", async (c) => {
  const feeds = await generateFeed();
  return c.text(feeds, 200, {
    "Content-Type": "text/xml",
  });
});

CSSの適用

Honoにはhono/cssというCSS in JSのヘルパーがあるので、それを利用しました。

グローバルなCSSをどう適用すれば良いのかちょっと試行錯誤しましたが、以下のようにhtmlタグに全体適用したいCSSを付与してあげて、headタグ内に <Style />を書いておけば良い感じになりました。

export const Layout: FC = (props) => {
  return (
    <html class={globalCSS}> // ここでグローバルなCSSを適用
      <Head metadata={props.metadata} />
      <Header {...props} />
      <main>{props.children}</main>
      <Footer />
    </html>
  );
};
export const Head: FC = (props) => {
  return (
    <head>
      <title>{props.metadata.title}</title>
      <Style /> // これが必要
      <link rel="icon" type="image/svg+xml" href="/favicon.ico" />
      <link rel="sitemap" href="/sitemap.xml" />
      <meta charset="utf-8" />
(中略)
    </head>
  );
};

DOCTYPEの設定

jsxRendererミドルウェアを利用して以下のように付与できます。最初jsxRendererミドルウェア要らないかなと思って消していたんですが、PageSpeed InsightsでDOCTYPEが無いと警告されました。

jsxRendererミドルウェアを利用していれば、デフォルトで付与されます。{ docType: false }とすることで明示的に消すことも可能なようです。

import { jsxRenderer } from "hono/jsx-renderer";
(中略)
app.all(
  "*",
  jsxRenderer(
    ({ children }) => {
      return <>{children}</>;
    },
    { docType: "<!DOCTYPE html>" }
  )
);

デプロイ

Astroを利用していたときからCloudflare Pagesでホストしていたので、そこは変えませんでした。
GitHubリポジトリと接続してあるので、Cloudflare Pagesの設定画面からBuild configurationsだけ今回構築したものと合わせてあげるだけでOKです。

※ただ、今回はoutput directoryも何も変わらなかったので全部そのままです。

yusukebe/hono-ssg-exampleを参考にしたんですが、package.jsonに以下のようなスクリプトがあると、./dist配下をCloudflare Pagesへすぐにデプロイできます。
ある程度完成した段階で「Cloudflare Pages上で動き見てみたいな」と思ったときにシュッと確認できて便利でした。

  "scripts": {
(中略)
    "deploy": "$npm_execpath run build && wrangler pages deploy ./dist --commit-dirty=true"
  },

まとめ

以上、AstroからHonoのSSGへの移行記録でした。
最終的な成果物はGitHub - tkancf/tkancf.comにあるので、興味があれば見てみてください。

Astroがよしなにやってくれてたんだな〜というありがたみを感じつつ、思った以上にシュッとHonoへ移行できたので良かったです。

よく分からずにAstroデフォルトのテンプレートを使ってる状態から脱したので「自分で作った感」が得られたのが一番良いですね。
今後もブログ弄りが捗りそうです。

参考資料

下記リンク先を参考にさせていただきました。大感謝です。