(19時間前)更新日:

Hono/HonoXを使ったブログの作成と技術スタック

𝕏XでシェアB!はてなブックマークLINEでシェア
コピーしました!

当ブログ ぽんろぐ備忘録 の技術スタックや特徴的な機能について紹介します。

目次

テーマの原点と感謝

このブログは、p1ass 氏が公開されているブログリポジトリをベースにして構築しています。

p1ass/blog — p1ass氏のブログリポジトリ

p1ass 氏のリポジトリは Hono / HonoX + Vite + MDX という構成で、SSG によるブログの実装例として非常に参考になりました。ファイルベースルーティングや MDX カスタムコンポーネントの設計、Cloudflare Pages へのデプロイ構成など、多くの部分で氏のコードから学ばせていただいています。

素晴らしいリポジトリを公開してくださった p1ass 氏に、心から感謝いたします。

また、デザインの原点として Chester How 氏が公開された Jekyll テーマ「Tale」と、それを Hugo に移植された Emiel Hollander 氏の「tale-hugo」にも影響を受けています。MIT ライセンスで公開してくださったことに感謝いたします。

現在のブログは p1ass 氏のリポジトリをベースに、自分好みのカスタマイズを重ねて独自のブログへと育てています。

技術スタックの概要

レイヤー技術
フレームワークHono / HonoX
ビルドツールVite
記事フォーマットMDX (remark / rehype プラグイン)
スタイリングhono/css (CSS-in-JS) + Tailwind CSS
デプロイ先Cloudflare Pages
コメントgiscus (GitHub Discussions)
リンターBiome
E2E テストPlaywright

SSG(静的サイト生成)アーキテクチャ

当ブログは @hono/vite-ssg を使ってビルド時にすべてのページを静的 HTML として生成しています。ランタイムでのサーバー処理はなく、Cloudflare Pages から静的ファイルを配信するだけなので、非常に高速です。

サーバーのエントリーポイントはわずか数行です:

import { showRoutes } from 'hono/dev'
import { createApp } from 'honox/server'

const app = createApp({
  trailingSlash: true,
  init(_app) {},
})

showRoutes(app)
export default app

Vite の設定で SSG プラグインをチェーンすることで、このシンプルなサーバー定義から静的サイトが生成されます:

plugins: [
  honox(),
  mdx({
    jsxImportSource: 'hono/jsx',
    providerImportSource: './app/lib/mdx-components',
    remarkPlugins,
    rehypePlugins,
    recmaPlugins: [recmaExportFilepath],
  }),
  tailwindcss(),
  ssg({ entry }),
  viteStaticCopy({ /* 記事画像を dist にコピー */ }),
]

ファイルベースルーティング

HonoX のファイルベースルーティングにより、app/routes/ ディレクトリの構造がそのまま URL パスに対応します。記事は app/routes/posts/<slug>/index.mdx に配置するだけで自動的にルートが生成されます。

app/routes/
  index.tsx          → /
  about/index.tsx    → /about/
  posts/
    hello-world/
      index.mdx      → /posts/hello-world/
    riss/
      index.mdx      → /posts/riss/
  categories/
    [id]/index.tsx   → /categories/:id/
  tags/
    [id]/index.tsx   → /tags/:id/

記事データの取得には Vite の import.meta.glob を使い、ビルド時にすべての MDX ファイルを一括読み込みしています:

const posts = import.meta.glob<MDXExports>(
  '../routes/posts/**/*.mdx',
  { eager: true }
)

MDX によるリッチな記事執筆

記事は MDX 形式で書いており、Markdown の中に JSX コンポーネントを埋め込めます。useMDXComponents プロバイダでカスタムコンポーネントを自動登録しているため、記事内で import を書く必要がありません。

export function useMDXComponents(): MDXComponents {
  return {
    img: Image,
    pre: StyledPre,
    blockquote: BlockQuote,
    a: Link,
    BlockLink: BlockLink,
    Note: Note,
    Toc: Toc,
    Marker: Marker,
    Bold: Bold,
    Twitter: Twitter,
    // ...
  }
}

これにより、記事の中で以下のようにカスタムタグを使えます:

<Toc>
- セクション1
- セクション2
</Toc>

<Note>
  これは補足情報です。
</Note>

<Marker color="pink">ハイライトされたテキスト</Marker>

remark / rehype プラグインも豊富に導入しており、GFM(GitHub Flavored Markdown)、シンタックスハイライト、Mermaid 図、見出しスラッグなどに対応しています:

export const remarkPlugins: PluggableList = [
  remarkFrontmatter,
  remarkMdxFrontmatter,
  remarkGfm,
]

export const rehypePlugins: PluggableList = [
  rehypeSlug,
  rehypeHighlight,
  rehypeMdxCodeProps,
  rehypeMdxImportMedia,
  rehypeMermaid,
]

Island Architecture

当ブログでは HonoX の Island Architecture を採用しています。ページのほとんどは静的 HTML として配信し、クライアントサイドで JavaScript が必要な部分だけを「アイランド」として個別にハイドレートします。

現在、唯一のアイランドコンポーネントはヘッダーのパーティクルアニメーションです:

// app/islands/HeaderParticles.tsx
// islands/ に配置するだけでクライアントサイドで自動ハイドレート
import { useEffect, useRef } from 'hono/jsx/dom'

export default function HeaderParticles() {
  const canvasRef = useRef<HTMLCanvasElement>(null)

  useEffect(() => {
    const canvas = canvasRef.current
    if (!canvas) return
    // Canvas パーティクルアニメーション
    // マウスカーソルに反応してパーティクルが逃げる物理演算
  }, [])

  return <canvas ref={canvasRef} />
}

