気まま研究所ブログ

ITとバイク、思ったことをてきとーに書きます。

jQuery 自動で目次メニューを生成する

f:id:AonaSuzutsuki:20200409110833p:plain

最近jQueryばっかじゃねえかと我ながら思うけど作っちゃったからしょうがない。
ってことで今回はH1, H2などの見出しから自動で目次メニューを生成するスクリプトのご紹介です。
静的ページ作ってると意外にだるいのが目次メニューの生成ですが、これを使えば一発で作り上げてくれます。
他に同様のスクリプト転がってるかもしれないけどなんか作りたくなったので作りました。

はじめに

f:id:AonaSuzutsuki:20200409112002p:plain
静的ページの記事を書いてる(こんなのとか)と内容にもよるけど目次が欲しくなります。
普通は考えるのめんどいから手書きでやるんだけど、これが結構重荷になってくるんですよね。
初めはいいけど、あとから項目作ったり、見出し変えたらその都度整合とらないといけないし、記事に不備があっても内容の変更が本気でめんどくさくなってくるんですよ。
そこで登場するのが自動で目次を作っちゃうスクリプトです。

このスクリプトでは見出しから自動で目次を生成するのはもちろん、グループ分け的な感じで記事内で別れてる項目を目次でも分けます。
上述した記事を見てもらえると分かる通り、「はじめに」とか「Ubuntu Serverでの構築」みたいに分けてるやつ。
繋げちゃうと見辛くなるから分けられると個人的にありがたい(自分で作ったけど)。
また、同一階層に置いた見出しだけでなく、divなどでネストした見出しもいい感じに目次化するようにしました。(仕様上どちらも一緒だからできただけ)

ちょっとだけ図出てるけど、途中からめんどくさくなって全くありません。
許してね。

サンプル

今回のサンプルはいつも通りサイトの方で公開しています。

AutoMenu Sample

H6の見出しとか気色悪いことなってますが、一応全部の見出しに対応しています。
また、邪魔臭い見出しにidを振るのも一応自動やってます。
個別に振ってある場合は振らないので降り忘れ対策ってところかな。

ダウンロードページも今回から公開します。
AutoMenu.zip

検証環境

項目 詳細
Google Chrome 80.0.3987.132, 64bit
Safari 13.0.5 (15608.5.11)
jQuery v3.4.1

これ以外の環境は検証はしてないので古いブラウザとか対応する予定なら細かいところのチェックが必要です。

Stack

まずは内部で使うスタックから。
Arrayオブジェクトのpopとpushをただただラップしただけなんですが、eachメソッドを追加で実装しています。

this.each = (func) => array.forEach((value, index) => func(value));

eachではただただ内部のArrayオブジェクトをforEachで各要素に対して引数でもらったコールバック関数により処理を行います。
これは後に出てくるナンバリングに使うんですが、popはしたくないので単にループさせるだけに留めます。
popしないため、要素数に変化はありません

以下Stackの全文

function Stack() {
    const array = [];

    const push = (item) => array.push(item);

    this.push = (item) => push(item);

    this.pop = () => array.length <= 0 ? null : array.pop();
    
    this.each = (func) => array.forEach((value, index) => func(value));
}

Queue

お次はQueueです。
こちらもStack同様にArrayオブジェクトのpushとshiftをラップしているだけですが、getメソッドを追加しています。

this.get = () => item = array.length < 0 ? null : array[0];

getメソッドではdequeueせずに要素を取得します。
dequeueはしないので要素数は変化しません

以下Queue全文

function Queue() {
    const array = [];

    const enqueue = (item) => array.push(item);

    this.enqueueRange = (items) => {
        for (let [_key, value] of Object.entries(items)) {
            enqueue(value);
        }
    }

    this.enqueue = (item) => enqueue(item);

    this.dequeue = () => array.length <= 0 ? null : array.shift();

    this.get = () => item = array.length < 0 ? null : array[0];
}

AutoMenu

さて、本題です。
こっからがブログ記事にしては強烈に長くなるのでわかりづらかったらごめんなさい。
頑張って項目分けして書くよ。

