【WordPress】投稿本文内の見出しを自動で目次に変換する方法(プラグインなし)

【WordPress】投稿本文内の見出しを自動で目次に変換する方法(プラグインなし) WordPress
Cocoonテーマなど、目次機能を持つテーマでは不要な場合があります
Cocoonテーマには、投稿本文のh2・h3見出しから自動で目次を生成する機能が標準で搭載されています(テーマ設定の「目次設定」でON/OFFや表示階層を調整可能)。既にCocoonの目次機能が有効なサイトでこの記事のコードを追加すると、Cocoon純正の目次に加えて独自の目次がもう1つ重複して表示されてしまいます。この記事は、目次機能を持たないテーマを使っている場合や、Cocoonの目次よりも細かく表示をカスタマイズしたい場合の実装例として参考にしてください。

SEOやユーザビリティの観点から、記事内に目次(Table of Contents)を設置するのは非常に有効です。多くのユーザーはプラグインを使って目次を生成していますが、「できるだけ軽量にしたい」「プラグインは使いたくない」という場合、PHPと正規表現を使って自前で目次を生成することが可能です。

この記事では、WordPressの投稿本文に含まれる見出し(<h2>や<h3>など)を自動で目次に変換する方法を紹介します。

スポンサーリンク

ステップ1:functions.php に目次生成関数を追加

functions.php:h2見出しから目次を自動生成
function generate_table_of_contents($content) {
  if (is_single()) {
    $pattern = '/<h2(.*?)>(.*?)<\/h2>/i';
    preg_match_all($pattern, $content, $matches, PREG_OFFSET_CAPTURE);

    if (!empty($matches[0])) {
      $headings = [];
      $replacements = []; // [オフセット, 元のタグの長さ, 新しいタグ]

      foreach ($matches[0] as $index => $match) {
        $slug = 'toc-' . $index;
        $attrs = $matches[1][$index][0];
        $heading_html = $matches[2][$index][0];
        $heading_text = strip_tags($heading_html);

        $headings[] = '<li><a href="#' . esc_attr($slug) . '">' . esc_html($heading_text) . '</a></li>';

        $original_tag = $match[0];
        $offset = $match[1];
        $new_tag = '<h2' . $attrs . ' id="' . esc_attr($slug) . '">' . $heading_html . '</h2>';
        $replacements[] = [$offset, strlen($original_tag), $new_tag];
      }

      // オフセットの大きい方(本文の後ろ側)から置換することで、
      // まだ処理していない前方の見出しのオフセットがズレない
      foreach (array_reverse($replacements) as $r) {
        list($offset, $length, $new_tag) = $r;
        $content = substr_replace($content, $new_tag, $offset, $length);
      }

      $toc = '<div class="toc-box">';
      $toc .= '<p class="toc-title">目次</p>';
      $toc .= '<ul class="toc-list">' . implode('', $headings) . '</ul>';
      $toc .= '</div>';

      $content = $toc . $content;
    }
  }

  return $content;
}
add_filter('the_content', 'generate_table_of_contents');
同じテキストの見出しが複数あるとリンクが壊れる落とし穴
旧来のよくある実装ではstr_replace($original, $replacement, $content)で見出しにidを追加しますが、これは一致する文字列をすべて置換してしまいます。同じテキストの見出し(例:複数セクションでそれぞれ「まとめ」という節がある)が2つ以上あると、最初のループで両方とも同じidに書き換わってしまい、2つ目以降の目次リンクがどこにもジャンプしなくなります。上記のコードはPREG_OFFSET_CAPTUREで取得した位置情報をもとに、文字列検索に頼らず安全に置換しています。

ステップ2:CSSで目次を装飾(任意)

style.css:目次ボックスの装飾
.toc-box {
  background: #f8f8f8;
  padding: 1em;
  border-left: 4px solid #0073aa;
  margin-bottom: 1.5em;
}
.toc-title {
  font-weight: bold;
  margin-bottom: 0.5em;
}
.toc-list {
  list-style: none;
  padding-left: 1em;
}
.toc-list li {
  margin: 0.3em 0;
}
.toc-list a {
  text-decoration: none;
  color: #0073aa;
}

補足:対象見出しを変更したい場合

h2 → h3 に変更するには、正規表現の対象を変えるだけです:

h3見出しを対象にする場合
$pattern = '/<h3(.*?)>(.*?)<\/h3>/i';

複数見出し階層に対応させたい場合は、h2 と h3 両方を抽出し、ネスト構造を組むロジックに改良する必要があります。

複数見出し階層に対応させる方法(h2・h3対応)

記事内の構成によっては、h2 を大見出し、h3 をその下の小見出しとして使っているケースもあるでしょう。そのような場合、目次も階層構造に対応させることで、より視認性の高い構成にすることが可能です。

