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つ。
- rehype-slug
- rehype-toc
- npm パッケージ名は
rehype-toc
でも@jsdevtools/rehype-toc
でも、どちらでも同じモノが落とせる
- npm パッケージ名は
# 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
のように ## 目次
と書いた場所に挿入するような自由度が欲しいが、残念ながら現時点では実装されていない。
- 参考 : Possibility to put the TOC in a specific element · Issue #2 · JS-DevTools/rehype-toc · GitHub
- 「任意の場所に挿入できるようにしてくれ」という Issue
挿入位置以外のオプションは豊富で、色々とカスタマイズしてなるべく remark-toc
に近い出力結果になるようにしてみた。
h1
要素をページタイトルにしている場合、コレが ToC に含まれると違和感があるので、オプションのheadings
でh1
要素を除外したnav
やcssClasses
による加工の他、customizeTOC
やcustomizeTOCItem
は hast (HTML AST) を関数で受け取って処理できるので、要素や属性、文言の追加・変更などができる
目次要素への ID 付与は rehype-slug
が行っているが、<h2 id="first">
のように、既に ID が付与されている場合はその値がそのまま利用される。rehype-toc
との連携も問題なし。rehype-toc
は href
属性値をパーセント・エンコーディングしないようだが、動作には特に問題ない。
以上
remark-slug
・remark-toc
のコンビと、rehype-slug
・rehype-toc
のコンビを紹介した。プラグインの作者も違って、微妙に仕様が異なる。特に rehype-toc
は目次リストの挿入位置が決められないので、若干使い勝手が悪い気がするが、ココは適宜、パース後に自分で replace()
をかけるなど原始的な方法でも調整できそう。