公開メソッド drawContentsMenuメソッド

まずはユーザが呼び出すことのできるdrawContentsMenuメソッドです。
これは引数でもらったIDの要素に対して生成したメニューを追加します
また、目次の前にcreateContentNameメソッドにより生成した「目次」って書いた見出しを追加します。
createContentNameメソッドは特に凝る気はなかったので必要に応じて書き換えてね。
なお、isShowNumberはスクリプトで番振りをするかどうかです。(後述)

で、肝心のメニュー生成はcreateContentsMenuメソッドへ。

this.drawContentsMenu = (exportId, isShowNumber = false) => {
    const container = createContentsMenu(isShowNumber);

    $(exportId).append(createContentName());
    $(exportId).append(container);
};

見出しへIdを自動で割り振る allocHeadIdメソッド

順番がちょっと前後しますが、IDが割り振られていない要素に対して要素を割り振ります
IDが割り振られていない場合は目次メニューから当該箇所へ飛べないので、なんでもいいので割り振る必要があります。
ただし、すでに割り振られている要素があったり、重複する可能性があるため連想配列により個数をチェックします。
すでに割り振られている物については無視し、重複しない物についてはそのまま見出しのテキストをidとします。
重複する物については個数を末尾につけて衝突を回避します。

これで全見出しにIDが割り振られました。

const allocHeadId = (headers) => {
    const map = {};

    // 自動割り振りの前に既存のidからマップを作る
    $("*").each((index, value) => {
        const id = $(value).attr("id");
        if (id !== void 0)
            map[id] = id in map ? map[id] + 1 : 0;
    });

    // マップを元にIDの生成と割り振りを行う
    headers.each((index, elem) => {
        if (elem.tagName in _hierarchyMap) {
            const element = $(elem);
            const id = element.attr("id");
            if (id === void 0) {
                const text = element.text();
                if (text in map) {
                    let index = map[text];
                    element.attr("id", `${text}_${++index}`);
                    map[text] = index;
                }
                else {
                    element.attr("id", text);
                    map[text] = 0;
                }
            }
            else {
                map[id] = id in map ? map[id] + 1 : 0;
            }
        }
    });
}

グループ別に配列を分ける separateGroupメソッド

グループについては触れてなかったんですが、公開しているregisterGroupメソッドでクラス名を登録するとそれをグループのセパレートとしてみなします
サンプルでは「span.title」をグループのセパレートとして登録しています。
なお、そこまで高度なチェックはしてないので見出しをグループセパレートとして使うことはできません。

ということで、まずはメニュー生成にも使う各見出しの階層マップを生成します。
グループ分けに階層はいらないのですが、タグ名で識別するので活用します。
本当ならクラス名からもチェックしたいのですが、いい案が浮かばずにそこまでやらんでもええやろという結論に。

const _hierarchyMap = { "H1": 0, "H2": 1, "H3": 2, "H4": 3, "H5": 4, "H6": 5 };

そうしたら引数で渡された見出しとグループの配列をループし、グループのタグが来るまで見出しをArrayオブジェクト(array)に打ち込み、グループがきたらグループ別のArrayオブジェクト(containerArray)にタグのarrayを打ち込みます
この時、どのグループか分かるようにグループ名とarrayの配列(タプルにしたかった)にします。
あとはarrayを作り直して同じ操作を繰り返します。
完成イメージはこんな感じ。

f:id:AonaSuzutsuki:20200409113827p:plain

なお、グループを登録していない場合はcontainerArrayが空なので[null, array]で返します。

これでグループ別に配列ができました。

const _hierarchyMap = { "H1": 0, "H2": 1, "H3": 2, "H4": 3, "H5": 4, "H6": 5 };

...

const separateGroup = (headers) => {
    const containerArray = [];
    let array = [];

    headers.each((index, header) => {
        if (header.tagName in _hierarchyMap) {
            array.push(header);
        }
        else {
            array = [];
            containerArray[containerArray.length] = [header, array];
        }
    });

    if (containerArray.length == 0)
        return [null, array];

    return containerArray;
}

メニュー生成の大枠 createContentsMenuメソッド

