Hugo で目次を自動生成し記事のサイド表示(Table of Contents: TOC)の紹介

はじめに

エンジニア向けのブログサイトZennのように記事のサイドに目次を表示する機能を実装できないか調べたところ、Hugoでも比較的簡単に実装可能なようなので試してみました。

✅ 難易度と実装の概要

項目 難易度 補足
目次の自動生成(Markdownの見出しから) ★☆☆(簡単) Hugoに内蔵されている { {.TableOfContents } } を使えば即可能
サイドバーや記事右側に固定表示(Zenn風) ★★☆(中級) PaperModなどのテーマにCSS追加+テンプレート改修が必要
スクロール連動(現在位置のハイライト) ★★★(応用) JavaScript(ScrollSpyなど)との連携が必要になる場合あり

🧩 Hugoでの基本的な目次機能(.TableOfContents)

HugoのMarkdown記事中の見出し(##など)から、以下のように自動で目次を生成する機能が備わっています。

{ { .TableOfContents } }

これは記事テンプレート(例:single.html)内に入れることで、目次をHTMLのリスト(ul/li)として出力してくれます。

🧭 Zenn風「サイド目次」をHugoでやるには?

必要な構成:

  1. { { .TableOfContents } } を記事テンプレートに埋め込む
  2. CSSで「右側に固定表示するようスタイリング」
  3. (可能であれば)表示・非表示を切り替えられるUIを付ける
  4. (応用)現在見ている見出しに自動でハイライト(ScrollSpy)

📝 難易度まとめ

やりたいこと 難易度 コメント
.TableOfContents で目次を記事内に表示 ★☆☆ すぐに実装可
Zenn風に右サイド固定表示 ★★☆ カスタムテンプレート+CSS調整が必要
スクロール連動・ハイライト ★★★ JavaScript ScrollSpyなどの導入が必要でやや高度

🧭 ステップ1:目次の自動生成(Markdownの見出しから)

Hugo には .TableOfContents という組み込み関数があり、Markdownの # や ## をもとに自動で目次を生成してくれます。

✅ 実装ステップ(PaperMod テーマ想定)

1. 表示させたい位置に以下を追加(例:layouts/_default/single.html)

{ { .TableOfContents } }

これは

タグの内部であれば、本文より前や後ろでも構いません。 最も簡単なのは本文直前:

<article class="post-content">
  { { .TableOfContents } }
  { { .Content } }
</article>

2. 表示確認(Hugoサーバー起動)

hugo server

ブラウザで http://localhost:1313/posts/任意の記事/ を開き、
## 見出しなどが複数ある場合、HTMLの ul 形式の目次が表示されます。

3. Markdown記事側の見出し例

# タイトル

## はじめに

## 準備するもの

### 拡張機能

### プレビュー表示

## まとめ

このように書かれていれば、階層構造付きの目次が .TableOfContents で自動生成されます。

✏ 補足(日本語でもOK?)

はい、日本語の見出しも問題なく認識され、目次リンクとして機能します。 見出しが ## 以上(つまり ### や ####)でも、きちんと階層になります。

🧭 次はステップ2:右側に目次を固定表示(Zenn風)

ここからはデザイン・CSS調整のフェーズに入ります。

AIからの提案ではCSSのみで再度に目次を表示する実装を進められたようですが、developertoolで記事ページのブロック構造を確認してわかった情報を図形で表してみました。

✅ articleの横に新しいブロック(目次用)を追加した方が理想的です

なぜなら?

  • .TableOfContents を 本文の中に入れると記事の一部として流れてしまう
  • Zenn風のサイド固定TOCを実現するには、レイアウトとして左右に分ける必要がある
  • 将来 position: sticky などの機能で自然なスクロールに連動する動きも可能になる

🛠 具体的な構成イメージ(理想構造)

HTML例

<main class="main page">
  <div class="content-wrapper" style="display: flex; align-items: flex-start;">

    <!-- 左:記事本文 -->
    <article class="post-single">
      {{ .Content }}
    </article>

    <!-- 右:目次 -->
    <aside class="article-toc">
      <nav class="toc">
        {{ .TableOfContents }}
      </nav>
    </aside>

  </div>

  <section id="comments">
    <!-- コメント欄 -->
  </section>
</main>

🎨 CSS例(Zenn風)

/* ■ 記事ページの目次ブロック:ここから */
.content-wrapper {
  display: flex;
  gap: 2rem;
  justify-content: space-between;
}

.post-single {
  flex: 1;
  max-width: 700px;
}

.article-toc {
  width: 260px;
  position: sticky;
  top: 100px;
  height: fit-content;
  max-height: 80vh;
  overflow-y: auto;
  font-size: 0.9rem;
  background: #f9f9f9;
  border: 1px solid #ccc;
  border-radius: 8px;
  padding: 1rem;
  box-shadow: 0 2px 8px rgba(0,0,0,0.05);
}

/* モバイルで非表示にする */
@media screen and (max-width: 768px) {
  .article-toc {
    display: none;
  }
}
/* ■ 記事ページの目次ブロック:ここまで*/

余談で、昨日導入したPrettierの影響で、CSSファイルの改行コードが自動で無くなり表示がおかしくなったので、特定の拡張子やファイルを無効にする設定し、その内容に関して記事を追加でまとめました。

完成:右側に目次を固定表示

ここまでで、サイドに目次が表示されるようになりました。

🎯 次のステップ:スクロール連動ハイライト(ScrollSpy)

ステップ 内容 難易度
1. 目次リンクと本文見出しの id の対応を確認 Hugoが自動で処理済み ★☆☆
2. スクロール位置を JavaScript で監視 IntersectionObserver API使用 ★★☆
3. 対象の目次リンクに .active クラスを付与 CSSでハイライト表示 ★★☆
4. CSSで li.active > a に装飾適用 やりたい見た目に調整 ★☆☆

✅ 仕組みだけ先にご紹介(概要)

// 1. すべての見出し要素を取得
const sections = document.querySelectorAll("article h2, article h3");

// 2. IntersectionObserverで監視
const observer = new IntersectionObserver(
  (entries) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        const id = entry.target.getAttribute("id");
        document.querySelectorAll(".toc a").forEach((link) => {
          link.classList.toggle("active", link.getAttribute("href") === `#${id}`);
        });
      }
    });
  },
  {
    rootMargin: "-30% 0px -60% 0px",
    threshold: 0.1,
  }
);

