【JavaScript】目次を自動生成させてみた

ガイドラインページの作成時、目次を静的にコーディングしていたのですが、見出しが増えれば増えるほど修正が大変…

* 本文内の見出し修正時に目次の修正を忘れる
* アンカー用 ID が連番だと見出し削除時に全ての ID を変更しなければならない
* ページが長くなれば長くなるほど html 上の見出しと目次の位置が離れる

というわけで、JavaScript で目次を自動生成させてみました！

## 仕様

### 目次の起点要素

`h1` はページタイトルで `h2` から見出しというパターンが多いかと思いますが、自由に設定できるように指定した要素以下が対象となるようにしました。

### hx 要素の親要素

`hx` 要素の親要素には目次に必要となる見出しが含まれるであろうセクショニングコンテツ `section` `article` が必須となります。

### セクショニングコンテツの親要素

`section` `article` の親要素となれるのは `div` のみとしました。
本当はなんでもOKにしたかったのですが、うまくいかず…

### アンカー用 ID の付与要素

`hx` 要素に付与するとデザインによっては見出し上のスペース設定ができず、アンカーリンクのクリック時に見出しとウィンドウ上部がピチピチになってしまうという経験がありました。

そこで、以下のような仕様としました。

デフォルトではセクショニングコンテツ `section` `article` とし、`isAddAnchorIdToHeading` というプロパティを `true` にすることで `hx` 要素に変更可能。

|                               | アンカー用 ID の付与要素 |
|-------------------------------|------------------------|
| デフォルト                     | section と article     |
| isAddAnchorIdToHeading = true | hx                     |

## JavaScript

```javascript:JS
class TableOfContents {
  constructor() {
    this.rootElement = document.getElementById('js-mainInner')
    this.toc = document.getElementById('js-toc')
    this.anchorIdPrefix = 'section'
    this.anchorIdSeparator = '-'
    this.isAddAnchorIdToHeading = false
    this.tocListItemHtml = ''
  }

  init() {
    this.createHtml(this.rootElement)
    this.insertHtml()
  }

  /**
   * 目次の HTML の生成
   * セクショニングコンテツ にアンカー用 id 付与
   * @param {Object} rootElem 起点となる要素
   * @param {String} anchorStr アンカー用 id の文字列
   */
  createHtml(rootElem, anchorStr) {
    const targetSelector = ':scope > section, :scope > article, :scope > div'
    const targetElements = rootElem.querySelectorAll(targetSelector)

    // 子要素に targetSelector がない場合は処理を抜ける
    if (targetElements.length === 0) return

    // 子要素の targetSelector をループ
    targetElements.forEach((tag, i) => {
      switch(tag.tagName) {
        // 子要素がセクショニングコンテツ
        case 'SECTION':
        case 'ARTICLE':
          const index = i + 1
          const anchorId =
            typeof anchorStr === 'undefined' ? this.anchorIdPrefix + index : anchorStr + this.anchorIdSeparator + index
          const childElementAll = tag.querySelectorAll('*')
          const childTargetElements = tag.querySelectorAll(targetSelector)
          const heading = (() => {
            for (const el of childElementAll) {
              if (/^H[1-6]$/.test(el.tagName)) return el
            }
          })()
          const headingText = heading.innerText
          const aTag = `<a href="#${anchorId}">${headingText}</a>`
          const addAnchorIdTarget = this.isAddAnchorIdToHeading ? heading : tag

          //  HTML を生成
          if (childTargetElements.length) {
            this.tocListItemHtml += `<li>${aTag}<ul>`
            this.createHtml(tag, anchorId)
            this.tocListItemHtml += '</ul></li>'
          } else {
            this.tocListItemHtml += `<li>${aTag}</li>`
          }

          // アンカー id 付与
          addAnchorIdTarget.setAttribute('id', anchorId)
          break

        // 子要素が div
        case 'DIV':
          this.createHtml(tag, anchorStr)
      }
    })
  }

  /**
   * 目次の HTML を挿入
   * @param {Object} target 目次の HTML を挿入したい要素
   */
  insertHtml() {
    this.toc.insertAdjacentHTML(
      'afterbegin',
      `
        <div class="toc_inner">
          <h2 class="toc_heading">目次</h2>
            <ul class="tocList">
              ${this.tocListItemHtml}
            </ul>
        </div>`
    )
  }
}
const tableOfContents = new TableOfContents()
tableOfContents.init()
```

## DEMO

<p class="codepen" data-height="500" data-default-tab="result" data-slug-hash="WNjQxdX" data-user="taqumo" style="height: 500px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;">
  <span>See the Pen <a href="https://codepen.io/taqumo/pen/WNjQxdX">
  Automatic Table of Contents</a> by Takuya Mori (<a href="https://codepen.io/taqumo">@taqumo</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>