unifiedとrehypeによるHTMLの加工 前編 unifiedとはどういうものなのか

「CMSのWYSIWYGで入力されたデータにdivやクラスを追加したい」とか「見出しから目次を作りたい」など、自由にHTMLを加工したいという要望はないでしょうか。unifiedというnpmパッケージでrehypeにあるプラグインを利用すると、自分が希望する操作を行えます。

発行

著者 高津戸 壮 テクニカルディレクター
unifiedとrehypeによるHTMLの加工 シリーズの記事一覧

はじめに

みなさんは「自由にHTMLを加工したい!」と思ったことはないでしょうか。そう言われてもなんのことやらと感じられるかと思いますので、まずはじめに、この連載で解説する処理を2つ、紹介します。

1. WYSIWYGっぽいHTMLを加工する

まず1つ目は、WYSIWYGで作られたようなこんなHTMLを、

変換前

<h1>記事タイトル</h1>
<p>段落</p>
<ul>
  <li>リスト</li>
  <li>リスト</li>
</ul>

こんなふうにします。

変換後

<div class="h1-wrapper">
  <h1>記事タイトル</h1>
</div>
<div class="p-wrapper">
  <p>段落</p>
</div>
<ul class="text-list">
  <li class="text-list-item">リスト</li>
  <li class="text-list-item">リスト</li>
</ul>

別の要素で包んだり、クラスを追加したり、です。

2. h2だけを拾って目次のページ内アンカーを作る

そして2つ目は、こんなHTMLから、

変換前

<h1>記事タイトル</h1>
<h2 id="toc-title">目次</h2>
<ol id="toc-holder"></ol>
<h2>大見出し1</h2>
<p>段落</p>
<h2>大見出し2</h2>
<p>段落</p>
<h2>大見出し3</h2>
<p>段落</p>
<h2>大見出し4</h2>
<p>段落</p>

h2だけで見出しを作り、以下のようにします。

変換後

<h1 id="記事タイトル">記事タイトル</h1>
<h2 id="toc-title">目次</h2>
<ol id="toc-holder">
  <li><a href="#大見出し1">大見出し1</a></li>
  <li><a href="#大見出し2">大見出し2</a></li>
  <li><a href="#大見出し3">大見出し3</a></li>
  <li><a href="#大見出し4">大見出し4</a></li>
</ol>
<h2 id="大見出し1">大見出し1</h2>
<p>段落</p>
<h2 id="大見出し2">大見出し2</h2>
<p>段落</p>
<h2 id="大見出し3">大見出し3</h2>
<p>段落</p>
<h2 id="大見出し4">大見出し4</h2>
<p>段落</p>

どうでしょうか? なにかしらのCMSで入力されたデータを処理する際など、こういうことをやりたかったことはないでしょうか。「WYSIWYGで入力されたデータなんだけど、他の画面で使われている見出しのデザインを反映したい……だけどdivやらクラスやらが足りない!」とか、「見出しから勝手に目次を作りたいとか言われても、そんな機能はこのCMSにはないんだよ……」などです。

「あー、あるある!」と思った方は、昔ながらのやり方として、jQueryでがんばってDOMを操作したりなどしたことがある人もいるのではないでしょうか。

この連載では、unifiedとrehypeを使い、このようなHTMLを加工する方法を解説します。unifiedとrehypeを使えば、ビルド時にこのような文字列操作を柔軟に行うことができるのです。

例として挙げた上記2つの処理を見ていきながら、unifiedとrehypeを理解していくという流れで進めます。上記2つの処理を書いたスクリプトは、実際にお手元で動かしていただくこともできます。以下リポジトリに置いてあるので、よろしければご参照ください。

留意事項

この連載を読み進めてもらう前に、いくつか留意しておいていただきたい点があります。

まず一つは、この連載には、かなり多くのパッケージが登場します。筆者は初めunifiedに触れたときに、そのパッケージの数の多さに面食らってしまったのですが、ひとまず、コードを読む前に、unifiedとはそういうものであると認識してもらえればと思います。どうしてそうなっているのかも今回解説します。

そしてもう一つ、この連載は、unifiedについての、深く突っ込んだ解説は行いません。基本的には、読者の方にunifiedとrehypeに興味をもってもらい、自分でも触ってみようかなと感じてもらうことをゴールにします。ですので、ざっくり概念だけを理解していただければと考え、この原稿を書いています。

筆者もunified自体にそんなに詳しいわけではないので、ひとまず興味を持ったら、まずは自分で動かしてみて実感してもらうのが良いかと考えています。そして、もっと深くunifiedについて知りたくなったら、ぜひunifiedのWebサイトにあるドキュメントを読み、理解を深めてみてください。

unifiedとは何か

ではさっそく、話を進めていきましょう。まずは、unifiedとは何かということについてです。unifiedとは、npmのパッケージであり、以下がそのWebサイトになります。

このWebサイトのトップページには以下のようにあります。

We compile content to syntax trees and syntax trees to content.