ここまで下準備が完了したところで早速本命のメニュー生成へ。
まずはjQueryの何ていうのかわからないけど、要素を取得するやつで見出しとグループを取得します

const headers = $(`${_className}, h1, h2, h3, h4, h5, h6`);

で、それらの要素たちをallocHeadIdメソッドseparateGroupメソッドに流してIDの割り振りとグループ分けを行います

allocHeadId(headers);
const groups = separateGroup(headers);

ここからが厄介なところで、平面に並んだ要素を立体化しないといけません。
そこでスタックとキューの出番です。

名前が微妙すぎて申し訳ないんですが、containersではStackオブジェクトを用いて、「<ol>」と各項目の番号をスタックします。
次にelementsではQueueオブジェクトを用いてグループ分けした配列をキューにします
そしてnumberStackをStackオブジェクトで空文字をスタックします
これらは後述しますが、スタックのLIFOの特性を利用することで階層構造を簡単に実現できます

で、スタックやキューを用意した後にappendMenuElementsメソッドに流すとリストが組み上がります。
それをここではdiv要素であるcontainerに追加することでメニューが完成します。

const createContentsMenu = (isShowNumber) => {
    const headers = $(`${_className}, h1, h2, h3, h4, h5, h6`);

    allocHeadId(headers);
    const groups = separateGroup(headers);

    const container = $("<div>");
    for (let [_key, value] of Object.entries(groups)) {
        const key = value[0];
        if (key != null) {
            let title = $("<div>").text($(key).text());
            container.append(title);
        }

        const ol = $("<ol>");
        const containers = new Stack();
        containers.push([ol, 1]);

        const elements = new Queue();
        elements.enqueueRange(value[1]);

        const numberStack = new Stack();
        numberStack.push("");

        appendMenuElements(elements, containers, numberStack, isShowNumber);
        container.append(ol);
    }


    return container;
};

番号を割り振る createParentStringメソッド

メニュー生成の前に、内部で使うメソッドを先に説明しておきます。

このメソッドはメニューでよくある

1 見出し
    1.2 サブ見出し

みたいに段で番振りを作る際に使用するメソッドです。

内部はスタックになっているのですが、popしてしまうと都合が悪いので予め作っておいたeachメソッドによりpopをしないループ処理をします。
そこで各スタックの要素をparentStringの末尾に追加していくことで番振りを実現します

const createParentString = (parentStringStack) => {
    let parentString = "";
    parentStringStack.each((item) => {
        parentString += item;
    });
    return parentString;
}

メニュー生成の実態 appendMenuElementsメソッド

よっしゃー、やっと最後の本題まできたぞ〜〜〜長かった〜〜。
ここからが最初で最後の難関ですが、ラストスパートお付き合いください。

まず、containerStackから追加すべきコンテナと番号を取得します。
次に、elementsから追加する要素を取得します。
そこから単純にli要素を作り、elementからidを取得、メニューに表示するテキストを生成し、リンクを作成します。
テキストの番号振りはcreateParentStringメソッドへparentStringStackを渡すことで生成できます。
なお、番振りに関しては引数のisShowNumberをfalseにすると振りません。(CSSの番振りになる)

elementsにキューを利用している理由としては、HTML的に上から(FIFO)順番に見出し要素が欲しかったからです。

a要素が生成できたらli要素に追加します。
それができたらcontainerStackにコンテナを戻します
なお、番号は一つ進んでいるので加算した物を戻します。

const item = containerStack.pop();
const container = item[0];
let number = item[1];
const element = elements.dequeue();

const parentString = createParentString(parentStringStack);

const list = $("<li>");
const id = $(element).attr("id");
const text = isShowNumber ? `${parentString}${number} ${$(element).text()}` : $(element).text();
list.append(createLink(`#${id}`, text));
container.append(list);
containerStack.push([container, number + 1]);

ここからが厄介なポイントで、全て見出しは一次元リストになっているのでなんとか木構造に変換しないといけません
そこでelementsのQueue#getメソッドにより次の要素を取得し、現在の要素と次の要素の階層を_hierarchyMapから取得します
ここでデキューしちゃうと次の処理で一個飛ばしてしまうため、デキューしないgetを実装しました。

