Remark・Rehype プラグインで文書の見出しに自動で ID を振り目次リストを自動生成する

Markdown や HTML をパースして加工できる Remark・Rehype のプラグイン紹介。今回は見出しに id 属性を自動付与し、目次 (Table of Contents・ToC) を自動生成してくれるプラグインを紹介する。

目次

Markdown から HTML にパースする際に見出し ID と目次を自動付与する

Markdown から HTML にパースする際に自動付与するには、

というパッケージを使うのが良いだろう。インストールする npm パッケージは以下のとおり。

# Markdown を HTML へと変換するために必要な最低限の Unified・Remark・Rehype 関連パッケージ
$ npm install --save-dev unified remark-parse remark-rehype rehype-stringify

# ID 付与と ToC 付与に必要なパッケージ
$ npm install --save-dev remark-slug remark-toc

サンプルの Markdown はこんな感じ。

# タイトル
文章

## 目次

## はじめに
文章…

## つぎに
文章…

### 補足
文章…

## さらに
文章…

## 目次 という、見出しだけの行があることに留意。

以下のようにパースする。

const fs = require('fs').promises;

const unified = require('unified');
const remarkParse = require('remark-parse');
const remarkSlug = require('remark-slug');
const remarkToc = require('remark-toc');
const remarkRehype = require('remark-rehype');
const rehypeStringify = require('rehype-stringify');

(async () => {
  const inputMarkdown = await fs.readFile('./example.md', 'utf-8');
  
  const processor = unified()
    .use(remarkParse)
    .use(remarkSlug)  // 見出しに ID を自動付与する
    .use(remarkToc, {
      heading: '目次',  // Table of Contents を挿入するための見出しを指定する
      tight: true       // `true` にすると `li` 要素内に `p` 要素を作らないようになる
    })
    .use(remarkRehype)
    .use(rehypeStringify);
  const result = await processor.process(inputMarkdown);
  
  const outputHtml = result.contents;
  await fs.writeFile('./example.html', outputHtml, 'utf-8');
})();

こうすると、生成される HTML は次のようになる。インデントは少し整形している。

<h1 id="タイトル">タイトル</h1>
<p>文章</p>
<h2 id="目次">目次</h2>
<ul>
  <li><a href="#%E3%81%AF%E3%81%98%E3%82%81%E3%81%AB">はじめに</a></li>
  <li><a href="#%E3%81%A4%E3%81%8E%E3%81%AB">つぎに</a>
    <ul>
      <li><a href="#%E8%A3%9C%E8%B6%B3">補足</a></li>
    </ul>
  </li>
  <li><a href="#%E3%81%95%E3%82%89%E3%81%AB">さらに</a></li>
</ul>
<h2 id="はじめに">はじめに</h2>
<p>文章…</p>
<h2 id="つぎに">つぎに</h2>
<p>文章…</p>
<h3 id="補足">補足</h3>
<p>文章…</p>
<h2 id="さらに">さらに</h2>
<p>文章…</p>

remark-slug プラグインにより、各見出しに id 属性が自動付与された。id 属性値には日本語が混じっていても HTML 仕様上は問題ない。

海外ではこのような見出し ID のことを Slug と呼ぶらしい。

で、## 目次 と書いていた部分には、ネストされた ul 要素が追加されていて、各見出しへのリンクが自動的に追加されている。コチラは remark-toc プラグインが処理するためか、href 属性値がパーセント・エンコーディングされているが、動作上は問題ない。

HTML をパースして見出し ID と目次を自動付与する

次は、Markdown ファイルではなく HTML ファイルがベースの場合。HTML を扱う場合は、Remark ではなく Rehype 関連のプラグインを使うと良い。今回使うのは以下の2つ。

# HTML を扱うために必要な最低限の Unified・Rehype 関連パッケージ
$ npm install --save-dev unified rehype-parse rehype-stringify

