rehypeを使ってHTMLを書き換える
背景
例えばブログサービスで投稿者には 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; }