// 3. 監視対象を登録
sections.forEach((section) => observer.observe(section));

🔸 CSS例

.toc a.active {
  font-weight: bold;
  color: #007acc;
  border-left: 3px solid #007acc;
  padding-left: 0.5em;
  background-color: #eef6fb;
}

🧭 ScrollSpy(スクロール連動ハイライト)の実装ガイド

✅ 目的

ページをスクロールすると、現在表示中の見出しに対応する目次リンクがハイライトされる

🧩 ステップ1:JSファイルの用意

1. ファイルを作成

my-blog/static/js/scrollspy.js

というファイルを新規作成

2. 以下のコードを貼り付け

document.addEventListener("DOMContentLoaded", () => {
  const sections = document.querySelectorAll("article h2, article h3");
  const navLinks = document.querySelectorAll(".toc a");

  const observer = new IntersectionObserver(
    (entries) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) {
          const id = entry.target.getAttribute("id");
          navLinks.forEach((link) => {
            link.classList.toggle("active", link.getAttribute("href") === `#${id}`);
          });
        }
      });
    },
    {
      rootMargin: "-30% 0px -60% 0px", // 視野の上下オフセット
      threshold: 0.1,
    }
  );

  sections.forEach((section) => observer.observe(section));
});

🎨 ステップ2:CSSで .active をハイライト

すでにある custom.css.html などのスタイルに以下を追加:

.toc a.active {
  font-weight: bold;
  color: #007acc;
  border-left: 3px solid #007acc;
  padding-left: 0.5em;
  background-color: #eef6fb;
}

🔗 ステップ3:JSファイルをテンプレートに読み込む

layouts/partials/footer.html に以下を追記(またはすでにJSがある場合はその直後)

<script src="{{ "js/scrollspy.js" | relURL }}" defer></script>

🚀 ステップ4:Hugo再起動 & 動作確認

hugo server
  • 目次がすでに表示されているページでスクロール
  • 表示範囲内の見出しに応じて、目次リンクがハイライトされる
ScrollSpy(スクロール連動ハイライト)の実装ガイド

🧪 動作しないときのチェックポイント

現象 確認ポイント
目次リンクが .active に切り替わらない <a href="#見出しid"> が正しく生成されてるか(HTML確認)
JSが読み込まれていない 開発者ツールの Console にエラーが出ていないか?
何も起きない console.log() でデバッグしてイベント発火を確認

🔍 JavaScriptのざっくり解説(なるべくわかりやすく)

JSコードの中身を理解しておきたいとのことでしたので、ひとつずつ丁寧に分解してみます。

🔸1. DOM読み込みを待ってからスタート

document.addEventListener("DOMContentLoaded", () => {

→ ページのHTML構造が全部読み込まれた後に中身の処理を実行するよ、という合図。

🔸2. 監視対象の要素を取得

const sections = document.querySelectorAll("article h2, article h3");

→ 記事の中にある h2 や h3 をすべて取得。 これが「どの見出しが表示されているか」を監視する対象。

🔸3. 目次リンクの一覧も取得

const navLinks = document.querySelectorAll(".toc a");

→ .toc の中にある a タグ(目次のリンク)を全部拾っておく。

🔸4. IntersectionObserver(交差監視)

const observer = new IntersectionObserver((entries) => {
  entries.forEach((entry) => {
    if (entry.isIntersecting) {

→ ブラウザに「スクロールで要素が画面内に入ったら教えて!」と頼む仕組み。

🔸5. どの目次リンクをハイライトするか判定

const id = entry.target.getAttribute("id");
navLinks.forEach((link) => {
  link.classList.toggle("active", link.getAttribute("href") === `#${id}`);
});

→ 現在画面に映ってる見出しのIDと一致する目次リンクだけ .active を付ける。

🔸6. 最後に全部の見出しを監視開始

sections.forEach((section) => observer.observe(section));

💡 イメージとしては…

「画面に映った見出し」をトラッキングして、対応する目次に青い下線や背景色をつけてるだけです。