app/islands/ ディレクトリに配置するだけで HonoX が自動的にクライアントサイドでハイドレートしてくれます。これにより、ページ全体の JavaScript バンドルサイズを最小限に抑えつつ、必要な部分だけにリッチなインタラクションを持たせることができます。

ゼロフレームワークなインタラクション

ダークモード切替、検索、シェアドロップダウン、ビュー切替(リスト / グリッド)など、ほとんどのインタラクションはバニラ JavaScript で実装しています。React のようなクライアントサイドフレームワークをランタイムで読み込む必要がないため、ページが非常に軽量です。

たとえばビュートグルは、data-active 属性でスタイルを制御しています:

&[data-active="true"] {
  background-color: var(--c-accent-bg);
  border-color: var(--c-accent);
  color: var(--c-text);
}

サイドバーのカテゴリアコーディオンは、ネイティブの <details> / <summary> 要素を使っており、JavaScript は一切不要です:

<details open>
  <summary>
    <h3>カテゴリー</h3>
    <span><svg class='sidebar-chevron'>...</svg></span>
  </summary>
  <ul>
    {categories.map(cat => (
      <li><a href={`/categories/${cat.id}/`}>{cat.name}</a></li>
    ))}
  </ul>
</details>

ダークモード

ダークモードは FOUC(Flash of Unstyled Content)を防ぐために、<head> 内のインラインスクリプトで即座に適用しています。localStorageprefers-color-scheme メディアクエリを組み合わせて、ユーザーの設定を尊重します:

(function(){
  var s = localStorage.getItem('theme');
  var prefersDark = window.matchMedia(
    '(prefers-color-scheme:dark)'
  ).matches;
  if (s === 'dark' || (s !== 'light' ? prefersDark : false)) {
    document.documentElement.classList.add('dark');
  }
})();

テーマの色は約 40 個の CSS カスタムプロパティで管理しており、.dark クラスの有無で light / dark を切り替えます。コンポーネントからは var(--c-*) 経由で参照するだけなので、テーマ定義とスタイリングが明確に分離されています:

// app/styles/color.ts
export const gray = 'var(--c-text)'
export const grayLight = 'var(--c-text-muted)'
export const blue = 'var(--c-accent)'
export const background = 'var(--c-bg)'

OGP 取得と外部リンクカード

<ExLinkCard> コンポーネントは非同期サーバーコンポーネントとして実装されています。SSG ビルド時に外部 URL の OGP 情報を取得し、リッチなリンクカードとして静的 HTML にベイクします:

export async function ExLinkCard({ url }: Props) {
  const ogp = await fetchOgp(url)
  return (
    <a href={url}>
      {ogp.Image?.length ? (
        <img src={ogp.Image[0].URL} alt={ogp.Title} />
      ) : null}
      <p>{ogp.Title}</p>
      <div>{ogp.Description}</div>
      <span>{new URL(url).host}</span>
    </a>
  )
}

OGP データはインメモリキャッシュで重複フェッチを防いでいます。ビルド時に取得するため、ランタイムでの API コールは一切発生しません。

giscus によるコメント機能

コメント機能には GitHub Discussions ベースの giscus を使っています。特筆すべきは、ダークモード切替と連動して giscus の iframe にリアルタイムでテーマを同期する仕組みです:

var observer = new MutationObserver(function() {
  var nowDark = document.documentElement.classList.contains('dark');
  var iframe = container
    .querySelector('iframe.giscus-frame');
  if (iframe && iframe.contentWindow) {
    iframe.contentWindow.postMessage(
      { giscus: {
          setConfig: { theme: nowDark ? 'dark' : 'light' }
      }},
      'https://giscus.app'
    );
  }
});
observer.observe(document.documentElement, {
  attributes: true,
  attributeFilter: ['class']
});

MutationObserver<html> 要素の class 属性変化を監視し、postMessage で giscus iframe のテーマをリアルタイムに切り替えています。

SEO 対応(RSS / Sitemap / robots.txt)

RSS フィード、サイトマップ、robots.txt はすべて HonoX のルートハンドラとして実装しており、ビルド時に静的ファイルとして生成されます:

// app/routes/index.xml.ts — RSS フィード
export default createRoute(c => {
  const rss = generateRss(getAllPosts())
  return c.text(rss, 200, {
    'Content-Type': 'application/xml',
  })
})

XML テンプレートエンジンを使わず、Array.map() と文字列テンプレートだけで RSS 2.0 を生成するシンプルなアプローチです。

Cloudflare Pages へのデプロイ

デプロイは wrangler コマンド一発で完了します:

pnpm deploy
# → vite build --mode client && vite build && wrangler pages deploy ./dist

dist/ ディレクトリに生成された静的ファイルを Cloudflare Pages にデプロイするだけなので、サーバーの管理は不要です。Cloudflare のグローバル CDN により、世界中から高速にアクセスできます。

まとめ

当ブログの技術的な特徴をまとめると:

  • Hono / HonoX による SSG — 軽量フレームワークで静的サイトを生成
  • Island Architecture — クライアント JS は必要最小限
  • バニラ JS によるインタラクション — ランタイムフレームワーク不要
  • MDX + カスタムコンポーネント — リッチな記事執筆体験
  • Cloudflare Pages — エッジ配信で高速アクセス

p1ass 氏のおかげで、こちらのブログを作成することができました。今後もカスタマイズを続けていきたいと思います。

またカスタマイズにあたっては、Claudeとdevinを活用しました。最近のAIエージェントの発達には驚きます。 課金の力を感じます。

何か質問やフィードバックがあれば、下のコメント欄からお気軽にどうぞ!

関連記事

カテゴリー