ソース解説(2) 目次は本文から作る

執筆者:

カテゴリ:

記事の目次は、以前は手で書いていました。見出しを1つ足すたびに目次も直す。直し忘れると、目次に無い節ができます。

しかも壊れていても画面は普通に見えます。リンクが切れているか、節が抜けているかは、上から順に確かめないと分かりません。

いまは本文から機械的に作っています。今回はその処理を、実際のコードで読んでいきます。

この記事で扱うこと

本文が「文字列」として届くこと、そこから見出しを拾う方法、既にあるidを勝手に変えてはいけない理由、そして読んでいる位置をどう追いかけているかです。

本文は、部品の木ではなく1本の文字列で届く

前提から確認します。記事の本文はWordPressからHTMLの文字列として届きます。

画面に出す側から見ると、これは部品の集まりではありません。<h2>…</h2> という文字の並びが入った、長い1本の文字列です。

だから見出しを数えるにも、リンクを張るにも、文字列のまま扱うしかありません。ここが出発点です。

見出しを拾う

使っているのはこの1行です。

const HEADING_PATTERN = /<(h2|h3)([^>]*)>([\s\S]*?)<\/\1>/gi;

3つに分けて読みます。

部分 拾うもの
(h2|h3) 見出しの種類
([^>]*) タグに付いている属性
([\s\S]*?) 見出しの中身

最後の \1 が効いています。これは「1つめで拾ったのと同じ文字」という意味です。h2 で始まったなら </h2> で終わるものだけを拾います。ここを (h2|h3) と書いてしまうと、<h2> で始まって </h3> で終わる、ありえない組み合わせまで拾ってしまいます。

[\s\S]*? は「改行を含む、なんでも」。見出しが複数行に分かれていても拾えます。末尾の ? は「できるだけ短く」で、これが無いと最初の見出しから最後の見出しまでを丸ごと1つとして拾ってしまいます。

中身からタグを落とす

見出しの中に <code> や <strong> が入っていることがあります。目次には文字だけ欲しいので、タグを落とします。

このとき、実体参照も戻します。&amp; のままだと、目次に「&amp;」と見えてしまうからです。

順番 戻す対象 戻した結果
1 &nbsp; 空白
2 &lt; <
3 &gt; >
4 &quot; 二重引用符
5 &#39; 単引用符
6 &rarr; →
最後 &amp; &
順番を変えると壊れる

&amp; を先に戻すと、&amp;lt; がまず &lt; になり、次の行で < になります。

元の文字列は「&lt; という文字を表示したい」という意味でした。2回変換したせいで、本物のタグの記号に化けています。置き換えの順番だけで結果が変わる例です。

既にあるidは、絶対に変えない

見出しにはリンク先としてのidが要ります。無ければ振りますが、既にあるものは触りません。

const existingId = extractId(attributes);
const id = existingId ?? ("section-" + (++autoIdCount));

?? は「左が無いときだけ右を使う」。ここが分かれ目です。

idを振り直すと、外から張られたリンクが切れる

公開済みの記事の見出しに、誰かが #その見出し 付きのURLで言及しているかもしれません。

順番でidを振り直すと、記事の途中に節を1つ足しただけで、それ以降のidが全部ずれます。ページは開けるので404にもならず、ただ違う場所に着地します。壊れたことに気づく手段がありません。

そして、idを書き足すときは既にある場合と分けています。

if (existingId) {
  // 属性をそのまま使い、id は書き足さない
} else {
  // 属性のうしろに id を1つ書き足す
}

属性をそのまま持ち回しているので、既にidがある側で書き足すと id が2つ並びます。分けているのはそのためです。

見出しへのリンクを、その場で差し込む

同じ処理のついでに、見出しの末尾へリンクを足しています。

<a class="heading-anchor" href="#見出しのid"
   aria-label="この見出しへのリンク">
  <span aria-hidden="true">#</span>
</a>

「#」は見る人には意味が通じますが、読み上げると「シャープ」としか聞こえません。そこで記号は読み上げの対象から外し、説明を別に持たせています。

ブラウザ側で後から差し込むこともできますが、ここで一緒に組み立てれば、訪問者に送るJavaScriptが1行も増えません。

読んでいる位置を追いかける

ここからはブラウザ側です。いま画面に見えている見出しを光らせています。

スクロールのたびに全見出しの座標を測る方法もありますが、それは見出しが多い記事ほど重くなります。代わりに、ブラウザ側で交差判定をしてくれる仕組みを使っています。

rootMargin: "-80px 0px -70% 0px"

この1行が効いています。画面全体ではなく、上から80pxより下、かつ画面の上から30%までの帯だけを対象にしています。

なぜ画面全体ではないのか

画面全体を対象にすると、見出しが画面のいちばん下に入った瞬間から「いま読んでいる節」と判定されます。まだ目は前の節を読んでいるのに、目次の光る場所だけ先に進みます。

帯を上のほうに絞ると、判定の位置と目の位置が近くなります。実感に合わせるための調整が、この数字です。

今回の学び

まず、手で二重に持つものは、いずれずれるということ。目次と本文は同じ情報を2か所に持っていました。片方から作れば、ずれようがありません。今回の処理は、正確に言えば目次を作っているのではなくずれる可能性を消しているほうが本題でした。

次に、既に外へ出したものは、勝手に動かせないこと。idを振り直せばコードは簡単になりますが、外から張られたリンクが静かに壊れます。自分の都合で変えられるものと、変えられないものがあります。

そして、正しい判定と、実感に合う判定は別だということ。見出しが画面に入ったかどうかの判定は、どちらの帯でも「正しく」動きます。合うほうを選ぶには、数字ではなく自分で読んでみるしかありませんでした。

コメント

コメントを残す

メールアドレスが公開されることはありません。 ※ が付いている欄は必須項目です