Cloudflare Pages × HonoX によるブログサイト作成
2024年9月12日

Cloudflare Pages × HonoX によるブログサイト作成

8,573文字(読了まで約22分)
headerimage

私自身、元々エンジニアをやっていた者ですが、技術者としての勘が薄れていくことを危惧しています。半ば興味本位でもありますが、あまりこれまで触れてこなかった Web 技術に手を出しつつ、何かの形で人生に役立てることを目論見の内に、自身の学習記録をつけていきたいと思います。

ここでは学習やその他雑多な記録を付けるためのツールとして、また、それ自体を学習としたブログサイト構築を試みたいと思います。

  • 前提:自身が使える(かじったことのある)技術

    • Typescript
    • nodejs
      • express
      • nestjs
      • nextjs
      • hono
    • GCP cloudfunction
    • AWS lambda
      • lambda web adapter
    • LINE API
    • kintone
      • javascript カスタマイズ
      • plugin 開発
  • せっかくなので、新しいフレームワーク等を使ってみようかですが、言語まで習得するのは時間もかかり大変なので、そこまでは手を出しません。

  • API サーバとしてのhonoは体験していましたが、ビルドツールであるviteと連携したhonoxがあると知りました。

    • ファイルベースルーティングに対応しているようでメンテナンス性も高そう。調べるとブログサイトで採用してみたという事例も多く出てくるため、これをやってみます。

基本的な手順は、HonoX にワクワクしたので、Markdown 式の Wiki っぽいものを作ってみたを参考にしました。

  • create hono の後に出てくる選択肢で、x-basic を選択するとHonoXのテンプレートができあがる。

    (この x というのがHonoXの X なんでしょうね)

    shell
    npm create hono@latest
    # 実行後、表示される対話形式の選択で x-basic を選択
    # dependencies をインストール
  • このときのディレクトリ構成は下のようになる(公式より)

    .
    ├── app
    │   ├── global.d.ts // global type definitions
    │   ├── routes
    │   │   ├── _404.tsx // not found page
    │   │   ├── _error.tsx // error page
    │   │   ├── _renderer.tsx // renderer definition
    │   │   ├── about
    │   │   │   └── [name].tsx // matches `/about/:name`
    │   │   └── index.tsx // matches `/`
    │   └── server.ts // server entry file
    ├── package.json
    ├── tsconfig.json
    └── vite.config.ts

