2020/04/13

Blogger コンテンツに自動的に目次を付ける

Blogger 記事に自動的に目次を付けるガジェットを作ってみました。
様々な目次自動生成機構を見てみたのですが、複数記事のあるページに目次を付けたかったので、主にこちらを参考にして作ってみました。
汎用性がちゃんとあるのかどうか分かりませんが、公開したいと思います。

特徴

  • テーマに手を付けず、レイアウトでガジェットを追加するだけで動く
  • ガジェットになっているので、ブログエリアの上に入れることもできるし、サイドバーなどに置くこともできる
  • 記事のページだけでなく、ホームページやラベルページ、アーカイブページ、検索ページといった、複数記事が並ぶページにも目次が表示される
  • 複数記事のあるページでの目次は、日付と記事タイトルも目次に含まれる
  • 単独記事のページでは、記事内の見出しだけの目次になる

ソースコード

まず、ソースコードを貼っておきます。これを Blogger のレイアウト設定で HTML/JavaScript ガジェットを配置して、そこにペタっと貼りつけたら動くと思います。

<script>
document.write('<div id="toc" class="toc-container"></div>');
(function() {
  document.addEventListener("DOMContentLoaded", function() {
    var blogWidget = document.querySelector(".widget.Blog");
    if (blogWidget == null || typeof blogWidget === "undefined") {
      return;
    }
    var rootItem = scanBlog(blogWidget);
    if (rootItem == null || rootItem.children == null || rootItem.children.length == 0) {
      return;
    }
    if (rootItem.children.length == 1) {
      rootItem = rootItem.children[0];
      if (rootItem.children != null && rootItem.children.length == 1) {
        rootItem = rootItem.children[0];
      }
      if (rootItem.children == null || rootItem.children.length == 0) {
        return;
      }
    }
    var elemRootList = document.createElement("ul");
    rootItem.children.forEach(function(item) {
      addToCItem(elemRootList, item);
    });
    var toc = document.getElementById("toc");
    toc.insertAdjacentElement('afterbegin', elemRootList);
    toc.style.display = 'table';
  });
  function scanBlog(blogWidget) {
    var targetTag = [ "h2", "h3", "h2", "h3", "h4" ];
    var targetClass = [ "date-header", "post-title entry-title", "", "", "" ];
    var tocItemIndex = 0;
    var scanHeading = function(level, heading, parentItem) {
      var targetTagName = targetTag[level];
      var targetClassName = targetClass[level];
      var nextLevel = level + 1;
      var newId = "toc_headline_" + (++tocItemIndex);
      var newItem = {
        text : heading.innerText,
        children : [],
        level : nextLevel,
        id : newId
      };
      parentItem.children.push(newItem);
      heading.id = newId;
      var nextLevelTagName = nextLevel < targetTag.length ? targetTag[nextLevel] : "";
      if (nextLevelTagName == "") {
        return;
      }
      var nextLevelClassName = nextLevel < targetClass.length ? targetClass[nextLevel] : "";
      for (var h = heading.nextElementSibling; h != null && typeof h !== "undefined"; h = h.nextElementSibling) {
        if (h.tagName.toLowerCase() == targetTagName && h.className == targetClassName) {
          break;
        }
        if (h.tagName.toLowerCase() == nextLevelTagName && h.className == nextLevelClassName) {
          scanHeading(nextLevel, h, newItem);
        } else {
          var headings = h.getElementsByTagName(nextLevelTagName);
          for (var i = 0; i < headings.length; i++) {
            if (headings[i].className == nextLevelClassName) {
              scanHeading(nextLevel, headings[i], newItem);
            }
          }
        }
      }
    };
    var rootItem = {
      text : "",
      children : [],
      level : 0,
      id : null
    };
    var headings = blogWidget.getElementsByTagName(targetTag[0]);
    for (var i = 0; i < headings.length; i++) {
      if (headings[i].className == targetClass[0]) {
        scanHeading(0, headings[i], rootItem, "");
      }
    }
    return rootItem;
  }
  function addToCItem(elemParentList, item) {
    var elemLI = document.createElement("li");
    var elemA = document.createElement("a");
    elemA.href = "#" + item.id;
    elemA.classList.add("toc-text");
    elemA.classList.add("toc-level-" + item.level);
    elemA.innerText = item.text;
    elemLI.appendChild(elemA);
    elemParentList.appendChild(elemLI);
    if (item.children.length > 0) {
      var elemSubList = document.createElement("ul");
      elemLI.appendChild(elemSubList);
      item.children.forEach(function(child) {
        addToCItem(elemSubList, child);
      });
    }
  }
})();
</script>
<style type="text/css">
.toc-container {
  display: none;
  width: auto;
  background: #f9f9f9;
  border: 1px solid #aaa;
  padding: 10px;
  margin-top: 20px;
  margin-bottom: 20px;
  font-size: 95%;
}
.toc-container ul {
  list-style-type: none;
  list-style: none;
  margin: 0;
  padding: 0;
}
.toc-container>ul {
  margin: 0 0 0;
}
.toc-container ul li {
  margin: 0;
  padding: 0 0 0 20px;
  list-style: none;
}
.toc-container ul li:after, .toc-container ul li:before {
  background: 0;
  border-radius: 0;
  content: "";
}
.toc-container ul li a {
  padding: 0;
  text-decoration: none;
  color: #008db7!important;
  font-weight: normal;
}
.toc-container ul li .toc-text:hover {
  text-decoration: underline;
}
.toc-container ul li .toc-level-1 {
  color: #666666!important;
}
.toc-container ul li .toc-level-2 {
  font-weight: bold;
}
</style>

挙動の解説

全体の動きとしては、2行目でガジェットの位置に目次用のコンテナを作り、その後 DOMContentLoaded のタイミングでコンテンツをスキャンし、目次を出力しています。
目次の表示スタイルは101行目以降の CSS で決めています。
目次情報は、31, 32行目に指定しているタグとクラスを探索することで収集しています。クラスを見ているのは、h2 が日付と記事内の見出し、h3 が記事タイトルと記事内の小見出しという形で複数の目的で使用されているのを識別するためです。
13〜21行目の処理は、単独記事のページの場合に、日付と記事タイトルを省略するための処理です。
目次の深さに対応した toc-level-n クラスを設定しているので、深さに応じたスタイルが指定できます。140〜142行目では、日付をグレーにしています。143〜145行目では、記事タイトルをボールドにしています。