個人ブログをAstroからHonoXに作りかえる
はじめに
このブログは元々Astroで作られていましたが、HonoXに移行しました。移行の理由と移行時に実施した変更について書きます。
なぜHonoXに移行したのか
HonoXは、HonoをベースにしたフルスタックWebフレームワークです。Honoはエッジ環境で動作する軽量なWebフレームワークとして知られていて、HonoXはその上に構築されたフルスタックフレームワークです。
移行した理由は、Cloudflare Workersなどのエッジ環境で動作するのでより高速なレスポンスが期待できるのと、Astroよりもシンプルな構成で必要な機能だけを選択できること、あとは新しいフレームワークを試してみたかったからです。
移行時に実施した変更
ルーティング
Astroではsrc/pages/配下にファイルを配置することでルーティングが自動生成されていましたが、HonoXではapp/routes/配下にファイルを配置し、createRouteを使ってルートを定義します。
// app/routes/blog/[slug].tsx
import { createRoute } from 'honox/factory'
import { ssgParams } from 'hono/ssg'
export default createRoute(
ssgParams(async () => {
const posts = allPosts
return posts.map((post) => ({
slug: post.id,
}))
}),
async (c) => {
const slug = c.req.param('slug')
// ...
}
)
レンダリング
Astroでは.astroファイルでコンポーネントを定義していましたが、HonoXではJSXを使用します。レンダラーはapp/routes/_renderer.tsxで定義します。
// app/routes/_renderer.tsx
import { jsxRenderer } from 'hono/jsx-renderer'
export default jsxRenderer(
({ children, title, description, image, canonicalURL }) => {
return (
<html lang='ja'>
<head>
{/* ... */}
</head>
<body>
{children}
</body>
</html>
)
}
)
ビルド設定
vite.config.tsでHonoX用のプラグインを設定します。honox/viteプラグインの追加、@hono/vite-ssgによるSSGの設定、@hono/vite-build/cloudflare-workersによるCloudflare Workers向けのビルド設定を行いました。
// vite.config.ts
import honox from 'honox/vite'
import ssg from '@hono/vite-ssg'
import build from '@hono/vite-build/cloudflare-workers'
export default defineConfig({
plugins: [
honox({
devServer: { adapter },
client: { input: ['./app/style.css'] },
}),
ssg({
entry: './app/server.ts',
}),
build(),
// ...
],
})
主な依存関係はhonox、hono、@hono/vite-ssg、@hono/vite-build、@hono/vite-dev-serverです。
コンテンツ管理
ブログ記事の読み込み方法は、AstroのAstro.glob()からViteのimport.meta.glob()に変更しました。
// app/lib/blog.ts
const markdownFiles = import.meta.glob('../../content/blog/**/index.md', {
eager: true,
query: '?raw',
import: 'default',
})
その他の変更
OGP画像生成はビルド時にOGP画像を生成するViteプラグインを追加しました。ブログ記事のアセットをdistにコピーするViteプラグインも追加しています。Pagefindについては開発サーバーでPagefindをサーブするViteプラグインを追加しました。
OGP画像生成の実装
OGP画像は、satoriとsharpを使ってJSXからPNG画像を生成しています。ビルド時に各記事のタイトルからOGP画像を自動生成するViteプラグインを追加しました。
// vite.config.ts
const generateOGImages = () => {
return {
name: 'generate-og-images',
closeBundle: async () => {
const markdownFiles = globSync('content/blog/**/index.md')
for (const filePath of markdownFiles) {
const raw = readFileSync(filePath, 'utf-8')
const { data } = matter(raw)
const title = data.title as string
const buffer = await generateOGImage(title, fontPath, iconPath)
writeFileSync(destPath, buffer)
}
},
}
}
OGP画像の生成はapp/lib/ogimage.tsxで実装しています。日本語のタイトルを適切に改行するために、budouxを使ってタイトルを分割しています。
// app/lib/ogimage.tsx
import { loadDefaultJapaneseParser } from 'budoux'
import satori from 'satori'
import sharp from 'sharp'
const parser = loadDefaultJapaneseParser()
export const generateOGImage = async (
title: string,
fontPath: string,
iconPath: string
): Promise<Buffer> => {
const words = parser.parse(title)
const svg = await satori(
<div>
<div>
{words.map((word, index) => (
<span key={index} style={{ display: 'block' }}>
{word}
</span>
))}
</div>
</div>,
{
width: 1200,
height: 630,
fonts: [{ name: 'Noto Sans JP', data: font, style: 'normal' }],
}
)
return await sharp(Buffer.from(svg)).png().toBuffer()
}
OGP画像生成で苦労したこと
Astroの時はastro:build:doneフックを使っていましたが、HonoXではViteプラグインのcloseBundleフックを使うように変更しました。ビルド完了時にOGP画像を生成するタイミングを調整するのに苦労しました。
また、satoriではinline-blockが使えないという制約があります。そのため、budouxで分割した単語をdisplay: blockで表示する必要がありました。最初はinline-blockを使おうとしましたが、satoriの制約でエラーになったので、各単語をdisplay: blockで表示するように変更しました。
// satoriではinline-blockは使用できないため、明示的にblockを指定する
// https://github.com/facebook/yoga/issues/968
{words.map((word, index) => (
<span key={index} style={{ display: 'block' }}>
{word}
</span>
))}
OGP画像の保存先はdist/blog/{slug}/ogp.pngですが、ビルド時にフォルダが存在しない場合があるので、mkdirSyncでフォルダを作成する処理を追加しました。また、タイトルが存在しない場合はOGP画像の生成をスキップするようにしています。
Astroの時はHTMLファイルからタイトルを抽出していましたが、今回はmarkdownファイルのfrontmatterから直接タイトルを取得するように変更しました。これにより、より確実にタイトルを取得できるようになりました。
RSSフィードの実装
RSSフィードはapp/routes/rss.xml.tsxで実装しています。Astroの時はsrc/pages/rss.xml.tsで実装していましたが、HonoXではcreateRouteを使ってルートを定義します。
// app/routes/rss.xml.tsx
export default createRoute((c) => {
const items = allPosts
.slice(0, 20) // 最新20件
.map((post) => {
const link = `${SITE_URL}/blog/${post.id}`
return `
<item>
<title>${escapeXml(post.data.title)}</title>
<link>${link}</link>
<guid isPermaLink="true">${link}</guid>
<description>${escapeXml(post.data.description)}</description>
<pubDate>${formatDate(post.data.pubDate)}</pubDate>
<author>${SITE_AUTHOR}</author>
</item>`
})
.join('')
const rss = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>${escapeXml(SITE_TITLE)}</title>
<link>${SITE_URL}</link>
<description>${escapeXml(SITE_DESCRIPTION)}</description>
<language>ja</language>
<lastBuildDate>${formatDate(new Date())}</lastBuildDate>
<atom:link href="${SITE_URL}/rss.xml" rel="self" type="application/rss+xml"/>${items}
</channel>
</rss>`
return c.body(rss, 200, {
'Content-Type': 'application/xml; charset=utf-8',
})
})
XMLを手動で組み立てて返しています。XMLエスケープはescapeXml関数で実装し、日付のフォーマットはformatDate関数で実装しています。最新20件の記事を取得してRSSフィードを生成しています。
RSSフィードの実装で苦労したこと
SSGを使っているので、ビルド時にRSSフィードのXMLファイルを生成してdist/rss.xmlとして保存する構成にもできます(というか、SSGでビルドすると最終的にはdist/rss.xmlとして出力されます)。OGP画像生成と同様に、ViteプラグインのcloseBundleフックで「ビルド後にXMLを生成して保存する」という作りに寄せることもできます。
今回はapp/routes/rss.xml.tsxとしてルートを実装しておいて、そこからRSSを組み立てる形にしています(結果としてSSGでは静的ファイルに落ちます)。どちらが正しい/制約というより、どこで生成するか(ルート側で書くか、ビルド後に吐くか)の好みの問題だと思います。XMLを手動で組み立てる必要があったので、XMLエスケープの処理を正しく実装する必要がありました。また、Content-Typeヘッダーを正しく設定しないと、RSSリーダーで正しく認識されないので注意が必要でした。
GoogleAnalyticsの実装
GoogleAnalyticsはapp/components/BaseHead.tsxに実装しています。本番環境でのみ読み込むようにimport.meta.env.PRODで条件分岐しています。
// app/components/BaseHead.tsx
{import.meta.env.PROD && (
<>
<script
async
src='https://www.googletagmanager.com/gtag/js?id=G-NCE9S2S68M'
/>
<script
dangerouslySetInnerHTML={{
__html: `
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-NCE9S2S68M');
`,
}}
/>
</>
)}
GoogleAnalyticsの実装で苦労したこと
Astroの時はimport.meta.env.MODE === 'production'を使っていましたが、Viteではimport.meta.env.PRODを使うように変更しました。また、dangerouslySetInnerHTMLを使ってスクリプトを埋め込む必要がありました。開発環境ではGoogleAnalyticsを読み込まないようにすることで、開発時のパフォーマンスを向上させています。
Markdown内の画像パスの処理
ブログ記事のmarkdownファイル内では、画像をという相対パスで書いています。このパスを、production、development、手元のmarkdownで正しく表示されるように工夫しました。
フォルダ構成の変更
相対パスで書けるようにするために、ブログ記事のフォルダ構成を以下のようにしました。
content/blog/
└── {slug}/
├── index.md
└── assets/
└── image.png
各記事フォルダ内にindex.mdとassets/フォルダを配置することで、markdownファイルから見て./assets/image.pngという相対パスで画像を参照できるようになりました。この構成により、手元のmarkdownエディタでも画像が正しく表示されます。
ブログ記事の読み込みは、import.meta.glob('../../content/blog/**/index.md')でindex.mdファイルを読み込み、フォルダ名からslugを取得しています。
// app/lib/blog.ts
const markdownFiles = import.meta.glob('../../content/blog/**/index.md', {
eager: true,
query: '?raw',
import: 'default',
})
export const allPosts: BlogPost[] = Object.entries(markdownFiles)
.map(([filePath, raw]) => {
// 例: '../../content/blog/2025-03-19-electricity-usage-2024/index.md'
// → '2025-03-19-electricity-usage-2024'
const pathParts = filePath.split('/')
const slug = pathParts[pathParts.length - 2] || ''
// ...
})
この構成により、各記事が独立したフォルダに配置され、そのフォルダ内のassets/フォルダに画像を配置することで、相対パスで画像を参照できるようになりました。
開発環境での画像配信
開発時に手元で困らないように、app/routes/blog/[slug]/assets/[filename].tsxというルートで画像ファイルを返せるようにしています(content/配下から直接読むやつ)。
// app/routes/blog/[slug]/assets/[filename].tsx
export default createRoute(async (c) => {
const slug = c.req.param('slug')
const filename = c.req.param('filename')
// (主に開発時向けの)静的ファイル配信
const imagePath = join(process.cwd(), 'content', 'blog', slug, 'assets', filename)
if (!existsSync(imagePath)) {
c.status(404)
return c.text('Not Found')
}
const imageBuffer = readFileSync(imagePath)
// Content-Typeを設定して返す
// ...
})
これにより、開発中はcontent/blog/{slug}/assets/配下の画像ファイルを直接配信できます(本番は次の「ビルド時コピー」でdist配下に置く想定です)。
本番環境での画像配信
本番環境では、ビルド時にcopyContentAssetsプラグインでcontent/blog/**/assets/**/*をdist/blog/**/assets/**/*にコピーしています。
// vite.config.ts
const copyContentAssets = () => {
return {
name: 'copy-content-assets',
buildStart() {
const imageFiles = globSync(
'content/blog/**/assets/**/*.{png,jpg,jpeg,gif,svg,webp}'
)
imageFiles.forEach((filePath) => {
// content/blog/2025-03-19-electricity-usage-2024/assets/image.png
// → dist/blog/2025-03-19-electricity-usage-2024/assets/image.png
const relativePath = filePath.replace('content/blog/', 'blog/')
const destPath = join('dist', relativePath)
mkdirSync(dirname(destPath), { recursive: true })
copyFileSync(filePath, destPath)
})
},
}
}
パスの変換
markdownファイル内のを、HTMLに変換した後に/blog/{slug}/assets/image.pngに変換しています。
// app/routes/blog/[slug].tsx
let html = await marked(post.html)
// マークダウン内の相対パスを絶対パスに変換
// ./assets/image.png -> /blog/{slug}/assets/image.png
html = html.replace(
/src="\.\/assets\/([^"]+)"/g,
`src="/blog/${post.id}/assets/$1"`
)
これにより、markdownファイル内では相対パスで書けるので、手元のmarkdownエディタでも画像が正しく表示されます。また、開発環境と本番環境の両方で正しく画像が表示されるようになりました。
Markdown内の画像パス処理で苦労したこと
最初は絶対パスで書こうとしましたが、手元のmarkdownエディタで画像が表示されないのが不便でした。相対パスで書けるようにするために、markedでHTMLに変換した後に正規表現でパスを変換する方法を採用しました。
開発環境と本番環境で画像の配信方法が異なるので、開発環境では動的にファイルを配信するルートを追加し、本番環境ではビルド時にファイルをコピーするプラグインを追加しました。これにより、どちらの環境でも正しく画像が表示されるようになりました。
テーマ変更ボタンの実装
DaisyUIを使っているので、テーマ変更はdata-theme属性を切り替えるだけで実現できます。テーマ変更ボタンはThemeChangeコンポーネントとして実装しました。
// app/components/ThemeChange.tsx
export const ThemeChange = () => (
<>
<button
id='themeChange'
class='btn btn-square btn-ghost swap swap-rotate'
type='button'
aria-label='テーマを切り替え'
>
<svg class='swap-off fill-current w-5 h-5'>{/* Sun icon */}</svg>
<svg class='swap-on fill-current w-5 h-5'>{/* Moon icon */}</svg>
</button>
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
</>
)
テーマの切り替えはインラインスクリプトで実装しています。localStorageに保存したテーマを読み込んで、data-theme属性を設定しています。
const themeScript = `
(function() {
// 初期テーマの設定
var theme = localStorage.getItem('theme');
if (!theme) {
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'night' : 'winter';
}
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
// DOMContentLoadedでボタンの初期化
document.addEventListener('DOMContentLoaded', function() {
var btn = document.getElementById('themeChange');
if (!btn) return;
// 初期状態の設定
if (theme === 'night') {
btn.classList.add('swap-active');
}
// クリックイベント
btn.addEventListener('click', function() {
var currentTheme = document.documentElement.getAttribute('data-theme');
var newTheme = currentTheme === 'winter' ? 'night' : 'winter';
document.documentElement.setAttribute('data-theme', newTheme);
localStorage.setItem('theme', newTheme);
btn.classList.toggle('swap-active');
});
});
})();
`
テーマ変更ボタンの実装で苦労したこと
今回はSSG(Static Site Generation)構成で、テーマの状態もlocalStorageベースにしているので、リクエスト時にサーバー側でユーザーのテーマを判定してHTMLに反映する、ということはしていません。そのため、ページ読み込み時にテーマが適用される前に一瞬フラッシュが発生する可能性があります。
これを防ぐために、インラインスクリプトを即座に実行するIIFE(即時実行関数)にして、HTMLのパース中にテーマを設定するようにしました。スクリプトはThemeChangeコンポーネント内でdangerouslySetInnerHTMLを使って埋め込んでいます。
また、DaisyUIのswapクラスを使っているので、ボタンの初期状態をswap-activeクラスで制御する必要があります。テーマがnightの場合はswap-activeを追加して、アイコンが正しく表示されるようにしています。DOMContentLoadedイベントでボタンの初期化を行っているので、ボタンが存在しない場合は早期リターンするようにしています。
最初はクライアントサイドのJavaScriptでテーマを管理しようとしましたが、ページ読み込み時のフラッシュが気になったので、インラインスクリプトを使う方法に変更しました。dangerouslySetInnerHTMLを使うのはあまり推奨されませんが、この場合はページ読み込み時のフラッシュを防ぐために必要な選択でした。_renderer.tsxの<head>内にスクリプトを配置することも可能ですが、今回はコンポーネント内で実装する方法を選択しました。
移行してみて
Cloudflare Workersでの実行により、エッジ環境での高速なレスポンスが可能になったのと、Astroよりもシンプルな構成で必要な機能だけを選択できるようになりました。TypeScriptを第一級でサポートしているので型安全性も高く、ルーティングやレンダリングをより細かく制御できるようになりました。
一方で、Astroほど成熟したエコシステムがないのと、ドキュメントもAstroほど充実していないので、新しいフレームワークを学習する必要があります。ただ、Honoのエコシステムを活用できるので、今後も発展が期待できると思います。
おわりに
AstroからHonoXへの移行は、主に学習目的とエッジ環境での実行を目的として実施しました。