YUTO MIMURA
← 記事一覧に戻る

·12

Markdownからブログをレンダリングする方法

  • ドキュメント
  • アーキテクチャ

この記事はAIが生成したものを人間が手直しして作成しています。

技術ブログを作るとき、 Markdown を HTML へ変換するだけなら難しくありません。しかし、実際に読みやすい記事を作るには、コードのシンタックスハイライト、見出しからの目次生成、 Mermaid による図の描画、 Front Matter の検証、静的配信なども必要になります。

私はブログを長期的に運用することを考えると、できるだけ運用負荷の小さい構成を取ることが重要だと考えています。このブログでは、 git 管理しているリポジトリの中で、 content/posts 以下の Markdown ファイルとして管理しています。 Next.js のビルド時に HTML へ変換して配信しています。本記事では、どのライブラリをどの段階で使い、どのように完全静的なブログを構成しているのかを紹介します。

全体アーキテクチャ

構成の中心にあるのは、 Markdown を抽象構文木として段階的に変換する unified です。記事ファイルの読み込みからブラウザでの表示までは、次のように流れます。

flowchart LR A["Markdown"] --> B["gray-matter<br/>Front Matter と本文を分離"] B --> C["Zod<br/>メタデータを検証"] C --> D["unified / remark<br/>Markdown AST"] D --> E["remark-rehype<br/>HTML ASTへ変換"] E --> F["rehype plugins<br/>見出し・コード・Mermaid"] F --> G["静的HTML<br/>out/"] G --> H["Cloudflare Workers<br/>Static Assets"]

Next.jsでは output: "export" を指定しています。記事ページはビルド時にすべて生成され、成果物は out/ へ出力されます。そのため、リクエストごとにサーバー上で Markdown を変換する必要がない構成になっています。

記事データの読み込みと検証

gray-matter で Front Matter を分離する

各記事の先頭には、タイトルや公開日時などをYAML形式で記述しています。

---
title: "記事タイトル"
description: "記事の概要"
publishedAt: "2000-01-01T00:00:00+09:00"
tags:
  - Next.js
  - Markdown
published: true
---

gray-matterを使うと、Front MatterとMarkdown本文を分離できます。

const source = await fs.readFile(filePath, "utf8");
const parsed = matter(source);
 
const metadata = parsed.data;
const markdown = parsed.content;

Zodでメタデータを検証する

Front Matter は自由に記述できる反面、日付の形式や必須項目を間違える可能性があります。このブログでは Zod でスキーマを定義し、ビルド時に検証しています。

const metadataSchema = z.object({
	title: z.string().trim().min(1),
	description: z.string().trim().min(1),
	publishedAt: isoDateSchema,
	tags: z.array(z.string().trim().min(1)).min(1),
	published: z.boolean(),
});

不正な記事がある場合はビルドを失敗させるため、壊れたメタデータのままデプロイされることを防げます。また、未来の公開日時や published: false の記事は一覧と静的生成の対象から除外しています。

unified による Markdown変換

Markdownの変換処理には、unifiedを中心としたremark・rehypeエコシステムを利用しています。

flowchart TD A["Markdown文字列"] --> B["remark-parse"] B --> C["mdast<br/>Markdown AST"] C --> D["remark-gfm"] D --> E["目次収集プラグイン"] E --> F["remark-rehype"] F --> G["hast<br/>HTML AST"] G --> H["rehype-slug"] H --> I["rehype-autolink-headings"] I --> J["Mermaid変換"] J --> K["rehype-pretty-code"] K --> L["rehype-stringify"] L --> M["HTML文字列"]

処理の概略は次のとおりです。

const result = await unified()
	.use(remarkParse)
	.use(remarkGfm)
	.use(remarkTableOfContents, toc)
	.use(remarkRehype)
	.use(rehypeSlug)
	.use(rehypeAutolinkHeadings, { behavior: "wrap" })
	.use(rehypeMermaid)
	.use(rehypePrettyCode, {
		theme: "github-dark-default",
		keepBackground: false,
	})
	.use(rehypeStringify)
	.process(markdown);

remarkはMarkdown側の構造を、 rehype は HTML 側の構造を扱います。文字列を正規表現で直接置換するのではなく、 AST を段階的に変換することで、処理の追加や順序の管理がしやすくなります。

GitHub Flavored Markdown

remark-gfm を追加すると、通常の Markdown に加えて、テーブル、取り消し線、タスクリストなどを利用できます。

記法 用途 対応
Table 比較や一覧
Task list 作業項目
Strikethrough 修正前の表現
Autolink URLのリンク化
  • Markdownを読み込む
  • GFMをHTMLへ変換する
  • ビルド時に検証する

このような要素は、 Tailwind CSS Typography の prose クラスによって記事向けに整形しています。

見出し ID と目次の生成

記事の見出しには rehype-slug で ID を付与し、 rehype-autolink-headings で見出し自体をアンカーリンクにしています。

目次については、 Markdown AST を unist-util-visit で走査し、レベル2とレベル3の見出しを収集する独自プラグインを用意しました。

visit(tree, "heading", (node) => {
	if (node.depth !== 2 && node.depth !== 3) return;
 
	const text = toString(node);
	toc.push({
		id: slugger.slug(text),
		text,
		level: node.depth,
	});
});

github-slugger を使うことで、同じ見出しが複数登場した場合も重複しない ID を生成できます。本文の見出しと目次で同じルールを使うことが重要です。

シンタックスハイライト

コードブロックの装飾には rehype-pretty-code を使っています。内部では Shiki が利用されており、 VS Code と同じTextMate grammar をもとにコードを色分けします。

Markdownのコードフェンスへ言語名を指定するだけで利用できます。

