Table of Contents block

See Building a Block with Advanced Custom Fields for more information.

<?php
/**
 * Table of Contents Block
 *
 * @package      Cultivate_Pro
 * @author       CultivateWP
 * @since        1.0.0
 * @license      GPL-2.0+
**/

namespace Cultivate_Pro\Blocks;

class Table_of_Contents {

	/**
	 * Refers to a single instance of this class
	 *
	 * @var null
	 */
	private static $instance = null;

	/**
	 * Current recipe
	 *
	 * @var int
	 */
	private $current_recipe = 0;

	/**
	 * Headings to include in table of contents
	 *
	 * @var array
	 */
	private $headings = [];

	/**
	 * Creates or returns an instance of this class.
	 *
	 * @since 0.1.8
	 * @return object BE_Table_of_Contents, a single instance of this class.
	 */
	public static function instance() {
		if ( null == self::$instance ) {
			self::$instance = new self;
		}
		return self::$instance;
	}

	/**
	 * Construct
	 *
	 * @since 0.1.0
	 */
	function __construct() {

		// Setup headings
		$this->setup_headings();

		// Add ids to headings
		add_filter( 'the_content', [ $this, 'the_content' ] );
		add_filter( 'cultivate_pro/toc/content', [ $this, 'the_content' ] );
		add_filter( 'cultivate_pro/landing/the_content', [ $this, 'the_content' ] );

		// Create block
		add_action('acf/init', [ $this, 'register_block' ], 4 );

	}

	/**
	 * Setup headings
	 *
	 */
	function setup_headings() {
		$headings = [ 'h2' ];
		$headings = apply_filters( 'cultivate_pro/toc/setup_headings', $headings );
		$this->headings = $headings;
	}

	/**
	 * Using DOMDocument, parse the content and add anchors to headers
	 *
	 * @since 0.1.0
	 *
	 * @param string  $content The content
	 * @return string          the content, updated if the content has H1-H6
	 */
	function the_content( $content ) {

		if ( '' == $content ) {
			return $content;
		}

		if( is_admin() )
			return $content;

		global $post;
		$include_anchors = apply_filters( 'cultivate_pro/toc/include_anchors', false !== strpos( $post->post_content, 'wp:acf/table-of-contents' ) );
		if( ! $include_anchors )
			return $content;

		$anchors = array();
		$doc = new \DOMDocument();
		// START LibXML error management.
		// Modify state
		$libxml_previous_state = libxml_use_internal_errors( true );
		$doc->loadHTML( mb_convert_encoding( $content, 'HTML-ENTITIES', 'UTF-8' ) );
		// handle errors
		libxml_clear_errors();
		// restore
		libxml_use_internal_errors( $libxml_previous_state );
		// END LibXML error management.

		foreach ( $this->headings as $h ) {
			$headings = $doc->getElementsByTagName( $h );
			foreach ( $headings as $heading ) {
				$slug = $heading->getAttribute( 'id' );
				if( empty( $slug ) ) {
					$slug = $tmpslug = sanitize_title( $heading->nodeValue );
					// @codingStandardsIgnoreEnd
					$i = 2;
					while ( false !== in_array( $slug, $anchors ) ) {
						$slug = sprintf( '%s-%d', $tmpslug, $i++ );
					}
					$heading->setAttribute( 'id', $slug );
				}
				$anchors[] = $slug;
			}
		}
		return $doc->saveHTML();
	}

	/**
	 * Register table of contents block
	 *
	 */
	function register_block() {

		acf_register_block_type( apply_filters( 'cultivate_pro/toc/block_type_args', array(
			'name'				=> 'table-of-contents',
			'title'				=> __( 'Table of Contents', 'cultivate-pro' ),
			'render_callback'	=> [ $this, 'render_block' ],
			'category'			=> 'cultivatewp',
			'icon'				=> cultivate_pro()->icon( [ 'icon' => 'cultivatewp' ] ),
			'mode'				=> 'preview',
			'supports'			=> [ 'anchor' => false, 'align' => false ],
			'enqueue_assets'	=> cultivate_pro()->blocks->enqueue_assets( 'table-of-contents', [ 'css' ] ),
		)));

	}

