WordPressにプラグインなしで目次を自動生成する（style、スクロールアニメーション付き）

WordPressの投稿エリアにプラグインなしで実装できる目次機能をつくっていきたいと思います。

##今回作るものの要件

- h2、h3を読み取って自動的に目次を生成する
- h3は小見出しとして、h2にぶら下がるように表示する
- クリックしたらスクロールするアニメーションを付ける

##目次を自動生成するPHPスクリプト
WordPressでご使用中のテーマファイル内のfunctions.phpに以下をコピペします。
コードがごちゃごちゃするのが嫌なのでDOMDocumentを使ってHTMLを構築しています。

```php:functions.php
class PHPDOM
{
    public function __construct()
    {
        $this->doc = new DOMDocument('1.0', 'UTF-8');
    }

    public function setNode(string $tagName, ?string $className = null, ?string $text = null): object
    {
        $node = $this->doc->createElement($tagName, $text);
        if (!empty($className)) {
            $node->setAttribute("class", $className);
        }

        return $node;
    }

    public function a(string $href, ?string $className = null, ?string $text = null): object
    {
        $node = $this->doc->createElement("a", $text);
        if (!empty($className)) {
            $node->setAttribute("class", $className);
        }
        $node->setAttribute("href", $href);

        return $node;
    }

    public function img(string $src, ?string $className = null): object
    {
        $node = $this->doc->createElement("img");
        if (!empty($className)) {
            $node->setAttribute("class", $className);
        }
        $node->setAttribute("src", $src);

        return $node;
    }

    public function generator(object $obj):void
    {
        $this->doc->appendChild($obj);
        echo $this->doc->saveHTML();
    }
}

class TableOfContents extends PHPDOM
{
    public function __construct()
    {
        parent::__construct();
        add_filter('the_content', array($this, 'generate'), 11);
    }

    private function parseContent($content): void
    {
        $this->doc->loadHTML(mb_convert_encoding($content, 'HTML-ENTITIES', 'utf-8'));
        $xpath = new DOMXpath($this->doc);
        $h2s = $xpath->query('//h2');
        $obj = new ArrayObject();
        $i = 1;
        foreach ($h2s as $h2) {
            $query = "//h2[{$i}]/following-sibling::h3[following::h2[.='{$h2s->item($i)->nodeValue}']]";
            if ($h2s->length === $i) {
                $query = "//h2[{$i}]/following-sibling::h3";
            }
            $h3s = $xpath->query($query);
            $obj->append($h2);
            $h2->child = new ArrayObject();
            if ($h3s->length > 0) {
                foreach ($h3s as $h3) {
                    $h2->child->append($h3);
                }
            }
            $i++;
        }
        $this->hds = $obj;
    }

    private function addSpanTag()
    {
        $i = 1;
        foreach ($this->hds as $hd) {
            $newNode = $this->doc->createElement('span', $hd->nodeValue);
            $newNode->setAttribute('id', "toc_index{$i}");
            $hd->replaceChild($newNode, $hd->childNodes->item(0));
            if (!empty($hd->child)) {
                $o = 1;
                foreach ($hd->child as $child) {
                    $newChildNode = $this->doc->createElement('span', $child->nodeValue);
                    $newChildNode->setAttribute('id', "toc_index{$i}-{$o}");
                    $child->replaceChild($newChildNode, $child->childNodes->item(0));
                    $o++;
                }
            }
            $i++;
        }
    }

    private function createTable()
    {
        $root = 'toc';
        $wrap = $this->setNode('section', $root);
        $wrap->appendChild($this->setNode('p', "{$root}__title", '目次'));

        $ul = $this->setNode('ul', "{$root}__list");
        $i = 1;
        foreach ($this->hds as $hd) {
            $tagName = $hd->tagName;
            $li = $this->setNode('li', "{$root}__item");
            $li->appendChild($this->a("#toc_index{$i}", "{$root}__link", $hd->nodeValue));
            if (!empty($hd->child)) {
                $ul2 = $this->setNode('ul', "{$root}__list--child");
                $o = 1;
                foreach ($hd->child as $h3) {
                    $li2 = $this->setNode('li', "{$root}__item--child");
                    $li2->appendChild($this->a("#toc_index{$i}-{$o}", "{$root}__link--child", $h3->nodeValue));
                    $ul2->appendChild($li2);
                    $o++;
                }
                $li->appendChild($ul2);
            }
            $ul->appendChild($li);
            $i++;
        }

        $wrap->appendChild($ul);
        if (!empty($this->hds[1])) {
            $this->hds[0]->parentNode->insertBefore($wrap, $this->hds[0]);
        }
    }


    public function generate($content): string
    {
        if (!is_singular()) {
            return $content;
        }
        $this->parseContent($content);
        $this->addSpanTag();
        $this->createTable();

        return $this->doc->saveHTML();
    }
}
(new TableOfContents());

```

