目次を自動生成するJavaScriptとHTML上の設置方法

主にブログ記事などでよく目にする「目次」ですが、手作業で作る場合、見出しなどに ID を設定して、その ID に対してリンクを設定するという作業を何度も行う必要があります。また、コンテンツを手直しした際には、作り直す必要もでてきます。

そこで今回は、目次を自動生成するJavaScriptコードとその使い方を紹介します。

目次を自動生成するJavaScriptコード

まずは、次のコードをコピーしてください。

( ( d ) => {
  const tocAll = d.querySelectorAll( 'details._toc[hidden]' );

  for ( const toc of tocAll ) {
    const tocTargetAll = toc.parentNode.querySelectorAll( '._toc~:is(h2,h3,h4,h5,h6),._toc~* :is(h2,h3,h4,h5,h6)' );

    if ( ! tocTargetAll.length ) {
      continue;
    }

    const tocList = d.createElement( 'ol' );

    for ( const [ index, tocTarget ] of tocTargetAll.entries() ) {
      let tocTargetId;

      if ( tocTarget.id ) {
        tocTargetId = tocTarget.id;
      } else {
        tocTargetId = `_${ index + 1 }_` + encodeURIComponent( tocTarget.textContent );
        tocTarget.id = tocTargetId;
      }

      const tocItem = document.createElement( 'li' );
      tocItem.className = tocTarget.tagName.toLowerCase();
      tocItem.innerHTML = `<a href="#${ tocTargetId }">${ tocTarget.textContent }</a>`;

      tocList.appendChild( tocItem );
    }

    toc.appendChild( tocList );
    toc.hidden = false;
  }
} ) ( document );

コピーしたら、body要素の終了タグ直前に作ったscript要素内にペーストするか、

…
<script>
/* ここにコードをペースト */
</script>
</body>
</html>

テキストエディタにペーストして任意の名称で保存し、script要素で読み込んでください。

…
<script src="任意の名称.js"></script>
</body>
</html>

head要素内で読み込む場合には、defer属性を忘れずに。

…
<script src="任意の名称.js" defer></script>
</head>
<body>
…

自動生成した目次を表示するためのHTMLコード

目次を表示したい箇所に、次のHTMLコードを埋め込みます。

<details class="_toc" hidden>
  <summary>目次</summary>
</details>

目次は最初、隠れた状態になっていて、「目次」と書かれた箇所をクリック・タップすることで表示されます。最初から表示させたい場合には、open属性を追加してください。

<details class="_toc" open hidden>
  <summary>目次</summary>
</details>

すると、目次の親要素を祖先要素に持つ見出しで、目次より後に記述された見出し (h2, h3, h4, h5, h6) から、目次内に表示されるリスト (ol, li, a) が生成されます。

リンクに使用される見出しの ID は、id属性がなければ、”_${見出しの番号}_${見出しをURIエンコーディングした文字列}” を見出しに設定して使用します。

<div>
  <h2>目次より前で、目次と異なる親要素を持つ見出し</h2><!-- 目次に含まれない -->
</div>
<div>
  <h2>目次より前で、目次と同じ親要素を持つ見出し</h2><!-- 目次に含まれない -->
  <div>
    <h3>目次より前で、目次の親要素を祖先要素に持つ見出し</h3><!-- 目次に含まれない -->
  </div>
  <details class="_toc" hidden>
    <summary>目次</summary>

    <!-- 自動生成されたリスト -->
    <ol>
      <li class="h2"><a href="#手動で設定されたID">目次より後で、目次と同じ親要素を持つ見出し</a></li>
      <li class="h3"><a href="#_2_目次より後で、目次の親要素を祖先要素に持つ見出し">目次より後で、目次の親要素を祖先要素に持つ見出し</a></li>
    </ol>
    <!-- / 自動生成されたリスト -->

  </details>
  <h2 id="手動で設定されたID">目次より後で、目次と同じ親要素を持つ見出し</h2><!-- 目次に含まれる -->
  <div>
    <h3 id="_2_目次より後で、目次の親要素を祖先要素に持つ見出し">目次より後で、目次の親要素を祖先要素に持つ見出し</h3><!-- 目次に含まれる -->
  </div>
