WordPressに目次(プラグインなし、保存型)

WordPressに目次(プラグインなし、保存型)

以前作ったプラグインなしの目次では、見出し要素を取り出すための記事のパースに苦労したんですが、なんとブロックエディターで書かれた記事は簡単にパースができるようになっていました。parse_blocks です。これを使ってプラグインなし、保存型の目次を作ってみました。

01parse_blocks 関数

記事をコードエディタで見ますと、各ブロックがコメント行でくくられていることに気づきます。たとえば段落や見出しの場合は次のようになっています。

<!-- wp:paragraph -->
<p>本文</p>
<!-- /wp:paragraph -->

<!-- wp:heading -->
<h2>見出し</h2>
<!-- /wp:heading -->

parse_blocks はこのコメント行を使って各ブロックをパースしてきます。この Parse_blocks 関数に記事オブジェクトを与えますと、各ブロックのデータを配列で返します。たとえば上の記事を引数にして parse_blocks を呼びますと、


array(5) {
  ["blockName"]=>
  string(14) "core/paragraph"
  ["attrs"]=>
  array(0) {
  }
  ["innerBlocks"]=>
  array(0) {
  }
  ["innerHTML"]=>
  string(15) "
<p>本文</p>
"
  ["innerContent"]=>
  array(1) {
    [0]=>
    string(15) "
<p>本文</p>
"
  }
}
array(5) {
  ["blockName"]=>
  string(12) "core/heading"
  ["attrs"]=>
  array(0) {
  }
  ["innerBlocks"]=>
  array(0) {
  }
  ["innerHTML"]=>
  string(20) "
<h2>見出し</h2>
"
  ["innerContent"]=>
  array(1) {
    [0]=>
    string(20) "
<h2>見出し</h2>
"
  }
}

と返してきます。ですので、”blockName” が “core/heading” のものだけを取り出せば簡単に目次が作れることになります。また、見出しの場合、”attrs” に見出し h2~h6 のレベルを持っていますので正規表現を使わなくても階層化が可能です。

02プラグインなし保存型目次

次のコードを functions.php に書いておき、記事の目次を入れたい場所に {toc} と書いておけば、各見出しへのリンク付き目次が表示されます。

記事の保存、更新時に目次を作成しカスタムフィールドに保存しますので、表示はそれを読み出すだけです。

(2022.9.3)一部コードを修正しました。

/*
 目次作成 登録、更新時にカスタムフィールド toc に保存する
 記事内に {toc} を書いておくとカスタムフィールド toc と置き換わる
*/
add_action( 'save_post', 'create_table_of_contents', 10, 3 );
function create_table_of_contents( $post_ID, $post, $update ) {

	$blocks = parse_blocks( $post->post_content );

	$headings = array();
	$i = 1;
	foreach( $blocks as &$block ) {
		if( 'core/heading' === $block['blockName'] ){
			$level = (isset($block['attrs']['level'])) ? $block['attrs']['level'] : 2;  // h2 as default
			$title = wp_strip_all_tags( $block['innerHTML'] );
			$id = 'heading' . $level . '-' . $i;
			$headings[] = ['title' => $title, 'level' => $level, 'id' => $id];

			$element = '<h' . $level . ' id="' . $id . '">' . $title . '</h' . $level . '>';
			$block['innerHTML'] = $element;
			$block['innerContent'][0] = $element;
			$i++;
		}
	}

	$toc = '';

// 9.3修正
//	$current_level = 2;
	$current_level = $first_level = 2;
// ここまで
	if( !empty( $headings ) ) {
		foreach ($headings as $heading) {
			if($current_level === $heading['level']){
				$toc .= '</li><li class="heading-level-' . $heading['level'] . '"><a href="#' . $heading['id'] . '">' . $heading['title'] . '</a>';
			}else if($current_level < $heading['level']){
				$toc .= '<ul><li class="heading-level-' . $heading['level'] . '"><a href="#' . $heading['id'] . '">' . $heading['title'] . '</a>';
			}else{
				$toc .= str_repeat('</li></ul>', $current_level - $heading['level']);
				$toc .= '</li><li class="heading-level-' . $heading['level'] . '"><a href="#' . $heading['id'] . '">' . $heading['title'] . '</a>';
			}
			$current_level = $heading['level'];
		}
// 9.3追加
		$toc .= str_repeat('</li></ul>', $current_level - $first_level);
// ここまで
	}
	$toc = '<ul id="toc" class="table-of-contents">' . substr($toc, 5) . '</li></ul>';

	$post->post_content = serialize_blocks($blocks);
	remove_action('save_post','create_table_of_contents', 10, 3);
	wp_update_post($post);
	add_action('save_post','create_table_of_contents', 10, 3);

	$result =  add_post_meta($post->ID, 'toc', $toc, true);
	if(!$result){
    	update_post_meta($post->ID, 'toc', $toc);
	}
}

