Rehype プラグインで Markdown からキレイな HTML ドキュメントを生成する

Markdown から HTML にパースしてくれる、Remark・Rehype プラグイン。コレまでも色々なプラグインを紹介してきたが、生成される HTML のインデントなど整形されておらず、読みづらかった。また、htmlheadbody 要素などがイマイチで、素のままだと正しい 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 のオプションが豊富。metalinkstylescript 要素などを柔軟に埋め込める。特に必要がなければオプションを省略すれば良い。

remark-rehypeallowDangerousHtml: true を指定しているが、コレは Markdown 中に stylescript 要素が記述されていた時に、それを残すための指定。

rehype-stringifyallowDangerousHtml: true を指定しているのは、Markdown 中の記述の他、rehype-document で追加した stylescript 要素が消えないようにするためだ。

rehype-formatrehype-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 に変換できた。

挙動が分かるように、わざと stylescript オプションで指定したコードの前後に改行コードを入れたのだが、出力結果を見てもらえば分かるとおり、

いうように、タグの前後で改行されないのがちょっと惜しいところ。個人的には

<style>
  body {
  font-size: 1rem;
  }
</style>

ないしは

<script>
function example() {
  alert("Hello");
}
</script>

のようにインデントと改行を入れて欲しいところだったが…。

title オプションの値を Front Matter から抽出する

rehype-documenttitle オプションでページタイトルを指定できるのだが、コレを Markdown ファイルごとに変えたい。

もっというと、cssjs オプションの値なんかも、個々の 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 のオプション名に合わせて項目を用意している。titlelanguage 以外は配列なので、配列の形式で書いている。stylescript で埋め込めるインラインコードは、パイプ | を用いてテンプレートリテラル的に記述している。いずれも、YAML から JSON に変換したら、rehype-document のオプションと同じ型になる寸法だ。要らない項目があれば、ただ書かないでおけば良い。

このようにすれば、生成される HTML 中の title 要素の値は Front Matter で定義した title プロパティの値になるし、Markdown ごとに異なる CSS を適用させたりすることもできる。