Hono から Astro への移行
自作 SSG の維持コストを考え、Hono から Astro に戻した理由と使い方のメモです。
目次
以前、Astro から Hono への移行 という記事を投稿しました。
当時は Astro に対する知識不足ということもあり、よりカスタマイズ性の高い Hono を採用していました。しかしその後 Astro をちゃんと使う機会があり、やっぱり Hono から Astro に戻すことにしました。React Router v7 の事前レンダリングも考えましたが、Workers 必須なのでそれは断念しました。
そもそも Hono にしたきっかけ
Hono にした大きな理由は Astro のマークダウンファイルのプロセッサー (以下「プロセッサー」) を 1 つしか定義できないことです。プラグイン内で VFile のパスを見れば特定のコンテンツをスキップするプラグインを書くことはできますが、プロセッサーの定義は 1 箇所に集約するのではなくコンテンツをレンダリングするところに書いたほうが管理しやすいです。
なぜ Astro に戻ったのか
Hono でで実装するコストと、Astro を割り切って使うコストを天秤にかけたとき、Astro を使ったほうが楽だと感じたからです。最初の頃はなるべく JavaScript を少なくファイル容量を少なくと思ってダイアログなどのロジックを自分で実装していましたが、段々面倒になってきて React と React を使ったライブラリでサクッと実装したいと考えるようになってきました。Hono でも React の一部機能が使えますが、クライアント側でも実行させたいのでハイドレーションする必要が出てきて、流石にこれは既存のフレームワークを使ったほうが良いだろうという結論です。
Astro の使い方
Astro 組み込みのプロセッサーにはなるべく何もしてほしくないので、gfm、smartypants、syntaxHighlight はすべて false です。また、本来は Astro が勝手にやってくれていたキャッシュ機能を独自に実装します。あとは例えば src/pages/bin/[...slug].astro に unified を使ったプロセッサーを定義してマークダウンをビルドさせます。
以下は実際に単発記事のビルドに使用している Astro ファイルの一部です:
import { getCollection } from "astro:content";
import astroLang from "shiki/langs/astro.mjs";
import cssLang from "shiki/langs/css.mjs";
import diffLang from "shiki/langs/diff.mjs";
import dockerfileLang from "shiki/langs/dockerfile.mjs";
import goLang from "shiki/langs/go.mjs";
import htmlLang from "shiki/langs/html.mjs";
import javascriptLang from "shiki/langs/javascript.mjs";
import jsoncLang from "shiki/langs/jsonc.mjs";
import jsonLang from "shiki/langs/json.mjs";
import jsonlLang from "shiki/langs/jsonl.mjs";
import jsxLang from "shiki/langs/jsx.mjs";
import markdownLang from "shiki/langs/markdown.mjs";
import pythonLang from "shiki/langs/python.mjs";
import rustLang from "shiki/langs/rust.mjs";
import shellLang from "shiki/langs/shell.mjs";
import tomlLang from "shiki/langs/toml.mjs";
import tsxLang from "shiki/langs/tsx.mjs";
import typescriptLang from "shiki/langs/typescript.mjs";
import yamlLang from "shiki/langs/yaml.mjs";
import { unified } from "unified";
import { VFile } from "vfile";
import categoryTextMap from "../../content/bin/categories.json";
import { schemas } from "../../content/config";
import Article from "../../layouts/article.astro";
import Root from "../../layouts/root.astro";
import cache from "../../utils/cache";
import rehypeCode from "../../utils/rehype-code";
import rehypeExternalLinks from "../../utils/rehype-external-links";
import rehypeStringify from "../../utils/rehype-stringify";
import rehypeToRaw from "../../utils/rehype-to-raw";
import remarkCalloutDirective from "../../utils/remark-callout-directive";
import remarkCodeTitle from "../../utils/remark-code-title";
import remarkDefinitionList from "../../utils/remark-definition-list";
import remarkExtractFrontmatter from "../../utils/remark-extract-frontmatter";
import remarkExtractHeadings from "../../utils/remark-extract-headings";
import remarkHeadingLinks from "../../utils/remark-heading-links";
import remarkLinkCard from "../../utils/remark-link-card";
import remarkParseDirective from "../../utils/remark-parse-directive";
import remarkParseFrontmatter from "../../utils/remark-parse-frontmatter";
import remarkParseGfm from "../../utils/remark-parse-gfm";
import remarkParseMarkdown from "../../utils/remark-parse-markdown";
import remarkPicture from "../../utils/remark-picture";
import remarkResponsiveTable from "../../utils/remark-responsive-table";
import remarkShiftHeadings from "../../utils/remark-shift-headings";
import remarkToRehype from "../../utils/remark-to-rehype";
import remarkTrimSpaces from "../../utils/remark-trim-spaces";
import toDateString from "../../utils/to-date-string";
export async function getStaticPaths() {
return (await getCollection("bin"))
.filter(entry => entry.data.pubDate != null)
.map(entry => ({
params: {
slug: entry.slug,
},
props: {
filePath: entry.filePath!,
},
}));
}
const { filePath } = Astro.props;
const {
image,
title,
content,
headings,
categories,
description,
pubDateString,
modDateString,
} = await cache("content:bin")(async ({ get, set }) => {
let file = await get(filePath);
if (file) {
return JSON.parse(await file.text()) as never;
}
const vfile = await unified()
.use(remarkParseMarkdown)
.use(remarkParseFrontmatter)
.use(remarkExtractFrontmatter)
.use(remarkParseGfm)
.use(remarkParseDirective)
.use(remarkCalloutDirective)
.use(remarkLinkCard)
.use(remarkCodeTitle)
.use(remarkDefinitionList)
.use(remarkResponsiveTable)
.use(remarkPicture)
.use(remarkTrimSpaces)
.use(remarkShiftHeadings)
.use(remarkExtractHeadings)
.use(remarkHeadingLinks)
.use(remarkToRehype, {
allowDangerousHtml: true,
handlers: {
...remarkDefinitionList.defListHastHandlers,
},
})
.use(rehypeCode, {
langs: [
astroLang,
cssLang,
diffLang,
dockerfileLang,
goLang,
htmlLang,
javascriptLang,
jsoncLang,
jsonLang,
jsonlLang,
jsxLang,
markdownLang,
pythonLang,
rustLang,
shellLang,
tomlLang,
tsxLang,
typescriptLang,
yamlLang,
],
})
.use(rehypeToRaw)
.use(rehypeExternalLinks, {
target: "_blank",
rel: ["noopener", "noreferrer"],
})
.use(rehypeStringify, {
allowDangerousHtml: true,
})
.process(new VFile({
path: filePath,
value: await Bun.file(filePath).bytes(),
}));
const headings = vfile.data.headings || [];
const frontmatter = vfile.data.frontmatter;
const {
image,
title,
pubDate,
modDate,
categories,
description,
} = schemas.bin.parse(frontmatter);
const data = {
image,
title,
content: vfile.toString(),
headings,
categories,
description,
pubDateString: toDateString(pubDate!),
modDateString: modDate && toDateString(modDate),
};
await set(filePath, JSON.stringify(data));
return data;
});