MarkdownをHTMLに変換するunifiedインターフェースについての解説

## はじめに

この記事はマークアップ言語変換インターフェイスunifiedについて解説、共有するためのものです。node.js環境でQiitaのマークダウンファイルをHTMLに変換する過程を題材に、unifiedを学習します。

### 対象とする環境

- node.js v16.18.0
- unified v10.1.2
- remark v14.0.2
- rehype v12.0.1

異なるバージョンをご利用の場合、この記事の内容がそのまま適用できないかもしれません。お手元の環境をご確認ください。

### 対象とする読者

- JavaScriptで開発をしたことがある
- node.jsを利用したことがある
- マークダウンを書いたことがある
- unifiedについては知らない / 名前ぐらいは知っている

## unifiedインターフェイスとは

Qiitaの記事は[マークダウン](https://www.markdown.jp/what-is-markdown/)構文で書かれています。まずはこのマークダウンの構造を分解し、HTMLに変換する仕組みが必要です。今回はマークダウン変換にunifiedを使います。unifiedインターフェイスとは、HTML / XML / Markdownなどの[マークアップ言語](https://kotobank.jp/word/%E3%83%9E%E3%83%BC%E3%82%AF%E3%82%A2%E3%83%83%E3%83%97%E8%A8%80%E8%AA%9E-8888)を相互変換することを目的としたプロジェクトです。

### unified

https://unifiedjs.com/

https://github.com/unifiedjs/unified

unifiedはnode.jsで稼働するunifiedインターフェイスの中核です。各種マークアップ言語を[構文ツリー](https://github.com/syntax-tree)に変換し、他のマークアップ言語に再変換します。unifiedは変換処理をAPIとして開放しており、ユーザーが自由にプラグインを作成して拡張できます。

### remark

https://github.com/remarkjs/remark

remarkは

 - マークダウン言語を構文ツリーに変換するパーサープラグイン[remark-parse](https://www.npmjs.com/package/remark-parse)
 - それらのプラグインをunifiedに統合した[パッケージ](https://github.com/remarkjs/remark/tree/main/packages/remark)

の名前です。名前の由来は、Markdownを再変換するのでre-markだと思われます。

両者は混同しやすいため、この記事では

- remark : remark-parseプラグイン
- remarkパッケージ : remark-parseプラグインを統合したパッケージ

と呼び分けます。

remarkパッケージは、remark-parse, remark-stringifyの2つのプラグインがunifiedに統合されいるため簡単にマークダウン→HTMLの変換処理が書けます。

```js
import {remark} from 'remark'

main()

async function main() {
  const file = await remark()
    .process('# Hello, Neptune!')

  console.log(String(file))
}
```

### rehype

https://github.com/rehypejs/rehype

rehypeはremark同様

 - HTMLを構文ツリーに変換するパーサープラグイン[rehype-parse](https://www.npmjs.com/package/rehype-parse)
 - それらのプラグインをunifiedに統合した[パッケージ](https://github.com/rehypejs/rehype)

の名前です。名前の由来は、ハイパーテキストを再変換するのでre-hypeだと思われます。


### remarkとrehypeを組み合わせたサンプル

remark-parseとrehypeを組み合わせ、マークダウンファイルをHTMLに変換するサンプルコードは以下のようになります。

```js
import {unified} from 'unified'
import remarkParse from 'remark-parse'
import remarkRehype from 'remark-rehype'
import rehypeStringify from 'rehype-stringify'

main()

async function main() {
  const file = await unified()
    .use(remarkParse) // マークダウン → mdast
    .use(remarkRehype) // mdast → hast
    .use(rehypeStringify) // hast → html
    .process('# Hello, Neptune!') // 一連の処理にマークダウンを投入

  console.log(String(file))
}
```

unifiedはまず、各種マークアップ言語を[抽象構文ツリー（Abstract Syntax Tree : AST）](https://github.com/syntax-tree/unist#syntax-tree)に変換します。ASTのマークダウン実装が[mdast](https://github.com/syntax-tree/mdast)です。

各種ASTは相互変換できます。mdastは`remark-rehype`プラグインを通してHTML用ASTの[hast](https://github.com/syntax-tree/hast)に再変換されます。

最後にhastをHTMLに変換して出力します。

## unifiedプラグイン

マークダウンからHTMLへの変換に成功しましたが、Qiitaでは以下のような装飾やマークダウンの拡張構文が利用されています。

- シンタックスハイライト
- リンクカード
- note記法

こうした拡張構文に対応するため、プラグインを導入します。

### 処理フローでの分類

実際にプラグインを導入する前に、unifiedプラグインがどのように分類されているかを解説します。unifiedプラグインは、どの処理フローで使われるかによって3つに分類されます。

- Parser
- Transformers
- Compiler

Parserは各種マークアップ言語をASTにパース（変換）する処理です。例としてremarkを構成するプラグイン[remark-parse](https://www.npmjs.com/package/remark-parse)はマークダウン言語を[mdast](https://github.com/syntax-tree/mdast)（マークダウンAST）にパースします。

TransformersはASTを変形します。例として[hast](https://github.com/syntax-tree/hast)にコードハイライトを追加する[rehype-highlight](https://github.com/rehypejs/rehype-highlight)がTrasfromersプラグインです。また、マークダウンの拡張構文プラグインもTransformersプラグインです。

CompilerはASTをマークアップ言語に再変換する処理です。hastをHTMLに変換する処理がこれにあたります。

#### 同期 / 非同期トランスフォーマー

Transformersプラグインは、内部で非同期処理を使っているか否かによって以下の2つに分類されます。

- 同期トランスフォーマー
- 非同期トランスフォーマー

`fetch`などの非同期処理を利用しているプラグインは、非同期トランスフォーマーになります。非同期トランスフォーマーは[processSync](https://github.com/unifiedjs/unified#processorprocesssyncfile)や[runSync](https://github.com/unifiedjs/unified#processorprocesssyncfile)などの同期処理に追加すると以下のエラーを返します。

```shell
Error: `processSync` finished async. Use `process` instead
```

### プラグインを追加する

それでは実際にプラグインを追加して、マークダウン構文を拡張します。

#### rehype-highlight

[rehype-highlight](https://github.com/rehypejs/rehype-highlight)はhastを解析し、コードブロックにシンタックスハイライトを追加します。

モジュールをインストールし

```shell
npm install rehype-highlight
```

hast変換後のプロセスにプラグインを追加します。

```js
import {unified} from 'unified'
import remarkParse from 'remark-parse'
import remarkRehype from 'remark-rehype'
import rehypeStringify from 'rehype-stringify'
import rehypeHighlight from 'rehype-highlight'// プラグインをimport

main()

async function main() {
  const file = await unified()
    .use(remarkParse) // マークダウン → mdast
    .use(remarkRehype) // mdast → hast
    .use(rehypeStringify) // hast → html
    .use(rehypeHighlight) // hastにhighlight.jsでの装飾を追加
    .process('# Hello, Neptune!') // 一連の処理にマークダウンを投入

  console.log(String(file))
}
```

[highlight.js](https://github.com/highlightjs/highlight.js)用CSSクラスが追加されます。お好きな[テーマ](https://highlightjs.org/static/demo/)を適用してシンタックスハイライトの完成です。

#### remark-link-card

マークダウン構文を拡張して、Qiitaのようなリンクカードを追加します。

https://github.com/gladevise/remark-link-card

`remark-link-card`モジュールをインストールします。

```shell
npm i remark-link-card
```

remarkプラグインはmdastに対して変換をかけます。そのため、mdastの変換後、hastの変換前に追加します。

```js
import {unified} from 'unified'
import remarkParse from 'remark-parse'
import remarkRehype from 'remark-rehype'
import rehypeStringify from 'rehype-stringify'
import rlc from 'remark-link-card'// プラグインをimport

main()

async function main() {
  const file = await unified()
    .use(remarkParse) // マークダウン → mdast
    .use(rlc) // リンクカード変換
    .use(remarkRehype, { allowDangerousHtml:true }) // mdast → hast
    .use(rehypeStringify, { allowDangerousHtml:true }) // hast → html
    .process(`# remark-link-card\n\nhttp://example.com/`) // 一連の処理にマークダウンを投入

  console.log(String(file))
}
```

##### allowDangerousHtml:trueとは？

remark-link-cardでは、リンクカードのHTMLを[直接mdastノードに挿入しています](
https://github.com/gladevise/remark-link-card/blob/951ce6b67768640fd2d3f68a737a8fe7e8b8fa8e/index.js#L160)。remark-rehypeおよびrehype-stringifyでは、セキュリティを維持するためmdastの[HTMLノード](https://github.com/syntax-tree/mdast#html)を削除します。

`allowDangerousHtml:true`オプションは、HTMLノード削除処理を一時的に停止させます。

https://github.com/syntax-tree/hast-util-to-html#optionsallowdangeroushtml

###### remark-rehypeはなぜHTMLノードを削除するのか

マークダウンによる入力は、信頼できない第三者からのものである可能性があります。たとえばQiitaのような、ユーザーが自由に記事やコメントを投稿できるWebサービスの場合、悪意ある第三者が投稿内にスクリプトを挿入し、それを閲覧したユーザーが被害に遭うというシナリオが考えられます。そのためremark-rehypeは標準設定でHTMLノードを削除します。

### プラグインを自作する

HTMLに変換するマークダウン記事が、常に信頼できる場合はallowDangerousHtmlオプションでの対処が可能です。また、remark-rehypeを挟まずremark単体で変換が完結する場合も、 HTMLノードの問題は発生しません。

しかし、信頼できない記事やコメントを取り扱い、なおかつremark-rehypeを通す場合には、プラグインを自作する必要があります。

:::note warn
自作プラグインの利用は攻撃の糸口となります。拡張構文解析に使う正規表現をターゲットにしたReDoS攻撃や、クロスサイトスクリプティング攻撃などが可能性として考えられます。利用シナリオを確認し、ご自身の責任で必要な対策を講じてください。
:::

マークダウン構文を拡張するunifiedプラグインは

- mdastトランスフォーマー
- rehypeパーサーのハンドラー

の2つの要素で構成されます。

https://unifiedjs.com/learn/guide/create-a-plugin/

#### mdastトランスフォーマー

マークダウンで記述されたテキストを、mdastに変換するプラグインを作成します。unifiedには、プラグインでよく使う処理をまとめたユーティリティモジュールがあります。今回はその1つ[unist-util-visit](https://unifiedjs.com/explore/package/unist-util-visit/)を利用します。

```js
import {visit} from 'unist-util-visit'

export default function retextSentenceSpacing() {
   return (tree, file) => {
    visit(tree, 'ParagraphNode', (node) => {
      console.log(node)
    })
  }
}

//このサンプルで文節型のノードがconsoleに出力されます。
```

URLリンクのみの文節があったら、カスタム型`LinkCard`ノードを作成し、ページのメタデータをpropertiesに埋め込みます。

```js
import {visit} from 'unist-util-visit'

export default function remarkLinkCardPlugin() {
    return async (tree) => {
      const promises: any[] = []; // visitorを配列に格納
    
      const visitor = (node, index, parent) => {
        const children = [...node.children];
    
        promises.push(async () => {
          const url = children[0].value;
          const meta = await MetadataScraper(url); // metaデータをfetchする
    
          parent.children[index] = {
            type: "LinkCard",
            properties: {
              className: [],
              title: meta.title,
              image: meta.image,
              urlOrigin: new URL(meta.url).origin,
            },
            children,
          };
        });
      };
    
      visit(tree, this.isLink, visitor); // 第2引数にbooleanを返す関数を渡すと、nodeがプラグインの対象か否かを判定します。
      await Promise.all(promises.map((t) => t())); // visitorの処理を待ちます
    };
};
```

コードの全体はこちらをご確認ください。

https://github.com/MasatoMakino/qiita-to-md/blob/main/src/plugin/RemarkLinkCardPlugin.ts

#### rehypeパーサーのハンドラー

つぎに、mdastのカスタムノードをhastのノードに変換するハンドラーを作成します。

`remark-rehype`は[handler](https://github.com/remarkjs/remark-rehype#optionshandlers)というインターフェイスで、カスタムノードの変換処理を受け取ります。

```js
  public static async convertToHTML(body: string) {
    const result = await unified()
      .use(remarkParse)
      .use(remarkRehype, {
        handlers: {
          LinkCard: RemarkLinkCardPlugin.rehypeHandler,// ここがハンドラー。
        },
      }) 
      .use(rehypeStringify)
      .process(body);
    return result.value;
  }
```

ハンドラーは、mdastのtypeとハンドラーオプションのkeyが一致すると動作します。ハンドラーはhastのnodeを返す関数です。

```js
  const rehypeHandler = (h, node) => {
    return {
      type: "element",
      tagName: "a",
      properties: {
        className: ["rlc-container"],
        href: url,
      },
      children:[...子要素],
    };
  }
```

ハンドラーの第一引数`h`は[mdast-util-to-hast](https://github.com/syntax-tree/mdast-util-to-hast)のインスタンスです。mdastのツリーとユーティリティ関数をプロパティとして持っていて、ノードツリーの再帰的な変換が必要な場合に利用します。今回はリンクカードの中にリンクカードがある場合を考えないので利用しません。

これでremarkとrehypeを利用した自作プラグインができました。

## 個人的な感想

unifiedを直接利用する機会は少ないですが、[gatsby-transformer-remark](https://www.gatsbyjs.com/plugins/gatsby-transformer-remark/)などを通じて間接的に利用している場合があります。一度直接触ってみることで、各種サービスのパーサーがどのように動作しているのか理解が進みました。

## 参考記事

https://zenn.dev/januswel/articles/745787422d425b01e0c1

https://www.haxibami.net/blog/posts/blog-renewal

https://qiita.com/sankentou/items/f8eadb5722f3b39bbbf8

以上、ありがとうございました。