2023-9-3

ブログのコードブロックをいい感じにする(前編)

自作ブログ

フロントエンド

React

Typescript

今使っているコードブロックが色々と見にくい。 コントラストが低くテキストを読みにくいし、よくあるコピー機能が無くてコピーしにくい。 色々とライブラリを漁ってみたがしっくりくるものが無かったので自作することにした。

Before

改修後

After

改修後

実装方針

自分が考えるいい感じのコードブロックとは以下のようなものだ。

  • ハイライトがしっかりしていてテキストが読みやすい。
  • 何の言語か明確に表記されている。
  • 行番号が表示されている。
  • コピペが簡単。
  • rehypeの枠組みの中で使える。
  • (可能であれば)Reactで実装できる。

これらを踏まえて調査した結果 + 簡単な実験の結果以下のような方針で実装した。 以前はコードハイライターとしてrehype-highlightを使っていたが、shikiの見た目がいい感じだったので乗り換えた。 rehype-pretty-codeも多機能だったが動きの付け方がわからず不採用とした。

  • codeのハイライトにはshikiを利用。
  • rehypeでプラグインを作成して、ハイライトするのと同時に付加的なタグをつける。
  • コピペなどの動作はReactコンポーネントからuseRefを使って頑張る。

他にも以下のような案があってそれぞれ試してみたのだが辛さが勝って今の方針に落ち着いた。

  • mdではなくmdxを使い自作のコンポーネントを記事内から利用する。→記事の中にコンポーネント入れ始めると依存関係が増えてあまり嬉しくない。記事ファイルはそれ単体で成立してほしい。
  • rehypeで頑張って記事をビルドするときに一部のタグをReactのコンポーネントに置き換える。→型、NextJSとのかみ合わせ周りで絶望して挫折。

Rehypeプラグインの作成

というわけでrehypeのプラグインを作成していく。 rehypeのプラグインは作ったことが無かったので、ドキュメントなどを読んでキャッチアップしつつ既存のライブラリなどを参考に作成した。 hastと呼ばれるHTMLの抽象構文木が渡されるので、書き換えたいNodeを探し当てて書き換えるだけ。 本質的な処理は多くなく、コードの大部分を訪問したノードが意図した構造を持っているのか検証するバリデーションが占めている。

やってみるとわかるが結構型が辛い。引数の型が複雑なだけではなく、rehypeという枠組み自体プラグインの集合体みたいなものなので、Nodeの型がライブラリによって微妙に異なっていたりする。 rehypeプラグイン初心者なので何かしらの不理解があるかもしれないが、厳密に型を合わせるのは諦めてts-ignoreを利用した。

typescript
Copied!
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で動きを付けていく必要があるが、長くなるので後編で。

typescript
Copied!
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();