# ID 付与と ToC 付与に必要なパッケージ
$ npm install --save-dev rehype-slug @jsdevtools/rehype-toc

サンプルの HTML はこんな感じ。「はじめに」の章だけ、id="first" と自前の ID が振られていることに留意。

<h1>タイトル</h1>
<p>文章</p>
<h2 id="first">はじめに</h2>
<p>文章…</p>
<h2>つぎに</h2>
<p>文章…</p>
<h3>補足</h3>
<p>文章…</p>
<h2>さらに</h2>
<p>文章…</p>

そして以下のようにパースする。

const fs = require('fs').promises;

const unified = require('unified');
const rehypeParse = require('rehype-parse');
const rehypeSlug = require('rehype-slug');
const rehypeToc = require('rehype-toc');
const rehypeStringify = require('rehype-stringify');

(async () => {
  const inputHtml = await fs.readFile('./example-input.html', 'utf-8');
  
  const processor = unified()
    .use(rehypeParse, {
      // `html`・`head`・`body` 要素がない HTML ファイルをそのまま変換する
      // (`html`・`head`・`body` 要素を自動付与しない)
      fragment: true
    })
    .use(rehypeSlug)
    .use(rehypeToc, {
      headings: ['h2', 'h3', 'h4', 'h5', 'h6'],  // `h1` 要素をリストに含めない
      nav: false,  // `nav` 要素を付与しない (`false`)
      cssClasses: {  // 各要素に付与する CSS クラス名。ココでは CSS クラス名を付与しない
        toc: '',
        list: '',
        listItem: '',
        link: ''
      },
      // hast という AST 仕様に則り、生成される ToC をカスタマイズできる
      // デフォルトでは `ol` 要素で出力されるリストを `ul` 要素に書き換えている (ネストするあため再帰呼び出し)
      customizeTOC: (toc) => {
        const replacer = (children) => {
          children.forEach(child => {
            if(child.type === 'element' && child.tagName === 'ol') {
              child.tagName = 'ul';
            }
            if(child.children) {
              replacer(child.children);
            }
          });
        };
        replacer([toc]);
      }
    })
    .use(rehypeStringify);
  const result = await processor.process(inputHtml);
  
  const outputHtml = result.contents;
  await fs.writeFile('./example-output.html', outputHtml, 'utf-8');
})();

生成される HTML は次のようになる。インデントのみ調整してある。

<ul>
  <li><a href="#first">はじめに</a></li>
  <li><a href="#つぎに">つぎに</a>
    <ul>
      <li><a href="#補足">補足</a></li>
    </ul>
  </li>
  <li><a href="#さらに">さらに</a></li>
</ul>

<h1 id="タイトル">タイトル</h1>
<p>文章</p>
<h2 id="first">はじめに</h2>
<p>文章…</p>
<h2 id="つぎに">つぎに</h2>
<p>文章…</p>
<h3 id="補足">補足</h3>
<p>文章…</p>
<h2 id="さらに">さらに</h2>
<p>文章…</p>

目次を構成する ul 要素が、HTML の先頭に付与されている。remark-toc のように ## 目次 と書いた場所に挿入するような自由度が欲しいが、残念ながら現時点では実装されていない。

挿入位置以外のオプションは豊富で、色々とカスタマイズしてなるべく remark-toc に近い出力結果になるようにしてみた。

目次要素への ID 付与は rehype-slug が行っているが、<h2 id="first"> のように、既に ID が付与されている場合はその値がそのまま利用される。rehype-toc との連携も問題なし。rehype-tochref 属性値をパーセント・エンコーディングしないようだが、動作には特に問題ない。

以上

remark-slugremark-toc のコンビと、rehype-slugrehype-toc のコンビを紹介した。プラグインの作者も違って、微妙に仕様が異なる。特に rehype-toc は目次リストの挿入位置が決められないので、若干使い勝手が悪い気がするが、ココは適宜、パース後に自分で replace() をかけるなど原始的な方法でも調整できそう。