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 名対応形式仕様
mdastMarkdownmdast spec
hastHTMLhast 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 ノードに変換する。 remarkRehypeallowDangerousHtml: 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 のセット忘れ

remarkRehypeallowDangerousHtml: true を渡しただけでは生 HTML は残らない。 rehypeRaw を後続に追加して初めて hast ノードに変換される。片方だけ設定しても効果がない。

2. プラグインの順序

rehypePrism は hast 上のコードブロックを処理するため、remarkRehype より後に置く必要がある。 また rehypeRawremarkRehype の直後に置くのが安全(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.tsxglobal.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 で書いている場合は注意が必要。

まとめ

プラグイン変換内容
remarkParseMarkdown → mdast
remarkGfmGFM 拡張構文を mdast に追加
remarkRehypemdast → hast(生 HTML は raw ノードとして保持)
rehypeRawraw ノード → 正式な hast ノード
rehypePrismコードブロックにハイライト用クラスを付与
rehypeExternalLinks外部リンクに target / rel を付与
rehypeStringifyhast → HTML 文字列

各プラグインは AST を受け取って変換した AST を返す単純な構造のため、必要に応じて差し替えや追加が容易にできる。たとえばシンタックスハイライトを rehype-highlight(highlight.js ベース)に変えたい場合は rehypePrism をそのプラグインに置き換えるだけで済む。