今や WordPress での投稿作業は、ブロックエディター(Gutenberg)が主流となっています。
つまり、記事を投稿するときは GUI ベースであらゆるブロックを組み合わせて投稿するため、投稿コンテンツすべてがブロックで構成されることになります。
WordPress のブロックをコードで確認すると、実際にはブロックとして解釈される HTML コメント(<!– wp:(slug) –>〜<!– /wp:(slug)–>)でコンテンツの HTML が括られています。
例えば、見出しブロックをコードで確認すると以下のようになっています。
<!-- wp:heading --> <h2>H2の見出しブロック</h2> <!-- /wp:heading -->
WordPress では parse_blocks という関数が提供されており、これを利用することでブロックで構成された本文やフルサイト編集(FSE)で利用されるテンプレートやパーツなどのソースコード(HTML のテキスト)から、ブロックを解析し、ブロック単位に分割することができます。
そこで、今回はこのparse_blocks 関数を利用して本文の見出しブロックを抽出し、目次用の HTML を自動で作成するプログラムのサンプルを作ってみます。
parse_blocks の基本
特定の投稿を対象とする場合は、投稿 ID から対象記事の本文を parse_blocks 関数に渡してブロックのデータ(配列)を抽出します。
// 投稿 ID が 1111 の場合 $post = get_post( 1111 ); // 本文からブロックデータを抽出 $blocks = parse_blocks( $post->post_content );
現在表示(クエリ)されている投稿を対象とする場合は、get_queried_object 関数を利用します。
// 現在の投稿データを取得 $post = get_queried_object(); // 本文からブロックデータを抽出 $blocks = parse_blocks( $post->post_content );
parse_blocks で抽出されたブロックデータは以下のようになっています。
Array
(
[0] => Array
(
[blockName] => core/heading
[attrs] => Array()
[innerBlocks] => Array()
[innerHTML] => <h2>見出しブロック(h2)</h2>
[innerContent] => Array(
[0] => <h2>見出しブロック(h2)</h2>
)
)
...
)
[blockName] にブロック名が入っているのがわかります。
つまり、ブロック名が core/heading のデータのみを抽出すれば、目次を作る基準になる見出しブロックの情報を集められます。
見出しブロックの抽出
the_content フィルターフックを利用して、投稿本文が画面に表示される直前に見出しブロックの抽出を行う処理を追加します。
以下のサンプルでは固定ページは対象外になっています。
固定ページも含める場合は、is_singular() を利用します。
function generate_my_toc( $content ){
// 投稿ページのメインループ内のときのみ
if ( is_single() && in_the_loop() && is_main_query() ) {
// 本文テキストをブロックごとに分割
$blocks = parse_blocks( $content );
// 分割したブロックを順番に処理
foreach ( $blocks as $key => $block ) {
// 見出しブロックの場合
if ( $block['blockName'] == 'core/heading' ) {
// ここに目次を作る処理
}
}
return $content;
}
add_filter( 'the_content', 'generate_my_toc', 1 );
上記のコードで、とりあえず本文から見出しブロックのみを検出するまでの流れができました。
見出しタグの解析
見出しブロックのデータから、さらに見出しタグを解析して目次用のデータを整形する必要があります。
見出しのブロックコンテンツは [innerHTML] に代入されているので、この値を解析します。
preg_match 関数を利用して、[innerHTML]から HTML タグ(<h1>〜<h6>、</h1>〜</h6>)と見出しタグ内のテキストに分解します。
if ( $block['blockName'] == 'core/heading' ) {
// 見出しタグを解析
if ( preg_match( "/(<h([1-6]).*?>)(.*?)(<\/h[1-6]>)/", $block['innerHTML'], $match ) ) {
// ここに目次を作る処理
}
}
/(<h([1-6]).*?>)(.*?)(<\/h[1-6]>)/ という正規表現により、$match 変数に「見出し開始タグ」、「見出しレベル(1〜6)」、「タグ内のテキスト」、「見出し終了タグ」の順番でデータがセットされます。
| 変数 | 代入される値 |
|---|---|
| $match[0] | 一致したテキスト |
| $match[1] | <h1>〜</h6> |
| $match[2] | 1〜6 ※見出しレベル判定用 |
| $match[3] | 見出しタグ内のテキスト |
| $match[4] | </h1>〜</h6> |
目次用リストの作成
今回のサンプルでは、目次は ul(順序なしリスト)または ol(順序ありリスト)で構成し、分解した見出しデータと見出しレベル(h1〜h6)に合わせてリストをインデント(字下げ)するためのデータを用意します。
インデントの有無、解除を判定するには、直前のリスト(見出し)の階層(レベル)と比較する必要があるため、現在の見出しレベルと直前の見出しレベルを保持するようにします。
// 現在と直前の見出しレベルの保持用(とりあえず h1 を基準)
$current_level = $previous_level = 1;
// カウンター、ID用
$index = 0;
// 本文テキストをブロックごとにパース
$blocks = parse_blocks( $content );
// パースしたブロックを順番に処理
foreach ( $blocks as $key => $block ) {
// 見出しブロックの場合
if ( $block['blockName'] == 'core/heading' ) {
// 見出しタグを解析
if ( preg_match( "/(<h([1-6]).*?>)(.*?)(<\/h[1-6]>)/", $block['innerHTML'], $match ) ) {
// 現在のレベルを取得
$current_level = (int)$match[2];
if ( $index == 0 || $current_level == $previous_level ) {
// 1番目または現在の見出しレベルと直前の見出しレベルが同じとき
} else if ( $current_level < $previous_level ) {
// 現在の見出しレベルが直前よりも高いとき
} else {
// 現在の見出しレベルが直前よりも低いとき
}
// 現在の見出しレベルを保持
$previous_level = (int)$match[2];
++$index;
}
}
}
目次のリストは、クリックすると対象の見出し位置までスクロールするよう、ハッシュタグのリンクにします。
'<a href="#i-' . $index . '">' . $match[3] . '</a>'
インデント/インデント解除の判定は、以下の3パターンを想定します。
| レベルの状態 | 判定結果 |
|---|---|
| 直前の見出しレベルと現在の見出しレベルが同じとき | インデントなし(同階層) |
| 現在の見出しレベルが直前の見出しレベルよりも高いとき (例 : 直前: h3、現在: h2) | インデント解除 |
| 現在の見出しレベルが直前の見出しレベルよりも低いとき (例 : 直前: h2、現在: h3) | インデント |
直前の見出しレベルと同じ階層のとき
現在の見出しレベルが直前の見出しレベルと同じときは、目次のリストタグを以下のようにします。
// 1番目以外は直前の見出しレベルに閉じタグ(</li>)を付ける
if ( $index > 0 ) {
$toc_code .= '</li>';
}
$toc_code .= '<li><a href="#i-' . $index . '">' . $match[3] . '</a>';
直前の見出しレベルより上の階層のとき
現在の見出しレベルが直前の見出しレベルより高いときは、目次のリストタグを以下のようにします。
$toc_code .= '</li>'. str_repeat( '</ol></li>', $previous_level - $current_level ) . '<li><a href="#i-' . $index . '">' . $match[3] . '</a>';
この場合は、直前の見出しレベルとの差だけリストを閉じる(インデントを解除する)必要があるため、str_repeat 関数で目次リストの前に </ol></li> を付けます。
直前の見出しレベルより下の階層のとき
現在の見出しレベルが直前の見出しレベルより低いときは、目次のリストタグを以下のようにします。
$toc_code .= '<ol><li><a href="#i-' . $index . '">' . $match[3] . '</a>';
この場合は、インデントを開始するため、目次リストの前に <ol> を付けます。
上記のコードをまとめると以下になります。
// 現在と直前の見出しレベルの保持用(とりあえず h1 を基準)
$current_level = $previous_level = 1;
// カウンター、ID用
$index = 0;
// 本文テキストをブロックごとにパース
$blocks = parse_blocks( $content );
// パースしたブロックを順番に処理
foreach ( $blocks as $key => $block ) {
// 見出しブロックの場合
if ( $block['blockName'] == 'core/heading' ) {
// 見出しタグを解析
if ( preg_match( "/(<h([1-6]).*?>)(.*?)(<\/h[1-6]>)/", $block['innerHTML'], $match ) ) {
// 現在のレベルを取得
$current_level = (int)$match[2];
if ( $index == 0 || $current_level == $previous_level ) {
// 1番目または現在の見出しレベルと直前の見出しレベルが同じとき
// 1番目以外は直前の見出しレベルに閉じタグ(</li>)を付ける
if ( $index > 0 ) {
$toc_code .= '</li>';
}
$toc_code .= '<li><a href="#i-' . $index . '">' . $match[3] . '</a>';
} else if ( $current_level < $previous_level ) {
// 現在の見出しレベルが直前よりも高いとき
$toc_code .= '</li>'. str_repeat( '</ol></li>', $previous_level - $current_level ) . '<li><a href="#i-' . $index . '">' . $match[3] . '</a>';
} else {
// 現在の見出しレベルが直前よりも低いとき
$toc_code .= '<ol><li><a href="#i-' . $index . '">' . $match[3] . '</a>';
}
// 現在の見出しレベルを保持
$previous_level = (int)$match[2];
++$index;
}
}
}
見出しに ID を付与(置換)
先述の通り、目次リストはハッシュタグのリンクにしているため、対象の見出しには同じハッシュタグの ID を付与しておく必要があります。
見出しタグには元々 ID 属性がある場合もあるため、見出しタグ内のテキストを ID 付きの span で括って置き換えます。preg_replace_callback 関数を利用して以下のように本文の見出しタグを入れ替えます。
$content = preg_replace_callback( "/(<h[1-6].*?>)(" . preg_quote( $match[3], '/' ) . ")(<\/h[1-6]>)/", function( $match2 ) use ( &$index ) {
// 見出しタグ内を span で括って ID を付けて入れ替える
return $match2[1] . '<span id="i-' . $index . '">' . $match2[2] . '</span>' . $match2[3];
}, $content );
目次リストの整形
すべての見出しブロックについて目次リストの作成と見出しへの ID 付与(ループ処理)が終わったら、目次リストの HTML を完成させます。
$toc_code = '<div class="my-toc-container"><p class="my-toc-title">' . __( 'INDEX' ) . '</p><ol>' . $toc_code . '</li>' . str_repeat( '</ol></li>', ( $previous_level - $top_level ) ) . '</ol></div>';
最初の見出しレベル($top_level)と最後の見出しレベル($previous_level)の差だけ </ol></li> を付けてリストタグを閉じておきます(インデント解除)。
目次リストの前に <p class="my-toc-title">' . __( 'INDEX' ) . '</p> で目次のタイトルを付け、目次リストを <ol>〜</ol> で括ります。
最後に <div class="my-toc-container"> 〜 </div> という div 要素で目次コンテンツをラップして完成です。
本文( $content )の前に目次リストのコードを連結して値を返せば、投稿ページを表示すると自動的に先頭に目次リストが表示されます。
return $toc_code . $content;
まとめる(プラグイン化)
ここまでの処理を実際に試すために、まとめてプラグイン化したものが以下です。
コピーして適当なファイル名を付け、/wp-content/plugins ディレクトリに入れれば利用できます。
<?php
/**
* Plugin Name: My Table of Contents
* Description: Generate Table of Contents in the top of single page.
* Requires at least: 5.9
* Requires PHP: 7.0
* Version: 0.0.1
* Author: your name
* License: GPL-2.0-or-later
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
* Text Domain: mytoc
*/
if ( !function_exists( 'generate_my_toc' ) ) {
function generate_my_toc( $content ){
// 投稿ページのメインループ内のときのみ
if ( is_single() && in_the_loop() && is_main_query() ) {
// 目次コード用
$toc_code = '';
// 番号あり : ol, 番号なし : ul
$list_tag = 'ol';
// 階層レベルチェック用
$top_level = $current_level = $previous_level = 1;
// カウンター
$index = 0;
// 見出しタグ抽出用
$reg = "/(<h([1-6]).*?>)(.*?)(<\/h[1-6]>)/";
// 本文テキストをブロックごとにパース
$blocks = parse_blocks( $content );
// パースしたブロックを順番に処理
foreach ( $blocks as $key => $block ) {
// 見出しブロックの場合
if ( $block['blockName'] == 'core/heading' ) {
// 見出しタグを解析
if ( preg_match( $reg, $block['innerHTML'], $m1 ) ) {
// 現在のレベルを取得
$current_level = (int)$m1[2];
// 1番目の見出しレベルを保持
if ( $index == 0 ) $top_level = $current_level;
// 本文内の見出しタグの置換
$content = preg_replace_callback( "/(<h[1-6].*?>)(" . preg_quote( $m1[3], '/' ) . ")(<\/h[1-6]>)/", function( $m2 ) use ( &$index ) {
// 見出しタグ内を span で括って ID を付けて入れ替える
return $m2[1] . '<span id="i-' . $index . '">' . $m2[2] . '</span>' . $m2[3];
}, $content );
// 1番目または現在の見出しレベルと直前の見出しレベルが同じとき
if ( $index == 0 || $current_level == $previous_level ) {
// 1番目以外は直前の見出しレベルに閉じタグ(</li>)を付ける
if ( $index > 0 ) {
$toc_code .= '</li>';
}
$toc_code .= '<li><a href="#i-' . $index . '">' . $m1[3] . '</a>';
} else if ( $current_level < $previous_level ) {
// 現在の見出しレベルが直前よりも高いとき
$toc_code .= '</li>'. str_repeat( '</' . $list_tag . '></li>', $previous_level - $current_level ) . '<li><a href="#i-' . $index . '">' . $m1[3] . '</a>';
} else {
// 現在の見出しレベルが直前よりも低いとき
$toc_code .= '<' . $list_tag . '><li><a href="#i-' . $index . '">' . $m1[3] . '</a>';
}
// 現在の見出しレベルを保持
$previous_level = (int)$m1[2];
// インクリメント
++$index;
}
}
}
if ( !empty( $toc_code ) ) {
// 目次リストの整形
$toc_code = '<div class="my-toc-container"><p class="my-toc-title">' . __( 'INDEX' ) . '</p><' . $list_tag . '>' . $toc_code . '</li>' . str_repeat( '</' . $list_tag . '></li>', ( $previous_level - $top_level ) ) . '</' . $list_tag . '></div>';
// 本文の先頭に目次リストを追加
return $toc_code . $content;
}
}
return $content;
}
add_filter( 'the_content', 'generate_my_toc', 1 );
}

サンプルでは CSS を用意していないので何もスタイリングされていませんが、専用の CSS を用意して見た目を整えれば、それなりの目次リストを表示できます。
また、このサンプルコードに目次を表示する条件(対象とする最大見出しレベル、見出しの最低出現数、対象から除外する class など)を加えれば、より柔軟な目次リストを作成できます。