/* 目次挿入 */
add_filter('the_content', 'add_toc');
function add_toc($content){
	global $post;
	if( strpos( $content, '{toc}') !== false ){
		$meta_value = get_post_meta( $post->ID, 'toc', true );
		$content = str_replace( '<p>{toc}</p>', $meta_value, $content );
	}
	return $content;
}

次の記事を参考にしました。

03リストの入れ子は必要か?

ところで、上のコードではリストを入れ子にして階層化していますが、結構面倒なことですので、一律 <li></li> で括るだけにして css でインデントしてはダメなんでしょうか? 見出しは構造化されており、目次は見出しへのリンクだけですので視覚的に階層がわかればいいような気もしますがどうなんでしょう?

その方法でいきますと次のようになります。。

/*
 目次作成 登録、更新時にカスタムフィールド toc に保存する
 記事内に {toc} を書いておくと single.php から呼び出される
*/
add_action( 'save_post', 'create_table_of_contents', 10, 3 );
function create_table_of_contents( $post_ID, $post, $update ) {

	$blocks = parse_blocks( $post->post_content );

	$headings = array();
	$i = 1;
	$toc = '';
	foreach( $blocks as &$block ) {
		if( 'core/heading' === $block['blockName'] ){
			$level = (isset($block['attrs']['level'])) ? $block['attrs']['level'] : 2;  // h2 as default
			$title = wp_strip_all_tags( $block['innerHTML'] );
			$id = 'heading' . $level . '-' . $i;

			$element = '<h' . $level . ' id="' . $id . '">' . $title . '</h' . $level . '>';
			$block['innerHTML'] = $element;
			$block['innerContent'][0] = $element;

			$toc .= '<li class="heading-level-' . $level . '"><a href="#' . $id . '">' . $title . '</a></li>';
			$i++;
		}
	}

	$toc = '<ul id="toc" class="table-of-contents">' . $toc . '</ul>';

	$post->post_content = serialize_blocks($blocks);
	remove_action('save_post','create_table_of_contents', 10, 3);
	wp_update_post($post);
	add_action('save_post','create_table_of_contents', 10, 3);

	$result =  add_post_meta($post->ID, 'toc', $toc, true);
	if(!$result){
    	update_post_meta($post->ID, 'toc', $toc);
	}
}

/* 目次挿入 */
add_filter('the_content', 'add_toc');
function add_toc($content){
	global $post;
	if( strpos( $content, '{toc}') !== false ){
		$meta_value = get_post_meta( $post->ID, 'toc', true );
		$content = str_replace( '<p>{toc}</p>', $meta_value, $content );
	}
	return $content;
}

これですと次の HTML を吐き出します。

<ul id="toc" class="table-of-contents">
<li class="heading-level-2"><a href="#heading2-1">H2見出し</a></li>
<li class="heading-level-3"><a href="#heading3-2">H3見出し</a></li>
<li class="heading-level-3"><a href="#heading3-3">H3見出し</a></li>
<li class="heading-level-2"><a href="#heading2-4">H2見出し</a></li>
</ul>

見出しレベルに応じたクラス heading-level-* を設定していますですので、たとえば次のようなスタイルを指定すれば、見た目は入れ子と同じようになります。

.heading-level-2 {
    list-style: disc;
}
.heading-level-3 {
    margin-left: 20px;
    list-style: circle;
}

入れ子にするためには、該当の見出しのレベルが、前と同じレベルか、大きいか、小さいかなどで分岐してリスト要素を追加することになります。難しいことではありませんが、ふと面倒だなあと思い、問題なければこれでいいんじゃないのと考えたということです。

参考にした記事はこの方法を取っています。