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 を適用させたりすることもできる。