rehypeによるHTMLポストプロセッシング

シリアライズ済みの完全なHTMLドキュメントに対して後処理を施したいということがあります。例えばbody要素の先頭にコンテンツを追加したり、imgやscriptをself containedにしたいといった場合です。

本稿ではrehypeにより生成済みのHTMLドキュメントを編集する方法を説明し、またこれを具体例としてモダンな言語処理エンジンがどうなっているか、ということを最後に説明します。

言語処理の一般的な形式

コンピュータがプログラミング言語やマークアップ言語を扱う際、一般に下図のような処理を行います。

image.png
  • Lexer: 単一文字列であるソースコードを分解してトークン列にする
  • Parser: トークン列を解釈して抽象構文木(AST)を生成する

このあとASTをどのように利用するかは処理系により異なります。C言語等ではコードジェネレータによってアセンブラコードを生成するし、いわゆるスクリプト言語等ではインタープリタがそのまま実行することもあります。

HTMLなどのマークアップ言語と呼ばれる言語のグループでは、アプリケーションが構文木を直接操作します。

hast

hastというHTMLの構文木の規格があります。NodeJS方面で広く利用されています。典型的なユースケースとしてMarkdownのレンダリングがあります。

image-1.png

この例ではremarkrehypeを組み合わせてMarkdown→mdast→hast→HTMLと変換していますが、本稿ではrehypeのみを使用し、HTML→hast→任意の操作→hast→HTMLとしてHTMLの後処理を行います。

image-2.png

例えば<body>hello, world!</body>rehype-parseすると以下の構造を得ます。普通のDOMパーサーです。

{
  type: 'element',
  tagName: 'body',
  properties: {},
  children: [ { type: 'text', value: 'hello, world!', position: [Object] } ],
  position: {
    start: { line: 1, column: 20, offset: 19 },
    end: { line: 1, column: 53, offset: 52 }
  }
}

rehype-parse, rehype-stringifyでHTMLとDOMの相互変換ができるので、DOMの状態で編集すると取り扱いが簡単です。

Node.jsによるHTMLポストプロセッサ

以下のサンプルコードでは、body要素の最初と最後にheader, footerを追加しています。

  • vfile-reporter エラー発生時のメッセージを成形する
  • unified 構文木を使うテキスト処理どうしを結合する
  • rehype-parse HTMLをパースしてhastにする
  • rehype-stringify hastをHTMLにシリアライズする
  • hast-util-select CSSセレクタでhastノードを検索する (document.querySelectorと同じ)
  • hastscript JSコードでhastオブジェクトを簡単に生成する
// index.js
import { VFile } from 'vfile';
import { reporter } from 'vfile-reporter';
import { unified } from 'unified';
import rehypeParse from 'rehype-parse';
import rehypeStringify from 'rehype-stringify';
import {select} from 'hast-util-select';
import {h} from 'hastscript';

function modifyHast(options) {
  return async function(node, vfile, done) {
    const body = select('body', node);
    body.children.splice(0, 0, h('header', ['header text']));
    body.children.push(h('footer', ['footer text']));
    done();
  };
}

unified()
    // 言語処理パイプラインの構築
    .use(rehypeParse)
    .use(modifyHast)
    .use(rehypeStringify)

    // パイプラインにデータ入力
    .process(new VFile({
      path: 'test.html',
      value: '<html><head></head><body>hello, world!</body></html>',
    }))

    // 処理完了コールバック
    .then(file => {
      if (file.messages.length > 0) {
        console.error(reporter(file));
        process.exit(1);
        return;
      }
      // 結果出力 → '<html><head></head><body><header>header text</header>hello, world!<footer>footer text</footer></body></html>'
      console.log(file.value);
    });

hastの編集はhast-util-selecthastscriptでおおむね何とかなります。

HTML特有でない、unist全般に対するユーティリティとして以下のようなライブラリがあります。hastunist (UNIversal Syntax Tree)の拡張という扱いで、hastunist規格を満たしているので、unist-util-*hastに適用可能です。

まとめ

  • 本稿で紹介したライブラリはunifiedjsの関連プロジェクトです
  • 多くの専用実装ではパーサーとシリアライザがまとめて提供されていますが、unifiedjsでは構文木の仕様・パーサー・シリアライザが別々に定義・実装されています
  • 構文木の仕様が独立に定義され、パーサー・シリアライザが分離されていることで、言語の変換が容易に実現できるようになっています (例えばMarkdown → HTML)
  • パーサー, シリアライザ, フィルター等のインターフェースが共通化されており、これらを結合するためのunifiedライブラリによってパイプライン的に操作できるようになっています
  • すべての処理が非同期に作られていてIOフレンドリーです
  • unifiedが処理を隠蔽しすぎること、async, Promiseの難しさ、また普通に学習を試みると手段系の文献に行き当たりUniversal Syntax Treeの思想になかなか到達できないこと、などが相まって学習コストがかなり高い部類の技術ですが、そのぶん極めて強力です