```go
func main() {
	fmt.Println("Hello")
}
```

Go

package main
 
import (
	"context"
	"fmt"
)
 
type UserRepository interface {
	FindByID(ctx context.Context, id string) (*User, error)
}
 
func main() {
	fmt.Println("syntax highlighting with Go")
}

TypeScript

type MarkdownResult = {
	html: string;
	toc: Array<{
		id: string;
		text: string;
		level: 2 | 3;
	}>;
};
 
async function buildPost(source: string): Promise<MarkdownResult> {
	return renderMarkdown(source);
}

SQL

SELECT
	posts.slug,
	posts.title,
	COUNT(tags.id) AS tag_count
FROM posts
LEFT JOIN tags ON tags.post_id = posts.id
WHERE posts.published = TRUE
GROUP BY posts.id
ORDER BY posts.published_at DESC;

diff

- const output = markdownToHtml(source);
+ const { html, toc } = await renderMarkdown(source);

Go、TypeScript、SQL、diff など、 Shiki が対応する多くの言語を同じ仕組みで表示できます。ハイライトはビルド時に HTML へ埋め込まれるため、通常のコードブロックを表示するためだけにブラウザで重い処理を実行する必要はありません。

Mermaid による図の表示

Mermaid はフローチャートやシーケンス図をテキストで管理できるライブラリです。画像編集ソフトを使わず、記事と同じ Git の差分として図をレビューできます。

```mermaid
sequenceDiagram
    Browser->>Next.js: 記事を要求
    Next.js-->>Browser: 静的HTML
```

このブログでは、 Markdown 変換時に language-mermaid のコードブロックを検出し、次の要素へ変換しています。

<div class="mermaid">
  flowchart LR
    A[Markdown] --> B[HTML]
</div>

その後、ブラウザ側でMermaidを動的importし、SVGへ描画します。

void import("mermaid").then(({ default: mermaid }) => {
	mermaid.initialize({
		startOnLoad: false,
		securityLevel: "strict",
		theme: "neutral",
	});
 
	return mermaid.run({ querySelector: ".mermaid" });
});

通常の Markdown 変換はビルド時に完了させ、ブラウザ API が必要な Mermaid の描画だけをクライアント側に分離している点がポイントです。また、 securityLevel: "strict" を指定し、図の描画時にも安全側の設定を採用しています。

Next.jsによる静的ページ生成

App Router の generateStaticParams で公開記事の slug を列挙し、記事ごとのページをビルド時に生成しています。

export async function generateStaticParams() {
	const posts = await getPublishedPosts();
 
	return posts
		.filter((post) => !post.metadata.externalUrl)
		.map((post) => ({ slug: post.slug }));
}

さらに generateMetadata で、タイトル、概要、canonical URL、記事公開日時、タグなどのメタデータを生成します。同じ記事データから RSS とサイトマップも生成しているため、表示内容と配信用メタデータのずれを抑えられます。

flowchart LR A["Validated Post"] --> B["Article Page"] A --> C["Metadata / OGP"] A --> D["RSS"] A --> E["Sitemap"]

Cloudflare への静的配信

ビルド成果物は out/ へ出力され、 Wrangler から Cloudflare Workers Static Assets へデプロイされます。

{
	"assets": {
		"directory": "./out"
	}
}

デプロイの流れはシンプルです。

pnpm lint
pnpm build
pnpm exec wrangler deploy

実行時に Node.js サーバーを必要とせず、生成済みの HTML、CSS、JavaScript 、画像を静的アセットとして配信できます。記事公開のたびにビルドは必要ですが、配信構成を小さく保ちやすいことが利点です。

採用ライブラリと役割

ライブラリ 役割
Next.js ルーティング、静的ページ、メタデータ生成
React UIコンポーネント
gray-matter Front Matterと本文の分離
Zod 記事メタデータの検証
unified AST変換パイプラインの統合
remark-parse Markdownのパース
remark-gfm GFM記法の追加
remark-rehype Markdown ASTからHTML ASTへの変換
rehype-slug 見出しIDの付与
rehype-autolink-headings 見出しへのリンク追加
rehype-pretty-code コードのシンタックスハイライト
rehype-stringify HTML文字列への変換
unist-util-visit ASTの走査
github-slugger GitHub互換の見出しID生成
Mermaid テキストから図を描画
Tailwind CSS Typography 記事本文のスタイリング
Wrangler Cloudflareへのデプロイ

この構成の利点と注意点

この構成には、次の利点があります。

  • 記事と実装を同じリポジトリで管理できる
  • コードレビューと同じ方法で記事の差分を確認できる
  • シンタックスハイライトをビルド時に処理できる
  • Mermaidの図もテキストとしてバージョン管理できる
  • メタデータの不備をデプロイ前に検出できる
  • 静的ファイルとして小さな構成で配信できる

一方で、記事を追加するたびにサイト全体のビルドが必要です。また、Mermaidはブラウザ上でSVGへ変換するため、JavaScriptが無効な環境では図のソースがそのまま残ります。記事数が大きく増えた場合は、ビルド時間や検索機能について別の設計が必要になる可能性があります。

まとめ

このブログは、 Markdown を単純に HTML へ置き換えるのではなく、 gray-matter と Zod で入力を整え、 unified・remark・rehype で AST を変換し、 Next.js で静的ページとして出力する構成になっています。

特に、シンタックスハイライト、目次、 Mermaid をそれぞれ独立した変換処理として組み込むことで、必要な機能を後から追加しやすくしています。 Markdown の書きやすさと静的サイトの配信しやすさを保ちながら、技術記事に必要な表現力を持たせられる点が、このアーキテクチャの大きな特徴です。