mdx は、markdown + react コンポーネント(Hono の場合は hono/jsx が同等の機能)を使える形式です。 vite で plugin を使えば、ビルド時に web ページとして表示可能なように変換されてそうです。

  • 必要な dependencies を導入(公式

    npm i @mdx-js/rollup remark-frontmatter remark-mdx-frontmatter
  • vite のビルド設定vite.config.tsを変更

    vite.config.ts
    import pages from "@hono/vite-cloudflare-pages";
    import adapter from "@hono/vite-dev-server/cloudflare";
    import honox from "honox/vite";
    import client from "honox/vite/client";
    import { defineConfig } from "vite";
    import mdx from "@mdx-js/rollup";
    import remarkFrontmatter from "remark-frontmatter";
    import remarkMdxFrontmatter from "remark-mdx-frontmatter";
     
    export default defineConfig(({ mode }) => {
      if (mode === "client") {
        return {
          plugins: [client()],
        };
      } else {
        return {
          build: {
            emptyOutDir: false,
          },
          plugins: [
            honox({ devServer: { adapter } }),
            pages(),
            mdx({
              jsxImportSource: "hono/jsx",
              remarkPlugins: [remarkFrontmatter, remarkMdxFrontmatter],
            }),
          ],
        };
      }
    });

実際にコンテンツとしてのブログ記事を mdx ファイルで書いてみます。

  • ./app/routes/postsディレクトリを用意し、その中に mdx 形式の記事ファイルを作成

    ./app/routes/posts/testpost.mdx
    ---
    date: 2024-09-12_02
    title: testpost
    ---
     
    ## Hello world
     
    test post

ここで、冒頭の---で囲っている部分は、frontmatter と呼ばれ、記事に関するメタデータを記述できるものです。HonoX のフレームワークによって html ページのタイトルなどとして利用することができます(具体的な設定は後述)。

先に述べたとおり、ブログ記事にはメタデータ(frontmatter)を埋め込むことができます。これを HonoX で作るプログラム中で型安全に扱得るようにしたいと思います。

  • メタデータ(frontmatter)として設定したい項目を型として定義
./app/types.ts
export type Meta = {
  postTitle: string;
  postDate: string;
};
  • HonoX のレンダラがこの型を認識できるよう設定

    app/global.d.ts
    import type {} from "hono";
     
    type Head = {
      title?: string;
    };
     
    declare module "hono" {
      interface ContextRenderer {
    -    (content: string | Promise<string>, head?: Head):
    +    (content: string | Promise<string>, head?: Head & { frontmatter?: Meta }):
          | Response
          | Promise<Response>;
      }
    }

ここで、Head は元々設定されているもので、mdx 以外のページでメタデータを引き渡す場合のために残しています(mdx 以外のページでは、frontmatter を設定する想定とはしないため、frontmatter は nullable としています)。

個別のブログ記事以外に、記事の一覧を表示させるためのトップページを用意します。

  • HonoXでは、./app/routes/直下がサイトトップのパスとなります。./app/routes/index.tsxを編集し、./app/routes/postsディレクトリ内の mdx ファイルの情報を取得し一覧表示するようにします。

    ./app/routes/index.tsx
    import { css } from "hono/css";
    import { createRoute } from "honox/factory";
    import { Meta } from "../types";
     
    export default createRoute((c) => {
      // mdxファイルとして作成したブログ投稿の一覧を取得する
      const posts = import.meta.glob<{ frontmatter: Meta }>("./posts/*.mdx", {
        eager: true,
      });
     
      return c.render(
        <div>
          <ul>
            {Object.entries(posts).map(([filepath, module]) => {
              if (module.frontmatter) {
                return (
                  <li>
                    <a href={`${filepath.replace(/\.mdx$/, "")}`}>
                      {/* ブログ投稿のメタデータ(frontmatter)の値から一覧を作成 */}
                      {module.frontmatter.postDate ?? "        "} : {module.frontmatter.postTitle}
                    </a>
                  </li>
                );
              }
            })}
          </ul>
        </div>,
        // 投稿一覧ページのメタデータを設定
        { title: "投稿一覧" }
      );
    });
  • 少しわかりにくいですが、c.render に引き渡している2つ目の引数{ title: "投稿一覧" }は、後述する_renderer.tsxに引き渡すメタデータとなります。app/global.d.tsに定義されていた Head の型として、メタデータを設定します。

各ページに共通する見出し等を設定します。

  • _renderer.tsxを編集。frontmatter の値などを用いて、各ページで共通的に表示する見出し等を設定

    ./app/routes/_renderer.tsx
    import { Style } from "hono/css";
    import { jsxRenderer } from "hono/jsx-renderer";
    import { Script } from "honox/server";
     
    export default jsxRenderer(({ children, title, frontmatter }) => {
      // frontmatterの有無(mdxで作成した記事かどうか)で、表示内容を切り替え
      const { headerTitle, bodyTitle } = frontmatter
        ? {
            headerTitle: frontmatter.title,
            bodyTitle: "[" + frontmatter.date + "] " + frontmatter.title,
          }
        : { headerTitle: title, bodyTitle: title };
     
      return (
        <html lang="en">
          <head>
            <meta charset="utf-8" />
            <meta
              name="viewport"
              content="width=device-width, initial-scale=1.0"
            />
            {/* ヘッダタイトル*/}
            <title>{headerTitle}</title>
            <link rel="icon" href="/favicon.ico" />
            <Script src="/app/client.ts" async />
            <Style />
          </head>
          <body>
            <a href="/">home</a>
            {/* 共通的な見出し */}
            <h2>{bodyTitle}</h2>
            {children}
          </body>
        </html>
      );
    });
  • chirdrenには、トップページの場合、./app/routes/index.tsxから返される react(jsx)コンポーネントが、mdx ファイルの場合は、vite plugin で変換された後の react(jsx)コンポーネントが、それぞれ渡されてきます。

  • _renderer.tsxとしては、メインのコンテンツを表示する部分にこのchildrenを設定しておきます(この例では body タグの中で、見出しの後に置いています)

  • テンプレートで元々 npm スクリプトを用意してくれていますので実行
    • `npm run dev'
  • 投稿一覧画面 投稿一覧画面
  • 個別の投稿画面 個別の投稿画面

  • 今のところ広くインターネットに公開したいかというとそうでもないため、basic 認証でいいので認証をしかけておきます

    • 普通のHonoサーバのように、単純に/app/server.ts上の app に middleware を設定するだけでは動作しませんでした(ファイルベースの route が設定されたあとに、use が設定されてしまうためと見られます)。

    • テンプレートで元々createAppの処理が記述されており、中で色々やってそうです。その前にベースとなる app を用意し、その時点で use を設定すれば良いようでした。

      /app/server.ts
      import { basicAuth } from "hono/basic-auth";
      import { showRoutes } from "hono/dev";
      import { createHono } from "honox/factory";
      import { createApp } from "honox/server";
       
      // createApp前にベースとなるappを用意し、middlewareを設定する
      const base = createHono();
      base.use(
        "*",
        basicAuth({
          username: "ogrtk",
          password: "ogrtk",
        })
      );
       
      const app = createApp({ app: base });
       
      showRoutes(app);
       
      export default app;

  • github と接続することで、github の main ブランチに更新があったら pages に反映するよう設定が可能になります
  • 詳細のスクショとか取ってませんでした・・

  • 画像等の静的リソースの扱い
    • 問題点
      • 最初、mdx ファイル中で画像を利用する場合に、/app/routes/posts/imgディレクトリ配下に画像を配置しましたが、この場合、開発環境とデプロイ時の環境とで異なるパスでアクセスしなければなりません。
      • // import { serveStatic } from "@hono/node-server/serve-static";を使うことで解決できるかとも思われましたが、開発環境では動作するものの、node.js の fs が利用されていることで、デプロイ時のエラーがうまく回避できませんでした。
    • 解決法
      • 公式ドキュメントにあるとおりですが、解決法としては、/public/static以下にファイルを配置し、プログラム中からは/static/xxxとアクセスすればよいようです。

  • HonoX を使うことでファイルベースルーティング可能な Web サイトを手早く構成できる
  • vite の処理と組み合わせ、markdown 用のプラグインを導入することで、mdx 形式でブログ記事を作成できる
  • 静的ファイルの配置場所に注意(公式をよく読もう)

- コメント -

    このサイトではcookieを利用して、サイト訪問者の識別に利用します。 cookieの利用に同意いただくことで、サイト訪問者は記事のいいね機能等をご利用いただけます。 なお、サイト運営者はアクセス統計としてcookieの情報を利用する場合があります。