Rehype プラグインで Markdown からキレイな HTML ドキュメントを生成する
Markdown から HTML にパースしてくれる、Remark・Rehype プラグイン。コレまでも色々なプラグインを紹介してきたが、生成される HTML のインデントなど整形されておらず、読みづらかった。また、html・head・body 要素などがイマイチで、素のままだと正しい HTML ファイルとして扱えなかった。
今回は、html 要素などをキレイに挿入し、インデントを調整してキレイな HTML ソースを出力するためのプラグインを紹介する。
目次
インストールするパッケージ
今回使うパッケージは以下。
Markdown から HTML へのパースに備えて、次の npm パッケージが必要となる。
# Markdown を HTML へと変換するために必要な最低限の Unified・Remark・Rehype 関連パッケージ
$ npm install --save-dev unified remark-parse remark-rehype rehype-stringify
# HTML ドキュメントの出力と、フォーマット用のプラグイン
$ npm install --save-dev rehype-document rehype-format
サンプルコード
次のようなコードで、Markdown ファイルから「キレイな HTML ドキュメント」が出力できる。
const fs = require('fs').promises;
const unified = require('unified');
const remarkParse = require('remark-parse');
const remarkRehype = require('remark-rehype');
const rehypeDocument = require('rehype-document');
const rehypeStringify = require('rehype-stringify');
const rehypeFormat = require('rehype-format');
(async () => {
const inputMarkdown = await fs.readFile('./example.md', 'utf-8');
const processor = unified()
.use(remarkParse)
.use(remarkRehype, { // Markdown から HTML に変換する
allowDangerousHtml: true // Markdown 中に `script`・`style` 要素などが記述されていてもそのまま HTML 出力する
})
.use(rehypeDocument, {
title: `ほーむぺーじ`, // `title` 要素の値
language: 'ja', // `html` 要素に付与する `lang` 属性値
responsive: true, // `<meta name="viewport">` 指定を自動付与してくれる
meta: [ // 独自の `meta` 要素を追加できる
{ name: 'robots', content: 'index, follow' }
],
link: [ // 独自の `link` 要素を追加できる
{ rel: 'icon', href: './favicon.ico' }
],
style: ['\nbody {\n font-size: 1rem;\n}\n'], // `head` 要素内に `style` 要素を配置してインラインスタイルを書ける
css: ['./styles.css'], // 外部 CSS ファイルを読み込める
script: ['\nfunction example() {\n alert("Hello");\n}\n'], // `body` 要素の最後に `script` 要素を配置してインラインスクリプトを書く
js: ['./scripts.js'] // `body` 要素の最後で外部 JS ファイルを読み込める
})
.use(rehypeStringify, {
upperDoctype: true, // `<!DOCTYPE html>` と大文字で表記する
allowDangerousHtml: true // `script`・`style` 要素などが記述されていてもそのまま HTML 出力する
})
.use(rehypeFormat, { // HTML フォーマットする
indent: 2, // インデントのスペース数 (デフォルト `2`)
indentInitial: true // 最初のネストからインデントを付ける (デフォルト `true`)
});
const result = await processor.process(inputMarkdown);
const outputHtml = result.contents;
await fs.writeFile('./example.html', outputHtml, 'utf-8');
})();
rehype-document のオプションが豊富。meta・link・style・script 要素などを柔軟に埋め込める。特に必要がなければオプションを省略すれば良い。
remark-rehype で allowDangerousHtml: true を指定しているが、コレは Markdown 中に style や script 要素が記述されていた時に、それを残すための指定。
rehype-stringify で allowDangerousHtml: true を指定しているのは、Markdown 中の記述の他、rehype-document で追加した style や script 要素が消えないようにするためだ。
rehype-format は rehype-stringify の後に使う。デフォルトで2スペースインデントなので、実はオプション指定しなくても良い感じになる。
コレで次のような HTML が生成できる。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title>ほーむぺーじ</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="index, follow">
<link rel="icon" href="./favicon.ico">
<style>body {
font-size: 1rem;
}</style>
<link rel="stylesheet" href="./styles.css">
</head>
<body>
<h1>タイトル</h1>
<p>文章</p>
<h2>はじめに</h2>
<p>文章…</p>
<script>function example() {
alert("Hello");
}</script>
<script src="./scripts.js"></script>
</body>
</html>
元の Markdown は
# タイトル
文章
## はじめに
文章…
だけだったのに、Valid で Formatting された HTML に変換できた。
挙動が分かるように、わざと style と script オプションで指定したコードの前後に改行コードを入れたのだが、出力結果を見てもらえば分かるとおり、
<style>body {だとか}</script>だとか
いうように、タグの前後で改行されないのがちょっと惜しいところ。個人的には
<style>
body {
font-size: 1rem;
}
</style>
ないしは
<script>
function example() {
alert("Hello");
}
</script>
のようにインデントと改行を入れて欲しいところだったが…。
title オプションの値を Front Matter から抽出する
rehype-document の title オプションでページタイトルを指定できるのだが、コレを Markdown ファイルごとに変えたい。
もっというと、css や js オプションの値なんかも、個々の Markdown ファイルで管理できたら柔軟性が高くなって嬉しい。
そこで考えたのが、以前紹介した remark-extract-frontmatter をつかって、事前に Front Matter 部分を抽出しておき、それを使って改めてパースしてやる、という方法だ。
# 追加で以下のパッケージをインストールする
$ npm install --save-dev remark-frontmatter remark-extract-frontmatter yaml
Markdown ファイルには次のように Front Maatter セクションを用意する。ハイフン部分は本来半角。
---
title: サンプルページ
language: ja,
meta:
- name : robots
content: index, follow
link:
- rel : icon
href: ./favicon.ico
style:
- |
body {
font-size: 1rem;
}
css:
- ./styles.css
script:
- |
function example() {
alert("Hello");
}
js:
- ./scripts.js
---
# タイトル
文章
## つぎに
文章…
そして、次のようにパースする。
const fs = require('fs').promises;
const unified = require('unified');
const remarkParse = require('remark-parse');
const remarkFrontmatter = require('remark-frontmatter');
const remarkExtractFrontmatter = require('remark-extract-frontmatter');
const yaml = require('yaml');
const remarkRehype = require('remark-rehype');
const rehypeDocument = require('rehype-document');
const rehypeStringify = require('rehype-stringify');
const rehypeFormat = require('rehype-format');
(async () => {
const inputMarkdown = await fs.readFile('./example.md', 'utf-8');
// 先に Front Matter 部分だけ抽出する
const preprocessor = unified()
.use(remarkParse)
.use(remarkFrontmatter, [{
type: 'yaml',
marker: '-',
anywhere: false
}])
.use(remarkExtractFrontmatter, {
yaml: yaml.parse,
name: 'frontMatter'
})
.use(remarkRehype) // `remark-stringify` だと `Error: Cannot handle unknown node `yaml`` が発生したため、とりあえず HTML に変換する
.use(rehypeStringify); // `Error: Cannot `process` without `Compiler`` を回避するためとりあえず入れておく
const preprosessorResult = await preprocessor.process(inputMarkdownText);
const frontMatter = preprosessorResult.data.frontMatter;
// Front Matter から特定のキーの値のみ抽出し、rehype-document のオプションを組み立てる
const documentOptions = ['title', 'language', 'meta', 'link', 'style', 'css', 'script', 'js']
.reduce((acc, key) => frontMatter[key] ? { ...acc, [key]: frontMatter[key] } : acc, {});
// 本処理
const processor = unified()
.use(remarkParse)
.use(remarkFrontmatter) // パースして除去しておかないと Front Matter の情報が HTML に出力されてしまう
.use(remarkRehype)
.use(rehypeDocument, documentOptions) // 上でまとめたオプションを指定する
.use(rehypeStringify)
.use(rehypeFormat);
const result = await processor.process(inputMarkdown);
const outputHtml = result.contents;
await fs.writeFile('./result.html', outputHtml, 'utf-8');
})();
こんな感じ。Processor#process() が2回登場していて、2回パースしているのが分かる。
最初の Processor で Front Matter を抽出している。Front Matter 部分が欲しいだけなのだが、Unified の仕様上、Parse・Transform・Stringify がセットになっていないと動作しないので、remark-rehype (Transform) と rehype-stringify (Stringify) を後ろに付けて、とりあえず変換をかませている。変化して生成された HTML ソースは使っていないワケだ。
変数 documentOptions は、Front Matter から rehype-document で使用するプロパティのみを抽出して連想配列を作っているだけ。もしも rehype-document のオプションと同じ項目しか用意しないのであれば、変数 frontMatter を直接 rehype-document のオプションオブジェクトとして渡してしまっても良い。
一度 Markdown の方を見てみよう。rehype-document のオプション名に合わせて項目を用意している。title と language 以外は配列なので、配列の形式で書いている。style や script で埋め込めるインラインコードは、パイプ | を用いてテンプレートリテラル的に記述している。いずれも、YAML から JSON に変換したら、rehype-document のオプションと同じ型になる寸法だ。要らない項目があれば、ただ書かないでおけば良い。
このようにすれば、生成される HTML 中の title 要素の値は Front Matter で定義した title プロパティの値になるし、Markdown ごとに異なる CSS を適用させたりすることもできる。