簡単に解説すると、Xpathを使ってWordPressのContent内を解析し、h2とそのh2の後に出てくるh3を読んでいます。
最後のh2だけ、次に続くh2がないためxPathのqueryを変更しています。

DOMDocumentクラスはどのみちXpathを利用するために必要になるため、クラス化してHTML生成にも利用している感じです。

もし特定のポストタイプで目次が必要ないという場合はgenerate内で条件分岐をしてください。（※`$content`を返さないと投稿ページが空になってしまいますので、`is_singular()`の後に追加するのが良いかと思います。）

##styleを当てる
次にスタイルを当てます。微調整が必要かも。ここらへんの色合いとかは適宜変更してご使用ください。

```scss:style.scss
.toc{
    margin-bottom: 1.5rem;
    padding: 1rem;
    background: rgba(44, 153, 181, .04);
    border: 3px solid rgba(64, 105, 144, .2);
    width: 100%;
    box-sizing: border-box;

    &__title {
        border-bottom: 1px solid rgba(0, 0, 0, .2);
        text-align: center;
        font-weight: 900;
        font-size: 1.1rem;
    }

    &__list {
        margin-left: 0;

        &--child {
            margin: .5rem 0 0 4rem;
        }
    }

    &__item {
        counter-increment: toc;
        margin-bottom: 1rem;
        list-style: none;

        &--child {
            font-size: .9rem;
        }

        &::before {
            content: counter(toc);
            background-color: rgba(125, 157, 188, .66);
            width: 1.5rem;
            height: 1.5rem;
            display: inline-flex;
            margin-right: .3rem;
            border-radius: 50%;
            color: #fff;
            align-items: center;
            justify-content: center;
            font-weight: 700;
            font-size: .9rem;
        }
    }

    &__link {
        color: #5f7b96;
        font-weight: 700;

        &--child {
            @extend .toc__link;
        }
    }
}
```

##スクロールアニメーションをつける
目次がクリックされたときに指定の見出しまでスクロールするアニメーションをJavaScriptでつけます。
ES6形式なので、IE11などへ対応する場合はwebpackなどを使ってES5にトランスパイルして使ってください。

```javascript:scroll.js
function smoothScroll(range) {
    let position = 0;
    let progress = 0;
    const easeOut = (p) => {
        return p * (4 - p);
    }
    const move = () => {
        progress++;
        position = range * easeOut(progress / 100);
        window.scrollTo(0, position);
        if (position < range) {
            requestAnimationFrame(move);
        }
    }

    requestAnimationFrame(move);
}

document.addEventListener("DOMContentLoaded",()=>{
	const ToC = Array.from(document.querySelectorAll('.toc__list a'));
	ToC.forEach(conts => {
		conts.addEventListener('click', event => {
			event.preventDefault();
			let loc = event.target.getAttribute('href');
			let span = document.getElementById(loc);
			let py = span.parentNode.getBoundingClientRect().top + window.pageYOffset;
			smoothScroll(py);
		})
	})
})


```

##所管
目次生成系には正規表現を使ったりするケースが多いと思いますが、今回はDOMXpathクラスを使って構築してみました。

さすがにh4まで対応するとqueryがごちゃごちゃしてしまうので、h3以降も目次に表示するような場合にはpreg系の関数を使ったほうが良いかもしれません。