remark/rehypeでMarkdownがHTMLになるまで:構文木の旅

はじめに

こんにちは、DX ビジネス開発部の政德です。普段は OPTiM Collaboration Portal の開発をしています。
本記事は OPTiM TECH BLOG Advent Calendar 2025 Day 19 の記事です。

今回はunifiedのremark/rehypeで、MarkdownをHTMLに変換するときに内部で行われていることを深ぼっていきたいと思います。

remark/rehypeとは

remarkとはmdastという構文木とMarkdownを相互に変換するためのツールです。 rehypeとはhastという構文木とHTMLを相互に変換するためのツールです。

また、remark/rehypeはunifiedというパッケージ群に属しています。unifiedとはHTMLやMarkdown等のコンテンツを構文木(ast)として扱うためのものです。

MarkdownをHTMLに変換するときは

  1. Markdownをmdastに変換
  2. mdastをhastに変換
  3. hastをHTMLに変換

の順番で変換が行われています。

これを活用することでMarkdownのプレビュー画面の実装をすることができます。
本記事ではmdastとhastが実際にどのように記載されているかを除くことで、remark/rehypeにおいてMarkdownをHTMLに変換するときに内部で行われていることを見ていきたいと思います。

実践

ここからはmdast/hastがどのように構成されているのか実際に見ていきます。
以下はサンプルのMarkdownです。

 このテキストは**MarkdownからHTML**への変換過程を解説するためのサンプルです。
 ```javascript
 console.log('hello')
 ```

これをmdastに変換した結果が以下です。

{
  "type": "root",
  "children": [
    {
      "type": "paragraph",
      "children": [
        {
          "type": "text",
          "value": "このテキストは",
          "position": {
            "start": {
              "line": 1,
              "column": 1,
              "offset": 0
            },
            "end": {
              "line": 1,
              "column": 8,
              "offset": 7
            }
          }
        },
        {
          "type": "strong",
          "children": [
            {
              "type": "text",
              "value": "MarkdownからHTML",
              "position": {
                "start": {
                  "line": 1,
                  "column": 10,
                  "offset": 9
                },
                "end": {
                  "line": 1,
                  "column": 24,
                  "offset": 23
                }
              }
            }
          ],
          "position": {
            "start": {
              "line": 1,
              "column": 8,
              "offset": 7
            },
            "end": {
              "line": 1,
              "column": 26,
              "offset": 25
            }
          }
        },
        {
          "type": "text",
          "value": "への変換過程を解説するためのサンプルです。",
          "position": {
            "start": {
              "line": 1,
              "column": 26,
              "offset": 25
            },
            "end": {
              "line": 1,
              "column": 47,
              "offset": 46
            }
          }
        }
      ],
      "position": {
        "start": {
          "line": 1,
          "column": 1,
          "offset": 0
        },
        "end": {
          "line": 1,
          "column": 47,
          "offset": 46
        }
      }
    },
    {
      "type": "code",
      "lang": "javascript",
      "meta": null,
      "value": "console.log('hello')",
      "position": {
        "start": {
          "line": 2,
          "column": 1,
          "offset": 47
        },
        "end": {
          "line": 4,
          "column": 4,
          "offset": 85
        }
      }
    }
  ],
  "position": {
    "start": {
      "line": 1,
      "column": 1,
      "offset": 0
    },
    "end": {
      "line": 4,
      "column": 4,
      "offset": 85
    }
  }
}

mdastの一部を解説すると、例えばコードブロックの部分はmdast上では以下のように表現されます。
"type": "code" の部分がコードブロックであることを判定しているものです。
positionのstartとendはそれぞれコードブロックが挿入されるスタート位置と終了位置を表しています。
lineとcolumnで「何行目の何文字目か」を表し、offsetでファイル全体で何文字目かを表しています。

    {
      "type": "code",
      "lang": "javascript",
      "meta": null,
      "value": "console.log('hello')",
      "position": {
        "start": {
          "line": 2,
          "column": 1,
          "offset": 47
        },
        "end": {
          "line": 4,
          "column": 4,
          "offset": 85
        }
      }
    }

mdastをさらにhastに変換したものは以下になります。