	/**
	 * Render block
	 *
	 */
	function render_block( $block ) {
		$classes = ['block-toc'];
		if( !empty( $args['className'] ) )
    			$classes = array_merge( $classes, explode( ' ', $args['className'] ) );

		$title = apply_filters( 'cultivate_pro/toc/title', __( 'Table of Contents', 'cultivate-pro' ) );
		$preview = apply_filters( 'cultivate_pro/toc/preview', __( 'Table of Contents will appear here.', 'cultivate-pro' ) );
		$open = apply_filters( 'cultivate_pro/toc/open', false ) ? ' open' : '';

		echo '<details class="' . join( ' ', $classes ) . '"' . $open . '>';
			echo '<summary>' . $title . '</summary>';
			if( is_admin() && !empty( $preview ) ) {
				echo $preview;
			} else {
				echo cultivate_pro()->blocks->table_of_contents->output();
			}
		echo '</details>';
	}

	/**
	 * Table of Contents
	 *
	 */
	function output() {

		global $post;
		$content = apply_filters( 'cultivate_pro/toc/content', $post->post_content );
		$doc = new \DOMDocument();
		// START LibXML error management.
		// Modify state
		$libxml_previous_state = libxml_use_internal_errors( true );
		$doc->loadHTML( mb_convert_encoding( $content, 'HTML-ENTITIES', 'UTF-8' ) );
		// handle errors
		libxml_clear_errors();
		// restore
		libxml_use_internal_errors( $libxml_previous_state );
		// END LibXML error management.

		$query = join( '|', array_map(
			function($h) {
				return '//' . $h;
			},
			$this->headings
		) );

		$domxpath = new \DOMXPath($doc);
		$headings = $domxpath->query($query);
		if( empty( $headings ) )
			return;

		$output = '';
		$depth = 1;
		foreach( $headings as $heading ) {
			$nodeDepth = intval( str_replace('h', '', $heading->nodeName ) );
			if( $nodeDepth > $depth ) {
				$output .= '<ol>';
			} elseif( $nodeDepth < $depth ) {
				$output .= '</ol>';
			}

			$title = $heading->nodeValue;
			$class = $heading->getAttribute( 'class' );
			$id = $heading->getAttribute('id');
			if( empty( $id ) )
				$id = sanitize_title( $heading->nodeValue );
			if( 'wprm-fallback-recipe-name' === $class ) {
				$append = apply_filters( 'cultivate_pro/toc/append_recipe', ' Recipe' );
				if( $append )
					$title .= $append;
				$id = 'wprm-recipe-container-' . $this->recipe_id();
			}

			$output .= '<li><a href="#' . $id . '">' . $title . '</a></li>';
			$depth = $nodeDepth;
		}
		$output .= '</ol>';

		return $output;
	}

	/**
	 * Get Recipe ID
	 *
	 */
	function recipe_id() {
		if( ! class_exists( 'WPRM_Recipe_Manager' ) )
			return false;

		$recipe_id = false;
		global $post;
		$recipes = \WPRM_Recipe_Manager::get_recipe_ids_from_content( $post->post_content );
		if( empty( $recipes ) )
			return $recipe_id;

		if( !empty( $recipes[ $this->current_recipe ] ) ) {
			$recipe_id = $recipes[ $this->current_recipe ];
		}

		$this->current_recipe++;
		return $recipe_id;
	}
}
cultivate_pro()->blocks->table_of_contents = new Table_of_Contents;

Bill Erickson

Bill Erickson is the co-founder and lead developer at CultivateWP, a WordPress agency focusing on high performance sites for web publishers.

About Me
Ready to upgrade your website?

I build custom WordPress websites that look great and are easy to manage.

Let's Talk