Heading 要素を section でラップする rehype プラグインを作成しました

アウトラインアルゴリズムが変更され明示的なアウトラインの概念がなくなったため、Heading 要素を section でラップする機会も少なくなったかもしれません。私は Scrollspy などの実装が必要で作成しました。
2022.12.17

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

Heading 要素を section でラップする rehype プラグインを作成してみました。

なぜ作ったのか

Markdown の変換では基本的に Heading 要素(h1-h6)がフラットな構造で出力されます。

# タイトル 1
文章 1
## タイトル 2
文章 2
### タイトル 3
文章 3

このような Markdown は次のような変換がされると想定されます(何を使うかで変わるため参考として)。

<h1>タイトル 1</h1>
<p>文章 1</p>
<h2>タイトル 2</h2>
<p>文章 2</p>
<h3>タイトル 3</h3>
<p>文章 3</p>

このプラグインは、こういった HTML を次のような出力に変換するものです。

<section>
  <h1>タイトル 1</h1>
  <p>文章 1</p>
  <section>
    <h2>タイトル 2</h2>
    <p>文章 2</p>
    <section>
      <h3>タイトル 3</h3>
      <p>文章 3</p>
    </section>
  </section>
</section>

過去の HTML の仕様における言葉を用いると、暗黙的セクション(implied section)を明示的セクション(explicit section)に変更するということになります。

アウトラインの仕様も変わったので、markdown から html への変換過程での Heading の正規化を remark-normalize-headings でするだけでも十分かもしれません。

ですが私は Scrollspy を作りたかったため、その Heading の region となるラッパーが必要でした。Observe の対象を Heading にしてしまえばそれっぽくなったのですが、ページ内から Heading が消えてしまうと何も表示していない挙動になってしまうので……。

実はすでに rehype-section という素晴らしいプラグインが存在しているのですが、しばらくメンテナンスがされていなかったことと、Heading 要素のもつ id を section に持たせることができなかったため新しく作ることにしました。

パッケージを作るための雛形

すべて同じツールを使ったわけではないのですが、多くを次の Zenn の本を参考にしました。

使用した異なるツールは次になります。

Vitest

Node.js の Test runner を使ってみようかなと試してみたのですが、TAP の format にどうも馴染めなかったため、初期設定の簡単な Vitest を使用しました。

環境構築がしたいわけではないので、入れるだけで大体うまく動いてくれるというのはとてもありがたいです。

Rome

ESLint と Prettier ではなく Rome を使ったのは npm の package 数に依存を増やしすぎるとメンテナンスコストが高くなりそうかなといった軽い気持ちと、使ってみたい興味の半々でした。

こちらも導入にほぼ手間はかからないのですが、多くの人はエディタの設定が必要かもしれません。私の場合は VSCode を使用しているため、プロジェクトに設定ファイルを含めておきました。

// .vscode/extensions.json
{
  "recommendations": ["rome.rome"]
}
// .vscode/settings.json
{
  "": {
    "editor.defaultFormatter": "rome.rome"
  }
}

また、基本的にこういったツールは初期設定のままで使用したいと考えているのですが、どうしてもデフォルトのインデントが "tab" であることに違和感があったため、その設定だけ変更しました。

{
  "$schema": "./node_modules/rome/configuration_schema.json",
  "linter": {
    "enabled": true,
    "rules": {
      "recommended": true
    }
  },
  "formatter": {
    "enabled": true,
    "indentStyle": "space"
  }
}

import の sort 順変更がないのは少し残念でしたが、ないとダメというわけでもないので特に問題を感じませんでした。

運用案件で使えそうかどうか判断できるものを作ったわけではないため、ここの問題を感じないというのは今回に限ってはという意味になります。

CI/CD

Github Actions を使って CI/CD の環境を整えました。やったことは次です。

  • main ブランチの pushpull_request でテストや Lint などを実行する
  • release の作成で npm にリリースする

これらは Github Docs にまとめられた記事があるため、そちらのリンクを掲載しておきます。

少し気をつけたい点があって、public な npm package の場合にはコマンドを npm publish ではなく npm publish --access=public にしておく必要があります。私は忘れていたので 402 Payment Required が出て publish に失敗しました。

コミットしない dist を、成果物として含めたい場合

ビルド結果は dist に出力されるよう設定しているので、package.json に file の記述を追加しました。LICENSE や README などは記述しなくても追加されます。

// package.json
{
  // ...
  "files": ["dist"]
  // ...
}

ただし、ビルド結果としての dist をコミットしてしまうと、余計な差分がでてしまうためノイズになります。そのため dist を .gitignore に含めるのですが、そうしてしまうと今度は .gitignore に記述されている dist を含めずに publish してしまいます。

これを避けるために .npmignore に .gitignore に記述されている dist を除外したものをコピペして追加したのですが、設定を多重管理している感じがして少しモヤッとしています。

rehype プラグインを作る

rehype は HTML を ASTs(rehype ではhast) として扱うためのエコシステムになります。プラグインを作る場合には hast を希望する hast に変換する処理を書くことになります。

次は公式の README に記述されている、プラグインの雛形です。

import { visit } from "unist-util-visit";

/** @type {import('unified').Plugin<[], import('hast').Root>} */
function myRehypePluginToIncreaseHeadings() {
  return (tree) => {
    visit(tree, "element", (node) => {
      if (["h1", "h2", "h3", "h4", "h5"].includes(node.tagName)) {
        node.tagName = "h" + (Number(node.tagName.charAt(1)) + 1);
      }
    });
  };
}

hast を扱う使いやすいユーティリティライブラリは一通り揃っているため、hast の構造を頑張って書き換えるというよりもそれらをうまく使えると簡単にプラグインを作れるはずです。

今回の私のプラグインではhast-util-headinghast-util-heading-rank を使用しました。

こういったユーティリティは書ききれないほどたくさんあるので、もしやりたいことがある場合にはまず調べてみると良さそうです。

最後に

rehype のプラグインを作ってみると、これまでなんとなく使っていた remark と rehype が何をするものなのかや実際にはどういった ASTs を扱っているのかを深く理解でき、とても勉強になりました。

もし自分のほしいプラグインがなかったときには思い切って挑戦してみると楽しいのでオススメです!