自作ブログ
フロントエンド
React
Typescript
今使っているコードブロックが色々と見にくい。 コントラストが低くテキストを読みにくいし、よくあるコピー機能が無くてコピーしにくい。 色々とライブラリを漁ってみたがしっくりくるものが無かったので自作することにした。
自分が考えるいい感じのコードブロックとは以下のようなものだ。
これらを踏まえて調査した結果 + 簡単な実験の結果以下のような方針で実装した。 以前はコードハイライターとしてrehype-highlightを使っていたが、shikiの見た目がいい感じだったので乗り換えた。 rehype-pretty-codeも多機能だったが動きの付け方がわからず不採用とした。
他にも以下のような案があってそれぞれ試してみたのだが辛さが勝って今の方針に落ち着いた。
というわけでrehypeのプラグインを作成していく。 rehypeのプラグインは作ったことが無かったので、ドキュメントなどを読んでキャッチアップしつつ既存のライブラリなどを参考に作成した。 hastと呼ばれるHTMLの抽象構文木が渡されるので、書き換えたいNodeを探し当てて書き換えるだけ。 本質的な処理は多くなく、コードの大部分を訪問したノードが意図した構造を持っているのか検証するバリデーションが占めている。
やってみるとわかるが結構型が辛い。引数の型が複雑なだけではなく、rehypeという枠組み自体プラグインの集合体みたいなものなので、Nodeの型がライブラリによって微妙に異なっていたりする。 rehypeプラグイン初心者なので何かしらの不理解があるかもしれないが、厳密に型を合わせるのは諦めてts-ignoreを利用した。
import shiki from "shiki";
import rehypeParse from "rehype-parse";
import { Element, Text, Root } from "hast";
import { isArray, isString, isObject } from "lodash-es";
import { Plugin, unified } from "unified";
import { Node } from "unist";
import { visit } from "unist-util-visit";
import { SVGIconCopy } from "./icons";
const parser = unified().use(rehypeParse, { fragment: true });
interface pluginCodeBlockOption {
highlighter: shiki.Highlighter;
}
/**
* pre -> codeの構文を検出した場合, codeの中身をhighlightする
* このプラグインはhast形式のnodeが与えられることを前提にしている
* そのため事前にremark-rehypeを実行して, hast形式のnodeを生成しておく必要がある
*
* Note:
* * hastはunistを継承したHTMLの構文木
* * rehypeプラグインは型がかなり複雑だが自前で読み解くしかない
*
* Reference
* unist: https://github.com/syntax-tree/unist
* hast: https://github.com/syntax-tree/hast
* rehype-shiki: https://github.com/leafac/rehype-shiki/tree/main
* visitの実装: https://github.com/syntax-tree/unist-util-visit/blob/main/lib/index.js
*/
export const pluginCodeBlock: Plugin<pluginCodeBlockOption[]> = ({
highlighter,
}) => {
return (node: Node) => {
// typeがelementのNodeを再帰的に探索する
// 型を合わせるのは諦め(おそらく参照するunistのVersionで型が微妙に違う)
// @ts-ignore
visit(node, "element", (node: Element): void => {
try {
// preタグで小要素が長さ1の配列であることを確認する
if (
!(
node.tagName === "pre" &&
isArray(node.children) &&
node.children.length === 1
)
) {
return;
}
// 小要素がオブジェクト型でかつcode タグであることを確認する
const codeElement = node.children[0] as Element;
if (!(isObject(codeElement) && codeElement.tagName === "code")) {
return;
}
// クラス名が指定1つ以上存在してtypeがtextであることを確認する
const classNames = codeElement.properties?.className;
if (
!(
isArray(classNames) &&
classNames.length > 0 &&
classNames.every(isString)
)
) {
return;
}
// textNodeがdataにstringを持つことを確認する
const textNode = codeElement.children[0] as Text;
if (!isString(textNode.value)) {
return;
}
const rawCode = textNode.value.trim();
const langName = parseLanguage(classNames);
if (langName === undefined) {
return;
}
const highlightCode = highlighter.codeToHtml(rawCode, {
lang: langName,
});
const container = `
<div className="codeBlock">
<div className="codeBlockHeader">
<div className="codeBlockHeader-lang"> ${langName} </div>
<div className="codeBlockHeader-copy">
${SVGIconCopy}
</div>
<div className="codeBlockHeader-copied">
Copied!
</div>
</div>
<div className="codeBlockText">
${highlightCode}
</div>
</div>
`;
const parsedRoot = parser.parse(container) as Root;
node.tagName = "div";
node.children = parsedRoot.children as Element[];
} catch (e) {
console.error(e);
return;
}
});
};
};
function parseLanguage(classNames: any[]): string | undefined {
for (const className of classNames) {
if (isString(className) && className.startsWith("language-")) {
return className.replace("language-", "");
}
}
return;
}
あとは他のプラグインと同様に呼び出してあげるだけ。
このプラグインを実行すると、pre code
にマッチする構造がハイライトされたHTMLに置換される。
cssで適切にスタイリングすれば、最低限の動きを持たないコードブロックが動く。
後はjavascriptで動きを付けていく必要があるが、長くなるので後編で。
const highlighter = await shiki.getHighlighter({ theme: "material-theme" });
const parsed = await remark()
.use(remarkParse)
.use(remarkGfm)
.use(remarkMath)
.use(remarkRehype)
// @ts-ignore
.use(rehypeKatex)
// @ts-ignore
.use(pluginCodeBlock, {
highlighter,
})
.use(rehypeStringify)
.process(matterResult.content);
const contentHtml = parsed.toString();