ぷらすのブログ

個人ブログをHugoからHonoに移行しました

#開発#ブログ#Hono#Hugo

こんにちは、@p1assです。 先日、本ブログの静的サイトジェネレーター (SSG) をHugoからHonoに切り替えました。

旧Hugoのブログは2019年に作成したものなので、おおよそ5年間ほど使い続けていたことになります。

この記事では、長く使っていたHugoをやめる決断をしたモチベーションや、Honoを採用した理由、実際にHonoを使っていく上で工夫したポイントについて紹介します。

Hugoをやめるモチベーション

テンプレートエンジンがそろそろ辛くなってきた

Hugoは静的サイトジェネレーターの1つです。 ビルドが高速である点や、ブログで役に立つ機能が多く含まれていた点が魅力的で、2019年に採用しました。

当初は便利に使っていたものの、月日が経つにつれてスタイル面でのカスタマイズに不便さを感じるようになりました。 HugoはGo製のツールということもあり、HTMLのカスタマイズはほぼほぼGoのhtml/templateと同じ仕組みが使われています。

html/templateはよくあるテンプレートエンジンです。{{ }} で囲まれたブロックを使って条件分岐やループをしながら、HTMLを生成することができます。

{{ if .IsNamedParams }}
<div style="padding-bottom:16px">
<a href="{{ .Get "href" }}" target="_blank" rel="noopener noreferrer">{{ if .Get "text"}}{{ .Get "text" }}{{ else }}{{ .Get "href"}}{{ end }}</a>
</div>
{{ else }}
<div style="padding-bottom:16px">
<a href="{{ .Get 0 }}" target="_blank" rel="noopener noreferrer">{{ .Get 0 }}</a>
{{ end }}
</div>

旧Hugoで作られたブログのコードの一部

Hugoでブログを作った当時はブログをがっつりカスタマイズする予定がなく、テンプレートエンジンで十分だと考えていました。 しかし、実際にはブログのカスタマイズが楽しくなっちゃって、どんどんテンプレートエンジンのコードが増えていきました...。

テンプレートエンジンのコードはコード補完や型の恩恵を受けづらく、開発者体験が良くないです。 ブログ以外の活動ではReact (JSX)を使ったコードを書くことが多く、JSXと比較したときに「つらいな〜」 と感じていました。

また、npmのエコシステムとの相性が悪く、便利なサードパーティーライブラリを入れるのにも苦労していました。 「今更 <script> でライブラリを読み込むの...?」という気持ちになり、脱テンプレートエンジンの機運が高まってきました。

こういった事情があり、Hugoを脱することとしました。

Honoとは?

HonoはJavaScriptのWebフレームワークです。 @yusukebeさんが開発しているOSSで、今勢いのあるフレームワークの1つです。

Honoの公式ドキュメント Honoの公式ドキュメントのトップページ

詳しい説明は公式ドキュメントやyusukebeさんの記事をご覧ください。

Honoを採用した理由

マルチランタイムのコンセプトが良いと感じていた

Honoの特徴の1つに複数のJavaScriptランタイムで動くというものがあります。 JavaScriptのランタイムと言えばNode.jsが一般的ですが、Honoでは新興ランタイムであるDenoBunなど複数のランタイムに対応しています。 そのため、私達開発者は1度コードを書けば、どののランタイムで動作させることができます。

このような「1つのインターフェース (今回はHono) を満たしておけば、詳細な実装が差し替え可能」という仕組みは、 PerlのPSGI・Plackを初めて知ったときに感心し、似たような思想を持つHonoも同様に良さそうと感じました。 (今回はSSGしちゃってるのでランタイムはあまり関係なくなってしまいましたが...)

サーバサイドを軸にしている点がサーバーサイドエンジニアである私とマッチする

私の中では、Honoはサーバーサイドのエコシステムから派生したものだと認識しています。 ExpressやKoaといったNode.jsのサーバーフレームワークから進化したものといったイメージでしょうか。 そのため、サーバーサイドエンジニアである私から見ると、Honoはとっつきやすく、今後の活用のチャンスも見込めそうに感じました。

JSXが使え、SSGもできる

一方で、HonoはWebフロントエンドの機能にも力をいれており、JSXをレンダリングしたり、SSGでHTMLファイルを生成することも可能です。 Hono v4でこの機能が追加されたことで、HugoからHonoに置き換える気持ちがぐんっと高まりました。

Honoを使っていく上で工夫したポイント

URLの互換性を保つために

ブログにおいて、URLの互換性を保ち続けることは非常に大事なことです。 互換が崩れてしまうとリンク切れを起こしてしまうので、Hono移行で記事ページのURLの互換性が崩れないことは必須の条件として対応しました。

本ブログの記事URLのパスは https://blog.p1ass.com/posts/${postId}/ という形式になっています。 そのため、この形式で配信するには、dist/posts/${postId}/index.html をビルド時に生成する必要があります。

