この記事は公開されてから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;
}