「コンテンツを構文木(Syntax tree)に変換し、構文木をコンテンツに変換する」と。今回紹介するサンプルからすると、より具体的には、

  • コンテンツ: HTMLの文字列
  • 構文木: JSON

になります。

「構文木」とは、筆者はその定義を正確に表現するのにあまり自信がありませんが、「プログラム的に処理しやすいように構造化したデータ」と考えておいて良いかと思います。今回はJSONがそのデータを表現する形式です。

  1. HTMLの文字列を一度JSONにし、
  2. そのJSONを操作し、
  3. 最終的にはまたHTMLにする

という処理を行ってくれるのがunifiedです。

しかし、unified自身には具体的な変換処理は書かれていません。unifiedのリポジトリのreadmeを見ると、もうちょっと詳しく書いてあります。

unified is an interface for processing text using syntax trees

「unifiedは構文木を使ってテキストを処理するためのインターフェース」であると。unified自身は、ただのインターフェース、プログラムを書く上でのルールのようなもの。unifiedだけでは、冒頭で例示したような変換はできないのです。

unifiedで何か処理するには、プラグインを使う必要があります。プラグインは自分でも書けるし、誰かが作ったものを使っても問題ありません。

rehypeとは何か

なるほど? わかったような、わからないような……。では具体的にHTMLをいじるにはどうすればよいのだ? ということで、登場するのがrehypeです。

rehypeとは、unified上でHTMLに関する操作を行うプラグインの集合と捉えておけば、ひとまずは良いかと思います。unifiedのドキュメントではrehypeのことを「エコシステム」であると言っています。「HTMLに関する処理を革新的にスマートに行うことを志すのが、rehypeという自律的な組織であり、エコシステムである」というようなニュアンスのことが、ドキュメントには書かれています。

rehypeと似たものとして、remarkという、Markdownの処理を行うもの、retextという、自然言語の処理を行うものがあります。

rehypeのプロジェクト配下には、たくさんのHTMLに関する操作を行うプラグインがまとめられており、そのMarkdown版がremark、自然言語版がretextです。それぞれのプラグインはほとんどの場合、数十行程度のとても小さいスクリプトであり、それぞれがちょっとした処理を行うものとしてまとめられています。unifiedはこれらを好きに組み合わせて、自分の実現したい操作を行うよう、意図して設計されています。

それぞれのプラグインは、たいていの場合独立したnpmパッケージとして配布されているため、unifiedを使う場合は必然的に多くのパッケージをimportすることになります。

こんなように、unifiedは、unified自体と、そのプラグインらで構成されるコミュニティを介して自然と成り立つように意図して設計されたプロジェクトです。

処理の流れの概念

unifiedとrehype自体の概念についてはこんなところに留めておき、実際に動くコードとしてはどうなるのかを見ていきましょう。まずは冒頭で紹介した、次の変換を行う処理についてです。

変換前

<h1>記事タイトル</h1>
<p>段落</p>
<ul>
  <li>リスト</li>
  <li>リスト</li>
</ul>

変換後

<div class="h1-wrapper">
  <h1>記事タイトル</h1>
</div>
<div class="p-wrapper">
  <p>段落</p>
</div>
<ul class="text-list">
  <li class="text-list-item">リスト</li>
  <li class="text-list-item">リスト</li>
</ul>

この変換を行っているコードは以下になります。

レポジトリをcloneして、

npm ci
npm run example1

すれば、手元で結果を確認することもできるので、よかったら試してみて頂ければと思います。

コードを実際に読む前に、unifiedが文字列を処理する流れを図にしたので、まずはそれを見て流れを理解しましょう。

これは、unifiedのreadmeにある説明を元に作った図です。謎の機械のようなもので描かれていますが、筆者がunifiedをなんとか理解しやすいよう表現した結果がコレです。HTMLを入れると、最終的には加工済のHTMLが出てきます。この機械のような部分は、unifiedが「Processor」と名付けているオブジェクトであり、これがテキストを変換する役割を担います。

ごく単純なコードとともに、ざっくり流れを解説してみます。unifiedはまず、初めのProcessorを作ります。

初めのProcessorを作成

const processor = unified();

そして、このProcessorを、以下のように、プラグインを使って拡張していくことができます。

プラグインを使って拡張

processor.use(plugin1);
processor.use(plugin2);
processor.use(plugin3);
processor.use(plugin4);

ここまでが準備で、最後に、

処理したい文字列を渡してProcessorを実行

const vfile = processor.processSync("<h1>見出しです</h1>");
console.log(vfile.toString());

のように、処理したい文字列を渡してProcessorを実行すると、処理された結果が得られるという具合です。

処理結果はVFileという形式のデータとして得られますが、これはファイルに書き出したりなどがしやすいようにまとめられているものです。今回はただ処理後の文字列がほしいだけです。.toString()すれば、純粋なStringが得られるので、上記ではそのようにして得た文字列をconsole.logしています。

それぞれの処理を担うパッケージ