{
  "type": "root",
  "children": [
    {
      "type": "element",
      "tagName": "p",
      "properties": {},
      "children": [
        {
          "type": "text",
          "value": "このテキストは",
          "position": {
            "start": {
              "line": 1,
              "column": 1,
              "offset": 0
            },
            "end": {
              "line": 1,
              "column": 8,
              "offset": 7
            }
          }
        },
        {
          "type": "element",
          "tagName": "strong",
          "properties": {},
          "children": [
            {
              "type": "text",
              "value": "MarkdownからHTML",
              "position": {
                "start": {
                  "line": 1,
                  "column": 10,
                  "offset": 9
                },
                "end": {
                  "line": 1,
                  "column": 24,
                  "offset": 23
                }
              }
            }
          ],
          "position": {
            "start": {
              "line": 1,
              "column": 8,
              "offset": 7
            },
            "end": {
              "line": 1,
              "column": 26,
              "offset": 25
            }
          }
        },
        {
          "type": "text",
          "value": "への変換過程を解説するためのサンプルです。",
          "position": {
            "start": {
              "line": 1,
              "column": 26,
              "offset": 25
            },
            "end": {
              "line": 1,
              "column": 47,
              "offset": 46
            }
          }
        }
      ],
      "position": {
        "start": {
          "line": 1,
          "column": 1,
          "offset": 0
        },
        "end": {
          "line": 1,
          "column": 47,
          "offset": 46
        }
      }
    },
    {
      "type": "text",
      "value": "\n"
    },
    {
      "type": "element",
      "tagName": "pre",
      "properties": {},
      "children": [
        {
          "type": "element",
          "tagName": "code",
          "properties": {
            "className": [
              "language-javascript"
            ]
          },
          "children": [
            {
              "type": "text",
              "value": "console.log('hello')\n"
            }
          ],
          "position": {
            "start": {
              "line": 2,
              "column": 1,
              "offset": 47
            },
            "end": {
              "line": 4,
              "column": 4,
              "offset": 85
            }
          }
        }
      ],
      "position": {
        "start": {
          "line": 2,
          "column": 1,
          "offset": 47
        },
        "end": {
          "line": 4,
          "column": 4,
          "offset": 85
        }
      }
    }
  ],
  "position": {
    "start": {
      "line": 1,
      "column": 1,
      "offset": 0
    },
    "end": {
      "line": 4,
      "column": 4,
      "offset": 85
    }
  }
}

こちらもコードブロックのところのみ切り取って記載すると、preタグの下にcodeタグがあり、その下にtextがあるという構造がjsonで表現されていることがわかります。

    {
      "type": "element",
      "tagName": "pre",
      "properties": {},
      "children": [
        {
          "type": "element",
          "tagName": "code",
          "properties": {
            "className": [
              "language-javascript"
            ]
          },
          "children": [
            {
              "type": "text",
              "value": "console.log('hello')\n"
            }
          ],
          "position": {
            "start": {
              "line": 2,
              "column": 1,
              "offset": 47
            },
            "end": {
              "line": 4,
              "column": 4,
              "offset": 85
            }
          }
        }
      ],
      "position": {
        "start": {
          "line": 2,
          "column": 1,
          "offset": 47
        },
        "end": {
          "line": 4,
          "column": 4,
          "offset": 85
        }
      }
    }
  ]

hastをHTMLに変換すると以下になります。最終的にはコンパクトに収まりました。

<p>このテキストは
  <strong>MarkdownからHTML</strong>への変換過程を解説するためのサンプルです。
</p>\n
<pre>
  <code class="language-javascript">console.log('hello')\n</code>
</pre>

余談: カスタマイズ機能を加えたときのmdast

以下のように色を指定すると背景色が変化するmdastとhastのpluginを試しに手元で作成しました。
(一部脆弱性が残ってしまっているためコード自体の公開は控えます)

 ```color#00ff00
 成功: データの保存が完了しました
 すべての操作が正常に終了しました
 ```

カスタマイズ機能を加えたときはmdastは以下のようになります。
hNameとhPropertiesで今回カスタマイズしたい内容が表現できていることがわかります。

        {
          "type": "code",
          "lang": null,
          "meta": null,
          "value": "成功: データの保存が完了しました\nすべての操作が正常に終了しました",
          "position": {
            "start": {
              "line": 1,
              "column": 1,
              "offset": 0
            },
            "end": {
              "line": 4,
              "column": 4,
              "offset": 54
            }
          },
          "data": {
            "hName": "div",
            "hProperties": {
              "className": [
                "color-code-block"
              ],
              "style": "background-color: #00ff00; padding: 16px; border-radius: 4px; margin: 16px 0;"
            }
          },
          "colorCode": "#00ff00",
          "isColorCodeBlock": true
        }

まとめ/所感

今回はmdast/hastの中身をのぞいてみました。
自分はMarkdownやHTMLを木構造で扱うと聞いて、複雑な表現になっているんじゃなかろうかと思っていましたが、中をのぞいてみると非常にシンプルに表現されていることがわかりました。
普段触っている技術が内部でどのように処理されているのか見てみるのはやはり面白いなと思います。

おわりに

当社では挑戦を楽しめる仲間を募集しています。興味がある方はぜひこちらからご応募ください! www.optim.co.jp