rehypeを使ってHTMLを書き換える

2020.04.15

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

背景

例えばブログサービスで投稿者には WYSIWYG なエディタで HTML を自由に書いてもらい、 実際のブログ上では その HTML を WebAPI で受け取り、出力しているとします。

ただ、そのブログサービスはリニューアルを行ったので、 DB に保存された html のままでは綺麗に表示されません。

jQuery で各々変換する方法もありますが、今回は WebAPI (Node.js) の時点で html を強引に書き換えることで対応したいとします。

rehype とは

https://github.com/rehypejs/rehype

HTML プロセッサです。 https://unifiedjs.com/ の plugin の 1 つで https://unifiedjs.com/ の思想については HP をご覧いただければ幸いです。

markdown を react component へ変換する際に https://remark.js.org/ を 使われたことがある方は多いと思われますが、こちらも unifiedjs の plugin の 1 つです。

rehype を使って書き換えるメリット

下記をメリットとして採用しました。

  • 1 要素に対する操作が重複しにくくなる
  • pluggable な関数として定義するので、apply, purge が容易
  • virtual dom への変換が容易

html を書き換えるとは

今回は例として syntaxhighlighter から prismjs 用の code に変換することを目的とします。

syntaxhighlighter の記法は下記のようになっています。

<pre class="brush: markdown; gutter: true; first-line: 37">
# rehype を使って HTML を書き換える

## 背景

例えばブログサービスで投稿者には WYSIWYG なエディタで HTML を自由に書いてもらい、
実際のブログ上では その HTML を WebAPI で受け取り、出力しているとします。
</pre>

prismjs の記法は下記になります。

<pre class="code line-numbers" data-first=37>
  <code class="language-markdown">
# rehype を使って HTML を書き換える

## 背景

例えばブログサービスで投稿者には WYSIWYG なエディタで HTML を自由に書いてもらい、
実際のブログ上では その HTML を WebAPI で受け取り、出力しているとします。
  </code>
</pre>

やってみた

下記の環境で確認しております。

x is ? v1.0.0 via ⬢ v13.12.0
? # cat package.json
{
  "name": "x",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "opts-parser": "^4.0.0",
    "rehype-parse": "^6.0.2",
    "rehype-stringify": "^7.0.0",
    "unified": "^9.0.0"
  }
}

processor を定義します。

  • rehype-parse で HTML を HAST に変換して、
  • HAST をごにょごにょして
  • rehype-stringify を使って処理した HAST を HTML に戻します
const unified = require("unified");
const parse = require("rehype-parse");
const stringify = require("rehype-stringify");

const processor = unified()
  .use(parse, { fragment: true }) // HTMLをHASTに変換する
  .use(myRehypePlugin) // カスタムプラグインを適用
  .use(stringify); // HASTを HTML(String) に変換する

最終的に process で 処理したい html をうけとり、コールバックを定義すれば完了です。

const fs = require("fs");

const rawContent = fs.readFileSync(0, "utf-8");
processor.process(rawContent, (err, vfile) => {
  if (err) {
    console.error(err);
    return;
  }

  process.stdout.write(vfile.contents);
});

use に渡す関数は node, vfile, done を引数とする関数を返すようにします。

// node, vfile, done を受け取る関数を返す
function myRehypePlugin() {
  return function (node, vfile, done) {
    try {
      visit(convertCode, node, null, 0);
      done();
    } catch (err) {
      done(err);
    }
  };
}

// hastの要素を訪問する関数
function visit(visitor, node, parentNode, index) {
  if (visitor(node, parentNode, index)) {
    return;
  }

  if (!node.children) {
    return;
  }

  for (let i = 0; i < node.children.length; i++) {
    visit(visitor, node.children[i], node, i);
  }
}

実際にゴニョゴニョする様子です。

const brushOptionParser = require("opts-parser");

function convertCode(node) {
  // syntaxhighlighter のコードだけをいじるという強い意思
  if (
    node.type === "element" &&
    node.tagName === "pre" &&
    node.properties &&
    node.properties.className &&
    node.properties.className.indexOf("brush:") >= 0
  ) {
    return convertPrismFromSyntaxHighlighter(node);
  }
  return false;
}

function convertPrismFromSyntaxHighlighter(node) {
  const prismProps = {
    className: ["code"]
  };

  const attrs = brushOptionParser.parse(node.properties.className.join(""));
  if (attrs.gutter && attrs.gutter !== "false") {
    prismProps.className.push("line-numbers");
  }
  if (attrs.firstLine) {
    prismProps["data-start"] = attrs.firstLine;
  }
  if (attrs.highlight) {
    prismProps["data-line"] = attrs.highlight.join(",");
  }

  const prismCode = {
    type: "element",
    tagName: "pre",
    properties: prismProps,
    children: [
      {
        type: "element",
        tagName: "code",
        properties: {
          className: [`language-${attrs.brush}`]
        },
        children: node.children
      }
    ]
  };

  let filenameElem;
  if (attrs.title) {
    filenameElem = {
      type: "element",
      tagName: "p",
      properties: {
        className: ["file-name"]
      },
      children: [
        {
          type: "element",
          tagName: "code",
          children: [
            {
              type: "text",
              value: attrs.title
            }
          ]
        }
      ]
    };
  }

  node.tagName = "div";
  node.properties = {};
  if (filenameElem) {
    node.children = [filenameElem, prismCode];
  } else {
    node.children = [prismCode];
  }
  return true;
}