unified/remark/rehype パイプライン — Markdown を高機能 HTML に変換する仕組み
このブログの記事ページは、Markdown ファイルを unified エコシステムのパイプラインで HTML に変換している。
この記事では、remarkParse から rehypeStringify まで各プラグインが何をしているかを、AST(抽象構文木)の変換フローと合わせて解説する。
unified エコシステムとは
unified は、テキスト処理を AST ベースのパイプラインで行うためのフレームワーク。 Markdown・HTML・自然言語など異なる形式を統一した仕組みで変換できる。
入力テキスト
↓ parse(パーサー)
AST(抽象構文木)
↓ transform(プラグイン群)
変換後 AST
↓ stringify(シリアライザー)
出力テキスト
remark は Markdown 用の unified プロセッサ、rehype は HTML 用の unified プロセッサで、両者を橋渡しする remark-rehype を介することで Markdown → HTML の変換チェーンが成立する。
登場する AST の種類
| AST 名 | 対応形式 | 仕様 |
|---|---|---|
| mdast | Markdown | mdast spec |
| hast | HTML | hast spec |
パイプラインの全体像
このブログで使用しているパイプラインは以下のとおり(src/app/posts/[slug]/page.tsx:76-84)。
import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkGfm from "remark-gfm";
import remarkRehype from "remark-rehype";
import rehypeRaw from "rehype-raw";
import rehypePrism from "rehype-prism-plus";
import rehypeExternalLinks from "rehype-external-links";
import rehypeStringify from "rehype-stringify";
const processedContent = await unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeRaw)
.use(rehypePrism)
.use(rehypeExternalLinks, { target: "_blank", rel: ["nofollow"] })
.use(rehypeStringify)
.process(content);
各ステップを順番に見ていく。
ステップ 1: remarkParse — Markdown → mdast
.use(remarkParse)
Markdown テキストを mdast(Markdown AST)に変換するパーサー。
unified() に最初に追加する「入口」の役割を担う。
たとえば ## 見出し という Markdown は、次のような mdast ノードになる。
{
"type": "heading",
"depth": 2,
"children": [{ "type": "text", "value": "見出し" }]
}
ステップ 2: remarkGfm — GFM 拡張の有効化
.use(remarkGfm)
GitHub Flavored Markdown の拡張構文を mdast に追加するプラグイン(remark-gfm v4)。
追加される構文:
| 構文 | 例 |
|---|---|
| テーブル | | A | B | |
| タスクリスト | - [x] 完了 |
| 打ち消し線 | ~~text~~ |
| 自動リンク | https://example.com |
ステップ 3: remarkRehype — mdast → hast
.use(remarkRehype, { allowDangerousHtml: true })
mdast を hast(HTML AST)に変換するブリッジプラグイン。
allowDangerousHtml: true を渡すことで、Markdown 内に書いた生 HTML(<div>, <span> など)を hast の raw ノードとして保持する。
このオプションを省略すると、生 HTML がすべて除去されてしまう。
ステップ 4: rehypeRaw — 生 HTML ノードのパース
.use(rehypeRaw)
前ステップで raw ノードとして残った生 HTML 文字列を、正式な hast ノードに変換する。
remarkRehype の allowDangerousHtml: true とセットで使うのが必須パターン。
remarkRehype({ allowDangerousHtml: true }) → raw ノードとして残す
↓
rehypeRaw → raw ノードを hast ノードに昇格させる
この 2 ステップがないと、Markdown 内の <details> や <kbd> などのカスタム HTML が消える。
ステップ 5: rehypePrism — シンタックスハイライト
.use(rehypePrism)
rehype-prism-plus v2 が提供するプラグイン。
hast 上のコードブロックノード(<pre><code class="language-xxx">)を走査し、Prism.js のトークン分割に基づいたクラスを付与する。
実際の出力イメージ:
<pre class="language-typescript">
<code class="language-typescript">
<span class="token keyword">const</span> x <span class="token operator">=</span> <span class="token number">1</span>
</code>
</pre>
CSS 側で .token.keyword などにスタイルを当てることでハイライトが実現する。
このブログでは Tailwind CSS の prose-pre と組み合わせてスタイリングしている。
ステップ 6: rehypeExternalLinks — 外部リンク処理
.use(rehypeExternalLinks, { target: "_blank", rel: ["nofollow"] })
http:// / https:// で始まる外部リンクに対して自動で属性を付与するプラグイン(rehype-external-links v3)。
| オプション | 付与される属性 | 効果 |
|---|---|---|
target: "_blank" | target="_blank" | 新しいタブで開く |
rel: ["nofollow"] | rel="nofollow" | 検索エンジンへのリンクジュース遮断 |
target="_blank" を使う場合、セキュリティのため rel="noopener noreferrer" も一緒に付与するのが推奨される。配列に追加するだけで対応できる。
.use(rehypeExternalLinks, {
target: "_blank",
rel: ["nofollow", "noopener", "noreferrer"], // セキュリティ強化版
})
ステップ 7: rehypeStringify — hast → HTML 文字列
.use(rehypeStringify)
hast を HTML 文字列に変換する「出口」のシリアライザー。
.process(content) の戻り値を .toString() することで最終的な HTML が得られる。
const processedContent = await unified()
// ... .use() チェーン
.process(content);
const html = processedContent.toString(); // "<h2>見出し</h2><p>本文</p>..."
パイプライン全体のデータフロー
Markdown テキスト (string)
│
▼ remarkParse
mdast (Markdown AST)
│
▼ remarkGfm ← テーブル・タスクリストなどのノードを追加
mdast (拡張済み)
│
▼ remarkRehype({ allowDangerousHtml: true })
hast (HTML AST) + raw ノード(生 HTML 断片)
│
▼ rehypeRaw ← raw ノードを正式な hast ノードに変換
hast (完全な HTML AST)
│
▼ rehypePrism ← コードブロックにハイライト用クラスを付与
hast (ハイライト済み)
│
▼ rehypeExternalLinks ← 外部リンクに target/rel を付与
hast (リンク属性付き)
│
▼ rehypeStringify
HTML 文字列 (string)
ハマりやすいポイント
1. allowDangerousHtml と rehypeRaw のセット忘れ
remarkRehype に allowDangerousHtml: true を渡しただけでは生 HTML は残らない。
rehypeRaw を後続に追加して初めて hast ノードに変換される。片方だけ設定しても効果がない。
2. プラグインの順序
rehypePrism は hast 上のコードブロックを処理するため、remarkRehype より後に置く必要がある。
また rehypeRaw は remarkRehype の直後に置くのが安全(raw ノードが他のプラグインで処理される前に解決しておく)。
// NG: rehypeRaw を後ろに置きすぎると raw ノードが別プラグインに触られる場合がある
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypePrism) // ← raw ノードが残ったまま処理される
.use(rehypeRaw)
// OK: rehypeRaw は remarkRehype の直後
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeRaw) // ← まず raw ノードを解決
.use(rehypePrism)
3. rehype-prism-plus のバージョンと CSS の用意
rehype-prism-plus v2 はクラスを付与するだけで、CSS は自分で用意する必要がある。
Prism.js のテーマ CSS(prism-tomorrow.css など)を layout.tsx や global.css でインポートしないとハイライトが見えない。
// src/app/layout.tsx
import "prismjs/themes/prism-tomorrow.css";
4. ESM 専用パッケージ
unified v11 以降、remark-* / rehype-* の主要パッケージはすべて ESM 専用になっている。
require() で呼び出す CommonJS 環境では動作しない。Next.js 13+ の App Router はデフォルトで ESM を扱えるため問題ないが、カスタムスクリプト(scripts/ など)を CommonJS で書いている場合は注意が必要。
まとめ
| プラグイン | 変換内容 |
|---|---|
remarkParse | Markdown → mdast |
remarkGfm | GFM 拡張構文を mdast に追加 |
remarkRehype | mdast → hast(生 HTML は raw ノードとして保持) |
rehypeRaw | raw ノード → 正式な hast ノード |
rehypePrism | コードブロックにハイライト用クラスを付与 |
rehypeExternalLinks | 外部リンクに target / rel を付与 |
rehypeStringify | hast → HTML 文字列 |
各プラグインは AST を受け取って変換した AST を返す単純な構造のため、必要に応じて差し替えや追加が容易にできる。たとえばシンタックスハイライトを rehype-highlight(highlight.js ベース)に変えたい場合は rehypePrism をそのプラグインに置き換えるだけで済む。