しかし、HonoのメタフレームワークであるHonoXを使ったファイルベースルーティングでSSGをすると、 ./posts/${postId}.html というファイルが生成されてしまうことがわかりました。 これだと先ほど示したURLとは異なる形式になってしまい、URLの互換性を保つことができません。

$ tree ./dist

dist
├── posts
│   ├── postId.html # これが生成されてしまう
│   └── postId
│      └── index.html # 理想はこれ
└── tags

どうしたもんかとしばらく悩んだ後、HonoXのissueに書き込みをしたところ、すぐにyusukebeさんから返信があり、このユースケースに対応したオプションを追加してくださいました! 爆速対応で驚きです。ありがとうございました 🙏

MDX内の画像パスを良い感じに解決する

記事内で表示する画像は index.mdx と同じディレクトリにコロケーションする形で配置しています。

$ tree app/routes/posts/migrate-to-hono

app/routes/posts/migrate-to-hono
├── hono_top_page.png # こんな感じ
└── index.mdx

このような配置にしているとき、マークダウンファイルでは次のような相対パスの書き方をしたくなります。

![画像](./hono_top_page.png)

しかし、このような書き方をしてしまうと、HTMLとしてレンダリングされるときにも、同じように相対パスで入力されてしまいます。

<!-- /posts/migrate-to-hono/index.html -->
<img src="./hono_top_page.png" />

パット見問題なさそうに見えますが、2つ問題があります。

まずビルド時に画像がコピーされない件については、Viteのプラグインであるvite-plugin-static-copyを使って画像をコピーするようにしました。

import { normalizePath } from 'vite'

// 記事内でco-locationして配置している画像たちを `dist/posts` にコピーする
viteStaticCopy({
  targets: [
    {
      src: [
        './app/routes/posts/**/*.png',
        './app/routes/posts/**/*.jpg',
        './app/routes/posts/**/*.jpeg',
        './app/routes/posts/**/*.webp',
      ],
      dest: 'posts',
      rename: (
        fileName: string,
        fileExtension: string,
        fullPath: string,
      ) => {
        const destPath = normalizePath(path.relative(__dirname, fullPath).replaceAll('app/routes/posts/', ''))
        return destPath
      },
      // 普通のviteのビルドで生成したファイルを消さないようにする
      overwrite: false,
    },
  ],
}),

次に、開発モードでサーバーを起動したときに、静的ファイルの配信URLが正しく解決されない件です。 こちらは期待している画像パスと実際に配信されている画像のパスが違うために発生します。

このようにパスが違うため、単に ./aaa.png のような書き方をすると画像が404になってしまいます。 また、上記のような /app/routes が含まれる形で画像が配信されるのは開発モードのときのみのため、import.meta.env.PROD で処理を分岐する必要があります。 (本番環境ではvite-plugin-static-copyによって正しいパスに画像をコピーしているため)

この問題は、MDXの img タグに対してカスタムコンポーネントをアタッチするようにし、そのカスタムコンポーネント内で、画像パスを書き換えることで対応しました。

JSX RendererでOGP情報を動的に埋め込む

HonoでJSXをRenderingするには、JSX Rendererを使います。 HonoXでは、_renderer.tsx ファイルを用意し、その中で jsxRenderer を呼び出せば、そのディレクトリ配下に対応するHTTPパスのレンダリング時に自動的にJSX Rendererが使われるようになります。

// app/routes/_renderer.tsx。この場合全てのパスで使われる。
import { jsxRenderer } from 'hono/jsx-renderer'

export default jsxRenderer(({ children, title }) => {
  return (
    <html lang='en'>
      <head>
        <meta charset='UTF-8' />
        <meta name='viewport' content='width=device-width, initial-scale=1.0' />
        {title ? <title>{title}</title> : <></>}
      </head>
      <body>{children}</body>
    </html>
  )
})

この機能はいわゆるLayoutやTemplateといった機能と同様に、ページをまたいで使うHTMLタグを差し込んでおくのに便利です。 本ブログでは、og:urlog:image といったOGP用のタグを挿入するために、jsxRendererを使っています。

export default jsxRenderer(({ children }, c /* ←これ */) => {
    return (
      // ...
      <meta property='og:url' content={`https://blog.p1ass.com${c.req.path}`}/>
      // ..
    )

少し前のバージョンでは、jsxRenderer でContextを取れないために、動的に og:url を設定することが難しかったのですが、Issueを起票したところこちらも対応してくださりました。🙏

おわりに

5年ぶりにブログを刷新しましたが、新しい技術を楽しみつつ、開発者体験も向上させることができました。 ただ、全ページちゃんとうまく動いているか確認が持てていないので、なにか不具合があればご連絡ください。

次の5年後にどうなっているか楽しみですね。

← Web Speed Hackathon 2024に参加して2位になりました
Topへ戻る