Markdown の見出しをツリー罫線で描画する目次の実装
記事の目次をファイルツリー風の罫線付きで描画する仕組みを、wabisabi の実装をもとに解説します。
処理は3段階に分かれます。
Markdown 本文から見出しを抽出し、フラットな見出し配列からツリー罫線を生成し、スクロール位置に追従するアクティブ表示を組み合わせます。
見出しの抽出
Markdown 本文を1行ずつ走査して、##(レベル2)から ####(レベル4)までの見出しを拾い上げます。
対処すべき問題は二つあります。
コードブロック内の # を見出しとして誤検出しないこと、そして同じ見出しテキストが複数回出現したときにアンカー ID が衝突しないことです。
コードブロックの除外には、inFence というフラグを用います。
行頭が ``` にマッチするたびにフラグを反転させ、フラグが立っている間はすべての行を無視します。
const { items } = markdown.split("\n").reduce<{
inFence: boolean;
items: Array<{ level: 2 | 3 | 4; text: string }>;
}>(
(acc, line) => {
if (/^\s*```/.test(line)) {
return { inFence: !acc.inFence, items: acc.items };
}
if (acc.inFence) return acc;
const match = /^(#{2,4})\s+(.+?)\s*#*\s*$/.exec(line);
if (!match) return acc;
// ...
},
{ inFence: false, items: [] }
);
見出しテキストからはインラインの Markdown 記法(太字、リンク、インラインコードなど)を正規表現で除去してからスラグ化します。
ID の衝突回避には、出現順のカウンタを使います。
「設計」という見出しが3回現れたとき、ID はそれぞれ 設計、設計-1、設計-2 になります。
記事本文のレンダラ側でも同じ順序で同じカウンタを回しているため、目次のリンク先と本文の見出し要素の ID が一致します。
ツリー罫線の生成
抽出した見出しの配列はフラットな(入れ子構造を持たない)リストです。
このリストから、ファイルツリーのような罫線文字列を各見出しに対応させて生成します。
たとえば [h2, h3, h4, h2] という4つの見出しがあるとき、生成される罫線は次のようになります。
├ 見出し1(h2)
│ └ 見出し2(h3)
│ └ 見出し3(h4)
└ 見出し4(h2)
各見出しの罫線は、左端からその見出しの深さまでのカラムを順に決定することで組み立てます。
深さは 見出しレベル - 2 で求めます。
h2 は深さ 0(カラム1個分)、h3 は深さ 1(カラム2個分)、h4 は深さ 2(カラム3個分)です。
各カラムで選ぶ文字は、二つの条件の組み合わせで決まります。
- そのカラムが見出し自身の深さか(自分のカラムか、それとも祖先のカラムか)
- そのレベルに「次の兄弟」が存在するか
次の兄弟あり次の兄弟なし自分のカラム├ └ 祖先のカラム│
自分のカラムでは分岐記号(├ か └)を置き、祖先のカラムでは縦線を伸ばすか空白にするかを選びます。
「次の兄弟」の判定
「次の兄弟」とは、自分より後ろに存在する同レベルの見出しのうち、間にそのレベルより浅い見出しが挟まっていないものを指します。
より浅い見出しが間に入ると、そこでツリーの枝が切れるためです。
const hasNextAtThisLevel = headings
.slice(i + 1)
.some(
(h) =>
h.level === level &&
!headings
.slice(i + 1, headings.indexOf(h))
.some((between) => between.level < level)
);
この判定を各カラムについて行い、結果を連結すると一つの見出しの罫線文字列が完成します。
具体例で追ってみましょう。
[h2, h3, h3, h4, h2] という5つの見出しがあるとします。
3番目の見出し(h3、深さ 1)の罫線を求めます。
カラム 0(h2 レベル)では、後ろに h2 が存在するので │ になります。
カラム 1(h3 レベル、自分のカラム)では、次の h3 を探すと4番目は h4 であり、5番目は h2 で h3 より浅いレベルです。
間に浅い見出しが入る前に同レベルの h3 は見つからないので、次の兄弟はなく └ になります。
結果は │ └ です。
スクロール追従
目次の各リンクは、ユーザーの現在の読み位置に応じてアクティブ表示を切り替えます。
スクロールイベントが発生するたびに、すべての見出し要素の getBoundingClientRect().top を確認し、画面上端から 100px 以内にある見出しのうち最後のもの(最も下までスクロールしたもの)をアクティブとします。
まだどの見出しも通過していない場合は、最初の見出しをアクティブにします。
const compute = () => {
const passed = headingIds.filter((id) => {
const el = document.getElementById(id);
return el !== null && el.getBoundingClientRect().top <= ACTIVE_OFFSET;
});
setActiveId(passed.at(-1) ?? headingIds[0]);
};
イベントリスナーは document に capture: true で登録しています。
scroll イベントはバブリングしないため、window だけを監視するとページがコンテナ内でスクロールしたときに検知できません。
キャプチャフェーズで document に登録することで、どの要素がスクロールしても検知できます。
まとめ
ツリー罫線の生成は、フラットな見出し配列を入れ子構造に変換せずに実現できます。
各見出しの各カラムについて「次の兄弟が存在するか」を判定するだけで、罫線文字が一意に決まるためです。
入れ子構造への変換が不要な分、配列の走査だけで完結し、実装が簡潔になっています。