以下のコードは、h2 と h3 を抽出し、入れ子構造の目次を作成します。

functions.php:h2・h3の階層構造に対応した目次生成
function generate_table_of_contents_with_hierarchy($content) {
  if (is_single()) {
    $pattern = '/<h([2-3])([^>]*)>(.*?)<\/h[2-3]>/i';
    preg_match_all($pattern, $content, $matches, PREG_OFFSET_CAPTURE);

    if (!empty($matches[0])) {
      $toc = '<div class="toc-box"><p class="toc-title">目次</p><ul class="toc-list">';
      $last_level = 2;
      $replacements = []; // [オフセット, 元のタグの長さ, 新しいタグ]

      foreach ($matches[0] as $index => $match) {
        $tag_level = intval($matches[1][$index][0]);
        $attrs = $matches[2][$index][0];
        $inner_html = $matches[3][$index][0];
        $heading_text = strip_tags($inner_html);
        $id = 'toc-' . $index;

        // 見出しへのid追加は後でまとめてオフセットの大きい方から適用する
        $original_tag = $match[0];
        $offset = $match[1];
        $new_tag = '<h' . $tag_level . $attrs . ' id="' . esc_attr($id) . '">' . $inner_html . '</h' . $tag_level . '>';
        $replacements[] = [$offset, strlen($original_tag), $new_tag];

        // 階層構造の制御(目次の組み立ては先頭から順番に行う必要がある)
        if ($tag_level > $last_level) {
          $toc .= '<ul>';
        } elseif ($tag_level < $last_level) {
          $toc .= '</ul>';
        }

        $toc .= '<li><a href="#' . esc_attr($id) . '">' . esc_html($heading_text) . '</a></li>';
        $last_level = $tag_level;
      }

      // 終了タグを補完
      $toc .= str_repeat('</ul>', max(0, $last_level - 2));
      $toc .= '</ul></div>';

      // 本文への書き込みは、オフセットの大きい方(本文の後ろ側)から行う。
      // 先頭から置換すると、id属性の追加で文字列が伸びるたびに後続の見出しの
      // 実際の位置がズレてHTMLが壊れてしまう
      foreach (array_reverse($replacements) as $r) {
        list($offset, $length, $new_tag) = $r;
        $content = substr_replace($content, $new_tag, $offset, $length);
      }

      // 目次を先頭に挿入
      $content = $toc . $content;
    }
  }

  return $content;
}
add_filter('the_content', 'generate_table_of_contents_with_hierarchy');
先頭から置換するとオフセットがズレてHTMLが壊れる落とし穴
PREG_OFFSET_CAPTUREで取得したオフセットは、あくまで元の(書き換え前の)本文を基準にした位置です。id属性を追加した新しいタグは元のタグより必ず長くなるため、本文の先頭側から順にsubstr_replace()していくと、後ろにある見出しの実際の位置は元のオフセットからズレていき、見出しが2つ以上ある記事ではほぼ確実にHTMLが壊れます。上記のコードは、目次の組み立て(先頭から順番に行う必要がある)と、本文への書き込み(オフセットの大きい方=本文の後ろ側から行う)を分離することで、この問題を回避しています。

よくある質問(FAQ)

Qプラグインなしで記事の見出しから目次を自動生成するには?
Athe_contentフィルターで本文を解析し、h2・h3タグにidを付与しつつ目次HTMLを生成して本文の先頭に挿入します。DOMDocumentクラスまたは正規表現で実装できますが、正規表現で実装する場合はPREG_OFFSET_CAPTUREでの位置情報をもとに置換し、同じ見出しテキストが複数あっても壊れないようにする必要があります。
Q目次にアンカーリンクを付ける際の注意点は?
A見出しのidには英数字と特殊文字(日本語はURLエンコードが必要)が使われます。mb_convert_encoding()とrawurlencode()で安全なidを生成します。同じ見出しテキストが複数ある場合の重複id対策も必要です。
Q目次の表示・非表示をユーザーが切り替えられるようにするには?
AJavaScriptでtoggle処理を実装し、ボタンクリックで目次ブロックのdisplayを切り替えます。LocalStorageに状態を保存すると次回訪問時も設定が維持されます。

まとめ

プラグインを使わずに、WordPress投稿本文から見出しを自動抽出して目次を生成する方法を紹介しました。同じテキストの見出しが複数あっても壊れないよう、文字列検索ではなく位置情報(オフセット)ベースで本文を書き換えることがポイントです。Cocoonなど目次機能を持つテーマを使っている場合は、まず標準機能で十分かどうかを確認したうえで導入を検討してください。