ここまでで「プラグイン」と呼んでいるものは、大きく分けると次の3種類に分類されます。一つ前の図にも書かれていた3つです。

  • A: Parser: 文字列を構文木に変換するもの
  • B: Transformer: 構文木を別の構文木に変換するもの
  • C: Compiler: 構文木を文字列に変換するもの

今回のサンプルで行っている処理を一つずつ列挙すると、以下になります。それぞれの処理をになっているプラグインが上記3種類のうちのいずれになるか、合わせて見てみましょう。

これを先ほどの図の中で示すと次のようになります。

それぞれの処理を担う具体的なパッケージ

こんなふうにProcessorをどんどん拡張して、自分のやりたい処理を作っていくわけです。

具体的なコード

いやー、いろいろ出てきてややこしいですね。

概念的には複雑ですが、実際のコードとしては、それぞれのプラグインをひたすら.use(plugin, options)でProcessorにくっつけていくような感覚で書くことができ、そんなに複雑なことが要求されるわけではありません。ほとんどの場合、プラグインのオプションをちょっと書くだけで済むでしょう。

では、やっと具体的なコードです。 ひとつひとつコメントを書いておいたので、順に読んでみてください。

WYSIWYGで作られたHTMLを加工する

import { unified } from "unified";
import rehypeParse from "rehype-parse";
import rehypeStringify from "rehype-stringify";
import rehypeWrapAll from "rehype-wrap-all";
import rehypeAddClasses from "rehype-add-classes";
import rehypeFormat from "rehype-format";

// 処理前のHTML文字列
const html = `
<h1>記事タイトル</h1>
<p>段落</p>
<ul>
  <li>リスト</li>
  <li>リスト</li>
</ul>
`;

// まずはProcessorを作成
const processor = unified();

// rehype-parseプラグインを使う
// - HTML文字列を構文木に変換するParser
// - HTML文字列の一部を使うオプションを指定
processor.use(rehypeParse, { fragment: true });

// rehype-wrap-allプラグインを使う
// - <h1>を<div class="h1-wrapper">で包む
// - <p>を<div class="p-wrapper">で包む
processor.use(rehypeWrapAll, [
  {
    selector: "h1",
    wrapper: "div.h1-wrapper",
  },
  {
    selector: "p",
    wrapper: "div.p-wrapper",
  },
]);

// rehype-add-classesプラグインを使う
// - <ul>に"text-list"クラスを追加
// - <li>に"text-list-item"クラスを追加
processor.use(rehypeAddClasses, {
  ul: "text-list",
  li: "text-list-item",
});

// rehype-formatプラグインを使う
// いい感じにインデントなど揃えてくれる
processor.use(rehypeFormat);

// rehype-stringifyプラグインを使う
// - 構文木をHTML文字列に変換するCompiler
processor.use(rehypeStringify);

// HTML文字列を処理してVFileを作成
const vfile = processor.processSync(html);
// VFileを文字列にしてconsole.log
console.log(vfile.toString());

前述したuseメソッドを使っている箇所が、プラグインをProcessorにくっつけている箇所です。プラグイン側で用意されていれば、.use(plugin, options)と、第二引数でオプションを渡すことができます。今回のコードの中では、

  • rehype-wrap-allで具体的にどの要素をどう囲むのか
  • rehype-add-classesでどの要素になんのクラスを追加するのか

を、オプションとして指定しているのがわかるかと思います。

これで、

変換前

<h1>記事タイトル</h1>
<p>段落</p>
<ul>
  <li>リスト</li>
  <li>リスト</li>
</ul>

が、以下に変換されます。

変換後

<div class="h1-wrapper">
  <h1>記事タイトル</h1>
</div>
<div class="p-wrapper">
  <p>段落</p>
</div>
<ul class="text-list">
  <li class="text-list-item">リスト</li>
  <li class="text-list-item">リスト</li>
</ul>

どうでしょうか。

これなら、ビルド時にHTMLの加工を済ませておけるので、ブラウザ上でjQueryなんかでがんばるよりもスマートではないでしょうか。もちろん、今回紹介した以外にも、コードのハイライトを行わせたり、見出しとその内容を自動でsectionで包んだりさせるなど、たくさんのプラグインがあります。

rehypeのプラグインの一覧は以下にまとまっています。ぜひ眺めてみて、何ができるのか想像してみてください。夢が広がります。

こんなふうに、unifiedとrehypeを使うと、HTMLの文字列を自由に加工することが可能です。今回紹介したような処理は、Jamstack的な設計の場合、有効に使えるケースが多いんじゃないでしょうか。筆者は冒頭で紹介したように、

WYSIWYGで入力されたデータなんだけど、他の画面で使われている見出しのデザインを反映したい……だけどdivやらクラスやらが足りない!

というときに、初めてunifiedとrehypeを使ったのでした。

次回は、もう一つのサンプルを見ながら、この一連の処理の中で具体的にどういうことが行われているかを解説します。