</div>
<div>
  <h2>目次より後で、目次と異なる親要素を持つ見出し</h2><!-- 目次に含まれない -->
</div>

目次はWebページ内に複数追加できる仕様になっているので、セクション毎に目次を用意するといったこともできます。

<section>
  <h2>セクション1</h2>
  <details class="_toc" hidden>
    <summary>セクション1の目次</summary>
  </details>
  …
</section>
<section>
  <h2>セクション2</h2>
  <details class="_toc" hidden>
    <summary>セクション2の目次</summary>
  </details>
  …
</section>

ちなみに、JavaScript が何らかの理由で実行されない場合や、目次の後に見出しがない場合には、hidden属性によって表示されることはありません。

目次をスタイリングするCSSコードの一例

最後に、お好みで目次をスタイリングすれば完成です。次のCSSコードのように、grid と subgrid を使用すると、目次の後の見出しがどのレベルで始まっていても、問題なく表示されます。

details._toc {
  & {
    border: solid 1px color-mix(in srgb,transparent,currentcolor 20%);
    border-radius: 4px;
    padding: 1rem;
    position: relative;
  }
  & > * {
    isolation: isolate;
  }
  & > summary {
    & {
      cursor: pointer;
    }
    &::before {
      content: "";
      position: absolute;
        inset: 0;
    }
  }
  & > ol {
    & {
      counter-reset: toc-h2 toc-h3 toc-h4 toc-h5 toc-h6;
      display: grid;
        grid: auto-flow / repeat(5,auto) 1fr;
    }
    & > li {
      & {
        display: grid;
          grid: auto-flow / subgrid;
          grid-column: 1 / -1;
        margin: 0;
      }
      &::before {
        text-align: end;
        white-space: pre-wrap;
      }
      & > a:not(:hover,:focus-visible) {
        text-decoration: none;
      }
      &.h2 {
        & {
          counter-increment: toc-h2;
          counter-reset: toc-h3;
        }
        &::before {
          content: counter(toc-h2) ". ";
          grid-column: 1 / span 1;
        }
        & > a {
          grid-column: 2 / span 5;
        }
      }
      &.h3 {
        & {
          counter-increment: toc-h3;
          counter-reset: toc-h4;
        }
        &::before {
          content: counter(toc-h3) ". ";
          grid-column: 2 / span 1;
        }
        & > a {
          grid-column: 3 / span 4;
        }
      }
      &.h4 {
        & {
          counter-increment: toc-h4;
          counter-reset: toc-h5;
        }
        &::before {
          content: counter(toc-h4) ". ";
          grid-column: 3 / span 1;
        }
        & > a {
          grid-column: 4 / span 3;
        }
      }
      &.h5 {
        & {
          counter-increment: toc-h5;
          counter-reset: toc-h6;
        }
        &::before {
          content: counter(toc-h5) ". ";
          grid-column: 4 / span 1;
        }
        & > a {
          grid-column: 5 / span 2;
        }
      }
      &.h6 {
        & {
          counter-increment: toc-h6;
        }
        &::before {
          content: counter(toc-h6) ". ";
          grid-column: 5 / span 1;
        }
        & > a {
          grid-column: 6 / span 1;
        }
      }
    }
  }
}

CodePen

See the Pen A simple table of contents using JavaScript by nov (@numerofive) on CodePen.

本来であれば、ol要素を入れ子にして見出しレベルを表現したいところですが、HTML が全く分からないWebサイト運営者さんは、割とデザインで見出しを選択しがちで、イレギュラーな見出し設定にうまく対処しようとすると JavaScript のコードがものすごく増えちゃうんですよね。

ということで、今回はol要素を入れ子にせず、見出しが登場した順のリストとしました。

関連記事