const nextHierarchy = _hierarchyMap[next.tagName];
const currentHierarchy = _hierarchyMap[element.tagName];

それらの階層情報から、nextHierarchyがcurrentHierarchyよりも大きければネスト構造と判断し、等値なら同じ階層とします。
逆に小さければネスト構造から元に戻ったと判断できます。

ではまずはネストする場合から。
ネストする場合は新たにol要素のコンテナを作る必要があるため、それを生成し、containerStackにpushします。
さらにparentStringStackに現在の番号をpushします。
あとはol要素を現在のolコンテナに追加し、再びappendMenuElementsメソッドを呼び出します。

すると次の処理で、リストとリンクを追加する際のコンテナがここで生成したものになるわけですね。
なのでスタックを利用します。
また、parentStringもスタックを利用することで同様に親の番号を容易に知ることができます。(これに関してはキューでもいける)

const _container = $("<ol>");
containerStack.push([_container, 1]);
container.append(_container);
parentStringStack.push(`${number}.`);
appendMenuElements(elements, containerStack, parentStringStack, isShowNumber);

同一階層の場合はただただappendMenuElementsを呼び出すだけです。
上述した箇所でコンテナをpopした後にまた戻すのはここと次の処理で同様のコンテナが必要だからです。

appendMenuElements(elements, containerStack, parentStringStack, isShowNumber);

ネスト構造から元に戻る場合は、containerStackとparentStringStackを現在の階層と次の階層の差の分だけpopします。
すると該当する階層のコンテナまで戻るので、あとはappendMenuElementsメソッドを呼び出せば必要なコンテナまで戻った状態で処理が進みます。

const loop = currentHierarchy - nextHierarchy;
for (let i = 0; i < loop; i++) {
    containerStack.pop();
    parentStringStack.pop();
}
appendMenuElements(elements, containerStack, parentStringStack, isShowNumber);

以下全文

const _hierarchyMap = { "H1": 0, "H2": 1, "H3": 2, "H4": 3, "H5": 4, "H6": 5 };

...

const appendMenuElements = (elements, containerStack, parentStringStack, isShowNumber) => {
    const item = containerStack.pop();
    const container = item[0];
    let number = item[1];
    const element = elements.dequeue();

    const parentString = createParentString(parentStringStack);

    const list = $("<li>");
    const id = $(element).attr("id");
    const text = isShowNumber ? `${parentString}${number} ${$(element).text()}` : $(element).text();
    list.append(createLink(`#${id}`, text));
    container.append(list);
    containerStack.push([container, number + 1]);

    const next = elements.get();
    if (next != null) {
        const nextHierarchy = _hierarchyMap[next.tagName];
        const currentHierarchy = _hierarchyMap[element.tagName];
        if (nextHierarchy > currentHierarchy) {
            const _container = $("<ol>");
            containerStack.push([_container, 1]);
            container.append(_container);
            parentStringStack.push(`${number}.`);
            appendMenuElements(elements, containerStack, parentStringStack, isShowNumber);
        }
        else if (nextHierarchy === currentHierarchy) {
            appendMenuElements(elements, containerStack, parentStringStack, isShowNumber);
        }
        else {
            const loop = currentHierarchy - nextHierarchy;
            for (let i = 0; i < loop; i++) {
                containerStack.pop();
                parentStringStack.pop();
            }
            appendMenuElements(elements, containerStack, parentStringStack, isShowNumber);
        }
    }
}

最後にちょっと裏話をしておきます。
実は「はじめに」で紹介したページには構造的に別のものを使っていて、全く今回のスクリプトは導入していません。
じゃあなんで作ったのって話なんですが、備忘録的にブログに書こうと思ったものの、限定的すぎるので汎用化するために作ったのがこれです。
ただ当のスクリプトをちょっと手を加えたらできたってものでもなくて、全部新規で書き直したくらい大変でした。
ほんとに平面のものを立体化するのはほんとに苦労します。
丸一日手も動かさずに悩んだのはほんとに久しぶりですわ。
何はともあれ完成してよかった・・・。