# simpletoc/trunk/plugin.php

SimpleTOC – Table of Contents Block, version trunk. 1,053 lines.

- Page: https://pluginprobe.com/plugins/simpletoc/trunk/code/plugin.php
- Raw: https://pluginprobe.com/plugins/simpletoc/trunk/raw/plugin.php
- Modified: 2026-08-22T08:52:06+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/simpletoc/trunk/code/plugin.php#L10-L20`.

```php
<?php
/**
 * Plugin Name:   SimpleTOC - Table of Contents Block
 * Plugin URI:    https://marc.tv/simpletoc-wordpress-inhaltsverzeichnis-plugin-gutenberg/
 * Description:   SEO-friendly Table of Contents Gutenberg block. No JavaScript or CSS by default.
 * Version:       7.3.0
 * Requires at least: 6.2
 * Requires PHP: 7.3
 * Author:        Marc Tönsing
 * Author URI:    https://toensing.com
 * Text Domain:   simpletoc
 * License: GPL   v2 or later
 * License URI:   http://www.gnu.org/licenses/gpl-2.0.html
 *
 * @package simpletoc
 */

namespace MToensing\SimpleTOC;

require_once __DIR__ . '/simpletoc-admin-settings.php';
require_once __DIR__ . '/simpletoc-class-headline-ids.php';

const DEFAULT_BOX_COLOR = '#ebebeb';
const SIMPLETOC_VERSION = '7.3.0';

/**
 * Prevents direct execution of the plugin file.
 * If a WordPress function does not exist, it means that the file has not been run by WordPress.
 */
if ( ! defined( 'ABSPATH' ) || ! function_exists( 'add_filter' ) ) {
	header( 'Status: 403 Forbidden' );
	header( 'HTTP/1.1 403 Forbidden' );
	exit;
}

/**
 * Registers the SimpleTOC block, adds a filter for plugin row meta, and sets script translations.
 *
 * This function registers the SimpleTOC block by specifying the build directory and render callback function.
 * It also sets the script translations for the block editor script and adds a filter for the plugin row meta.
 */
function register_simpletoc_block() {

	if ( function_exists( 'wp_set_script_translations' ) ) {
		wp_set_script_translations( 'simpletoc-toc-editor-script', 'simpletoc' );
	}

	add_filter( 'plugin_row_meta', __NAMESPACE__ . '\simpletoc_plugin_meta', 10, 2 );

	register_block_type(
		__DIR__ . '/build',
		array(
			'render_callback' => __NAMESPACE__ . '\render_callback_simpletoc',
		)
	);

	wp_add_inline_script(
		'simpletoc-toc-editor-script',
		'window.simpletocEditorSettings = ' . wp_json_encode(
			array(
				'settingsUrl' => admin_url( 'options-general.php?page=simpletoc' ),
			)
		) . ';',
		'before'
	);
}

add_action( 'init', __NAMESPACE__ . '\register_simpletoc_block' );

/**
 * Adds SimpleTOC-specific block editor settings.
 *
 * @param array                    $editor_settings Default editor settings.
 * @param \WP_Block_Editor_Context $editor_context  Editor context.
 *
 * @return array
 */
function add_simpletoc_block_editor_settings( $editor_settings, $editor_context ) {
	$editor_settings['simpletocSettingsUrl'] = admin_url( 'options-general.php?page=simpletoc' );

	return $editor_settings;
}

add_filter( 'block_editor_settings_all', __NAMESPACE__ . '\add_simpletoc_block_editor_settings', 10, 2 );

/**
 * Inject potentially missing translations into the block-editor i18n
 * collection.
 *
 * This keeps the plugin backwards compatible, in case the user did not
 * update translations on their website (yet).
 *
 * @param string|false|null $translations JSON-encoded translation data. Default null.
 * @param string|false      $file         Path to the translation file to load. False if there isn't one.
 * @param string            $handle       Name of the script to register a translation domain to.
 * @param string            $domain       The text domain.
 *
 * @return string|false|null JSON string
 */
add_filter(
	'load_script_translations',
	function ( $translations, $file, $handle, $domain ) {
		if ( 'simpletoc' === $domain && $translations ) {
			// List of translations that we inject into the block-editor JS.
			$dynamic_translations = array(
				'Table of Contents' => __( 'Table of Contents', 'simpletoc' ),
			);

			$changed = false;
			$obj     = json_decode( $translations, true );

			// Confirm that the translation JSON is valid.
			if ( isset( $obj['locale_data'] ) && isset( $obj['locale_data']['messages'] ) ) {
				$messages = $obj['locale_data']['messages'];

				// Inject dynamic translations, when needed.
				foreach ( $dynamic_translations as $key => $locale ) {
					if ( empty( $messages[ $key ] )
					|| ! is_array( $messages[ $key ] )
					|| ! array_key_exists( 0, $messages[ $key ] )
					|| $locale !== $messages[ $key ][0]
					) {
						$messages[ $key ] = array( $locale );
						$changed          = true;
					}
				}

				// Only modify the translations string when locales did change.
				if ( $changed ) {
					$obj['locale_data']['messages'] = $messages;
					$translations                   = wp_json_encode( $obj );
				}
			}
		}

		return $translations;
	},
	10,
	4
);

/**
 * Sets the default value of translatable attributes.
 *
 * Values inside block.json are static strings that are not translated. This
 * filter inserts relevant translations i
 *
 * @param array $settings Array of determined settings for registering a block type.
 * @param array $metadata Metadata provided for registering a block type.
 *
 * @return array Modified settings array.
 */
add_filter(
	'block_type_metadata_settings',
	function ( $settings, $metadata ) {
		if ( 'simpletoc/toc' === $metadata['name'] ) {
			$settings['attributes']['title_text']['default'] = __( 'Table of Contents', 'simpletoc' );
		}

		return $settings;
	},
	10,
	2
);

/**
 * Filter to add plugins to the TOC list for Rank Math plugin.
 *
 * @param array $toc_plugins TOC plugins.
 */
add_filter(
	'rank_math/researches/toc_plugins',
	function ( $toc_plugins ) {
		$toc_plugins['simpletoc/plugin.php'] = 'SimpleTOC';
		return $toc_plugins;
	}
);



/**
 * Adds IDs to the headings of the provided post content using a recursive block structure.
 *
 * @param string $content The content to add IDs to.
 * @return string The content with IDs added to its headings
 */
function simpletoc_add_ids_to_content( $content ) {

	$blocks = parse_blocks( $content );

	$blocks = add_ids_to_blocks_recursive( $blocks );

	$content = serialize_blocks( $blocks );

	return $content;
}

add_filter( 'the_content', __NAMESPACE__ . '\simpletoc_add_ids_to_content', 1 );

/**
 * Recursively adds IDs to the headings of a nested block structure.
 *
 * @param array $blocks The blocks to add IDs to.
 * @return array The blocks with IDs added to their headings
 */
function add_ids_to_blocks_recursive( $blocks ) {

	$supported_blocks = array(
		'core/heading',
		'generateblocks/text',
		'generateblocks/headline',
	);

	/**
	 * Filter to add supported blocks for IDs.
	 *
	 * @param array $supported_blocks The array of supported blocks.
	 */
	$supported_blocks = apply_filters( 'simpletoc_supported_blocks_for_ids', $supported_blocks );

	// Need two separate instances so that IDs aren't double counted.
	$inner_html_id_instance    = new SimpleTOC_Headline_Ids();
	$inner_content_id_instance = new SimpleTOC_Headline_Ids();

	foreach ( $blocks as &$block ) {
		if ( isset( $block['blockName'] ) && in_array( $block['blockName'], $supported_blocks, true ) && isset( $block['innerHTML'] ) && isset( $block['innerContent'] ) && isset( $block['innerContent'][0] ) ) {
			$block['innerHTML']       = add_anchor_attribute( $block['innerHTML'], $inner_html_id_instance, $block );
			$block['innerContent'][0] = add_anchor_attribute( $block['innerContent'][0], $inner_content_id_instance, $block );
		} elseif ( ! empty( $block['innerBlocks'] ) ) {
			// search in groups.
			$block['innerBlocks'] = add_ids_to_blocks_recursive( $block['innerBlocks'] );
		}
	}

	return $blocks;
}

/**
 * Renders a Table of Contents block for a post
 *
 * @param array $attributes An array of attributes for the Table of Contents block.
 * @return string The HTML output for the Table of Contents block
 */
function render_callback_simpletoc( $attributes ) {
	$is_backend  = defined( 'REST_REQUEST' ) && REST_REQUEST && 'edit' === filter_input( INPUT_GET, 'context' );
	$title_text  = $attributes['title_text'] ? esc_html( trim( $attributes['title_text'] ) ) : __( 'Table of Contents', 'simpletoc' );
	$alignclass  = ! empty( $attributes['align'] ) ? 'align' . $attributes['align'] : '';
	$title_level = $attributes['title_level'];
	$global_box_style_enabled = apply_filters( 'simpletoc_box_style_enabled', false ) || true === (bool) get_option( 'simpletoc_box_style_enabled', false );
	$legacy_box_style_enabled = ! empty( $attributes['box_style'] );
	$typography_enabled       = ! empty( $attributes['fontSize'] ) || ! empty( $attributes['style']['typography'] );
	$wrapper_classes   = array( 'simpletoc' );
	$wrapper_style     = '';

	if ( $typography_enabled ) {
		$wrapper_classes[] = 'has-simpletoc-typography';
	}

	if ( $global_box_style_enabled || $legacy_box_style_enabled ) {
		$wrapper_classes[] = 'is-style-boxed';
		$wrapper_classes[] = 'has-simpletoc-box-style';

		if ( $global_box_style_enabled ) {
			$wrapper_classes[] = 'has-background';
			$wrapper_style     = safecss_filter_attr( 'background-color:' . DEFAULT_BOX_COLOR . ';' );
		} elseif ( ! empty( $attributes['box_color'] ) ) {
			$wrapper_classes[] = 'has-background';
			$wrapper_style     = safecss_filter_attr( 'background-color:' . $attributes['box_color'] . ';' );
		} else {
			$wrapper_classes[] = 'has-background';
			$wrapper_style     = safecss_filter_attr( 'background-color:' . DEFAULT_BOX_COLOR . ';' );
		}
	}

	$wrapper_attrs   = get_block_wrapper_attributes(
		array(
			'class' => implode( ' ', $wrapper_classes ),
			'style' => $wrapper_style,
		)
	);
	$pre_html        = '<div role="navigation" aria-label="' . esc_attr__( 'Table of Contents', 'simpletoc' ) . '" ' . $wrapper_attrs . '>';
	$post_html       = '</div>';

	$post   = get_post();
	$blocks = ! is_null( $post ) && ! is_null( $post->post_content ) ? parse_blocks( $post->post_content ) : '';

	$headings       = array_reverse( filter_headings_recursive( $blocks ) );
	$headings       = simpletoc_add_pagenumber( $blocks, $headings );
	$headings_clean = array_map( 'trim', $headings );
	$toc_html       = generate_toc( $headings_clean, $attributes );

	if ( empty( $blocks ) ) {
		return get_empty_blocks_message( $is_backend, $attributes, $title_level, $alignclass, $title_text, __( 'No blocks found.', 'simpletoc' ), __( 'Save or update post first.', 'simpletoc' ), $wrapper_attrs );
	}

	if ( empty( $headings_clean ) ) {
		return get_empty_blocks_message( $is_backend, $attributes, $title_level, $alignclass, $title_text, __( 'No headings found.', 'simpletoc' ), __( 'Save or update post first.', 'simpletoc' ), $wrapper_attrs );
	}

	if ( empty( $toc_html ) ) {
		return get_empty_blocks_message( $is_backend, $attributes, $title_level, $alignclass, $title_text, __( 'No headings found.', 'simpletoc' ), __( 'Check minimal and maximum level block settings.', 'simpletoc' ), $wrapper_attrs );
	}

	return $pre_html . $toc_html . $post_html;
}

/**
 * Generates an HTML message for empty blocks cases in the Table of Contents.
 *
 * @param bool   $is_backend    Indicates if the request is from the backend (i.e., the WordPress editor).
 * @param array  $attributes    An array of attributes for the Table of Contents block.
 * @param int    $title_level   The heading level for the Table of Contents title.
 * @param string $alignclass    The CSS class for alignment of the Table of Contents block.
 * @param string $title_text    The text for the Table of Contents title.
 * @param string $warning_text1 The first part of the warning message to be displayed.
 * @param string $warning_text2 The second part of the warning message to be displayed.
 * @param string $wrapper_attrs Block wrapper attributes.
 *
 * @return string The HTML output for the empty blocks message.
 */
function get_empty_blocks_message( $is_backend, $attributes, $title_level, $alignclass, $title_text, $warning_text1, $warning_text2, $wrapper_attrs = '' ) {
	$html = '';

	if ( $is_backend ) {
		$html .= '<div role="navigation" aria-label="' . esc_attr__( 'Table of Contents', 'simpletoc' ) . '" ' . $wrapper_attrs . '>';
		$html .= sprintf( '<h%d class="%s">%s</h%d>', $title_level, esc_attr( trim( 'simpletoc-title ' . $alignclass ) ), $title_text, $title_level );
		$html .= sprintf( '<p class="components-notice is-warning %s">%s %s</p>', esc_attr( $alignclass ), esc_html( $warning_text1 ), esc_html( $warning_text2 ) );
		$html .= '</div>';
	}

	return $html;
}

/**
 * Adds page numbers to headings in the provided blocks array.
 *
 * @param array $blocks The array of blocks to process.
 * @param array $headings The array of headings to add page numbers to.
 * @return array The modified headings array with page numbers added.
 */
function simpletoc_add_pagenumber( $blocks, $headings ) {
	$pages = 1;

	if ( ! is_array( $blocks ) ) {
		return $headings;
	}

	foreach ( $blocks as $block => $inner_block ) {
		// count nextpage blocks.
		if ( isset( $blocks[ $block ]['blockName'] ) && 'core/nextpage' === $blocks[ $block ]['blockName'] ) {
			++$pages;
		}

		if ( isset( $blocks[ $block ]['blockName'] ) && 'core/heading' === $blocks[ $block ]['blockName'] ) {
			// make sure its a headline.
			foreach ( $headings as $heading => &$inner_heading ) {
				if ( $inner_heading === $blocks[ $block ]['innerHTML'] ) {
					$inner_heading = simpletoc_add_page_number_to_headline( $blocks[ $block ]['innerHTML'], $pages );
				}
			}
		}
	}
	return $headings;
}

/**
 * Return all headings with a recursive walk through all blocks.
 * This includes groups and reusable block with groups within reusable blocks.
 *
 * @param array[] $blocks The blocks to filter headings from.
 * @return array[]
 */
function filter_headings_recursive( $blocks ) {
	$arr = array();

	if ( ! is_array( $blocks ) ) {
		return $arr;
	}

	// allow developers to ignore specific blocks.
	$ignored_blocks = apply_filters( 'simpletoc_excluded_blocks', array() );

	foreach ( $blocks as $inner_block ) {
		if ( is_array( $inner_block ) ) {
			// if block is ignored, skip.
			if ( isset( $inner_block['blockName'] ) && in_array( $inner_block['blockName'], $ignored_blocks, true ) ) {
				continue;
			}

			if ( isset( $inner_block['attrs']['ref'] ) ) {
				// search in reusable blocks.
				$post = get_post( $inner_block['attrs']['ref'] );
				if ( $post ) {
					$e_arr = parse_blocks( $post->post_content );
					$arr   = array_merge( filter_headings_recursive( $e_arr ), $arr );
				}
			} else {
				// search in groups.
				$arr = array_merge( filter_headings_recursive( $inner_block ), $arr );
			}
		} else {
			if ( isset( $blocks['blockName'] ) && ( 'core/heading' === $blocks['blockName'] ) && 'core/heading' !== $inner_block && simpletoc_is_heading_html( $inner_block ) ) {
				$arr[] = $inner_block;
			}

			$supported_third_party_blocks = array(
				'generateblocks/headline', /* GenerateBlocks 1.x */
				'generateblocks/text', /* GenerateBlocks 2.0 */
			);

			/**
			 * Filter to add supported third party blocks.
			 *
			 * @param array $supported_third_party_blocks The array of supported third party blocks.
			 * @return array The modified array of supported third party blocks.
			 */
			$supported_third_party_blocks = apply_filters(
				'simpletoc_supported_third_party_blocks',
				$supported_third_party_blocks
			);

			if ( isset( $blocks['blockName'] ) && in_array( $blocks['blockName'], $supported_third_party_blocks, true ) && 'core/heading' !== $inner_block && simpletoc_is_heading_html( $inner_block ) ) {
				$inner_block = simpletoc_maybe_replace_generateblocks_dynamic_tags( $inner_block, $blocks );
				$arr[] = $inner_block;
			}
		}
	}

	return $arr;
}

/**
 * Replaces GenerateBlocks dynamic tags in heading HTML before SimpleTOC uses it in the TOC.
 *
 * @param string $html  The heading HTML.
 * @param array  $block The parsed block data.
 * @return string The heading HTML with GenerateBlocks dynamic tags resolved when available.
 */
function simpletoc_maybe_replace_generateblocks_dynamic_tags( $html, $block ) {
	if ( ! class_exists( '\GenerateBlocks_Register_Dynamic_Tag' ) || false === strpos( $html, '{{' ) ) {
		return $html;
	}

	return \GenerateBlocks_Register_Dynamic_Tag::replace_tags( $html, $block, null );
}

/**
 * Gets heading HTML used for anchor generation.
 *
 * @param string $html  The original heading HTML.
 * @param array  $block The parsed block data.
 * @return string The heading HTML to use for anchor generation.
 */
function simpletoc_get_heading_html_for_anchor( $html, $block ) {
	$heading_html = simpletoc_maybe_replace_generateblocks_dynamic_tags( $html, $block );

	if ( '' === trim( wp_strip_all_tags( $heading_html ) ) ) {
		return $html;
	}

	return $heading_html;
}

/**
 * Sanitizes a string to be used as an anchor attribute in HTML by removing punctuation, non-breaking spaces, umlauts, and accents,
 * and replacing whitespace and other characters with dashes.
 *
 * @param string $string_to_sanitize The input string to be sanitized.
 * @return string The sanitized string encoded for use in a URL.
 */
function simpletoc_sanitize_string( $string_to_sanitize ) {
	// remove punctuation.
	$zero_punctuation = preg_replace( '/\p{P}/u', '', $string_to_sanitize );
	// remove non-breaking spaces.
	$html_wo_nbs = str_replace( '&nbsp;', ' ', $zero_punctuation );
	// remove umlauts and accents.
	$string_without_accents = remove_accents( $html_wo_nbs );
	// Sanitizes a title, replacing whitespace and a few other characters with dashes.
	$sanitized_string = sanitize_title_with_dashes( $string_without_accents );
	// Encode for use in an url.
	$urlencoded = rawurlencode( $sanitized_string );
	return $urlencoded;
}

/**
 * Add additional plugin meta links to the SimpleTOC plugin page.
 *
 * @param array  $links An array of plugin meta links.
 * @param string $file The plugin file path.
 * @return array The modified array of plugin meta links.
 */
function simpletoc_plugin_meta( $links, $file ) {

	if ( false !== strpos( $file, 'simpletoc' ) ) {
		$links = array_merge( $links, array( '<a href="https://wordpress.org/support/plugin/simpletoc">' . esc_html__( 'Support', 'simpletoc' ) . '</a>' ) );
		$links = array_merge( $links, array( '<a href="https://marc.tv/out/donate">' . esc_html__( 'Donate', 'simpletoc' ) . '</a>' ) );
		$links = array_merge( $links, array( '<a href="https://wordpress.org/support/plugin/simpletoc/reviews/#new-post">' . esc_html__( 'Write a review', 'simpletoc' ) . '&nbsp;⭐️⭐️⭐️⭐️⭐️</a>' ) );
	}

	return $links;
}

/**
 * Loads the WordPress HTML Tag Processor when available.
 *
 * @return bool True when the HTML Tag Processor can be used.
 */
function simpletoc_load_html_tag_processor() {
	if ( class_exists( '\WP_HTML_Tag_Processor' ) ) {
		return true;
	}

	if ( defined( 'ABSPATH' ) && defined( 'WPINC' ) ) {
		$html_tag_processor_file = ABSPATH . WPINC . '/html-api/class-wp-html-tag-processor.php';

		if ( file_exists( $html_tag_processor_file ) ) {
			require_once $html_tag_processor_file;
		}
	}

	return class_exists( '\WP_HTML_Tag_Processor' );
}

/**
 * Creates an HTML Tag Processor for a valid HTML fragment.
 *
 * Parsed block content can contain non-string placeholders for nested blocks.
 * The WordPress HTML API accepts strings only.
 *
 * @param mixed $html The HTML fragment to inspect.
 * @return \WP_HTML_Tag_Processor|null The processor, or null when unavailable or invalid.
 */
function simpletoc_get_html_tag_processor( $html ) {
	if ( ! is_string( $html ) || ! simpletoc_load_html_tag_processor() ) {
		return null;
	}

	return new \WP_HTML_Tag_Processor( $html );
}

/**
 * Returns true when the provided HTML contains a heading tag.
 *
 * @param string $html The HTML to inspect.
 * @return bool True when the HTML contains a heading tag.
 */
function simpletoc_is_heading_html( $html ) {
	return false !== simpletoc_get_heading_depth( $html );
}

/**
 * Gets the first heading depth from an HTML fragment.
 *
 * @param string $html The HTML to inspect.
 * @return int|false The heading depth, or false when no heading was found.
 */
function simpletoc_get_heading_depth( $html ) {
	$processor = simpletoc_get_html_tag_processor( $html );

	if ( $processor ) {

		while ( $processor->next_tag() ) {
			$tag_name = $processor->get_tag();

			if ( in_array( $tag_name, array( 'H1', 'H2', 'H3', 'H4', 'H5', 'H6' ), true ) ) {
				return (int) substr( $tag_name, 1 );
			}
		}
	}

	return false;
}

/**
 * Adds a data-page attribute to the first heading in an HTML fragment.
 *
 * @param string $html The heading HTML.
 * @param int    $page_number The page number to set.
 * @return string The updated HTML.
 */
function simpletoc_add_page_number_to_headline( $html, $page_number ) {
	$processor = simpletoc_get_html_tag_processor( $html );

	if ( $processor ) {

		while ( $processor->next_tag() ) {
			if ( ! in_array( $processor->get_tag(), array( 'H1', 'H2', 'H3', 'H4', 'H5', 'H6' ), true ) ) {
				continue;
			}

			$processor->set_attribute( 'data-page', (string) $page_number );
			return $processor->get_updated_html();
		}
	}

	return $html;
}

/**
 * Checks whether the first heading in an HTML fragment has the provided class.
 *
 * @param string $html The heading HTML.
 * @param string $class_name The class name to find.
 * @return bool True when the class exists.
 */
function simpletoc_heading_has_class( $html, $class_name ) {
	$processor = simpletoc_get_html_tag_processor( $html );

	if ( $processor ) {

		while ( $processor->next_tag() ) {
			if ( ! in_array( $processor->get_tag(), array( 'H1', 'H2', 'H3', 'H4', 'H5', 'H6' ), true ) ) {
				continue;
			}

			$class_attribute = $processor->get_attribute( 'class' );

			if ( ! is_string( $class_attribute ) ) {
				return false;
			}

			return in_array( $class_name, preg_split( '/\s+/', trim( $class_attribute ) ), true );
		}
	}

	return false;
}

/**
 * Adds an ID attribute to all Heading tags in the provided HTML.
 *
 * @param string                 $html The HTML content to modify.
 * @param SimpleTOC_Headline_Ids $headline_class_instance The instance of the SimpleTOC_Headline_Ids class.
 * @param array                  $block The parsed block data.
 * @return string The modified HTML content with ID attributes added to the Heading tags
 */
function add_anchor_attribute( $html, $headline_class_instance = null, $block = array() ) {
	if ( ! is_string( $html ) ) {
		return $html;
	}

	// remove non-breaking space entites from input HTML.
	$html_wo_nbs = str_replace( '&nbsp;', ' ', $html );

	// Thank you Nick Diego.
	if ( ! $html_wo_nbs ) {
		return $html;
	}

	$processor = simpletoc_get_html_tag_processor( $html_wo_nbs );

	if ( $processor ) {

		while ( $processor->next_tag() ) {
			if ( ! in_array( $processor->get_tag(), array( 'H1', 'H2', 'H3', 'H4', 'H5', 'H6' ), true ) ) {
				continue;
			}

			// If tag already has an attribute "id" defined, no need for creating a new one.
			if ( ! empty( $processor->get_attribute( 'id' ) ) ) {
				continue;
			}

			$heading_html = simpletoc_get_heading_html_for_anchor( $html, $block );
			$heading_text = trim( wp_strip_all_tags( $heading_html ) );
			$anchor       = $headline_class_instance->get_headline_anchor( $heading_text );
			$processor->set_attribute( 'id', $anchor );
		}

		return $processor->get_updated_html();
	}

	return $html;
}

/**
 * Generates a table of contents based on the provided headings and attributes
 *
 * @param array $headings An array of headings to include in the table of contents.
 * @param array $attributes An array of attributes to customize the output.
 * @return string The generated table of contents as HTML
 */
function generate_toc( $headings, $attributes ) {
	$list                        = '';
	$html                        = '';
	$min_depth                   = 6;
	$initial_depth               = 6;
	$align_class                 = isset( $attributes['align'] ) ? 'align' . $attributes['align'] : '';
	$styles                      = $attributes['remove_indent'] ? 'style="padding-left:0;list-style:none;"' : '';
	$list_type                   = $attributes['use_ol'] ? 'ol' : 'ul';
	$global_absolut_urls_enabled = get_option( 'simpletoc_absolute_urls_enabled', false );
	$absolute_url                = $attributes['use_absolute_urls'] || $global_absolut_urls_enabled ? get_permalink() : '';

	$toc_headings = get_included_toc_headings( $headings, $attributes );
	list($min_depth, $initial_depth) = find_min_depth( $toc_headings, $attributes );

	$item_count = count( $toc_headings );
	$list       = render_toc_list_items( $toc_headings, $list_type, $absolute_url, $min_depth );

	$html = add_accordion_start( $html, $attributes, $item_count, $align_class );
	$html = add_hidden_markup_start( $html, $attributes, $item_count, $align_class );
	$html = add_smooth( $html, $attributes );

	// Add the table of contents list to the output if the list is not empty.
	if ( ! empty( $list ) ) {
		$html_class = 'simpletoc-list';
		if ( ! empty( $align_class ) ) {
			$html_class .= " $align_class";
		}

		$html_style = '';
		if ( ! empty( $styles ) ) {
			$html_style = " $styles";
		}

		$html .= "<$list_type class=\"$html_class\"$html_style>\n$list</$list_type>";
	}

	$html = add_accordion_end( $html, $attributes );
	$html = add_hidden_markup_end( $html, $attributes );

	// return an emtpy string if stripped result is empty.
	if ( empty( trim( wp_strip_all_tags( $html ) ) ) ) {
		$html = '';
	}

	return $html;
}

/**
 * Finds the minimum depth level of headings in the provided array and adjusts it based on the provided attributes
 *
 * @param array $headings An array of headings to search through.
 * @param array $attributes An array of attributes to adjust the minimum depth level.
 * @return array An array containing the minimum depth level and the initial depth level.
 */
function find_min_depth( $headings, $attributes ) {
	$min_depth     = 6;
	$initial_depth = 6;

	foreach ( $headings as $line => $headline ) {
		$this_depth = is_array( $headline ) && isset( $headline['depth'] ) ? (int) $headline['depth'] : (int) $headings[ $line ][2];
		if ( $min_depth > $this_depth ) {
			$min_depth     = $this_depth;
			$initial_depth = $min_depth;
		}
	}

	if ( $attributes['min_level'] > $min_depth ) {
		$min_depth     = $attributes['min_level'];
		$initial_depth = $min_depth;
	}

	return array( $min_depth, $initial_depth );
}

/**
 * Determines if a given headline should be excluded based on the provided attributes.
 *
 * @param string $headline The headline to check for exclusion.
 * @param array  $attributes An array of attributes to use for exclusion.
 * @param int    $this_depth The depth level of the headline.
 * @return bool True if the headline should be excluded, false otherwise.
 */
function should_exclude_headline( $headline, $attributes, $this_depth ) {
	$exclude_headline = simpletoc_heading_has_class( $headline, 'simpletoc-hidden' );

	return ( $this_depth > $attributes['max_level'] || $exclude_headline || $this_depth < $attributes['min_level'] );
}

/**
 * Filters headings down to TOC-visible entries while preserving anchor generation order.
 *
 * @param array $headings An array of headings to include in the table of contents.
 * @param array $attributes An array of attributes to customize the output.
 * @return array[] The headings that should be rendered in the table of contents.
 */
function get_included_toc_headings( $headings, $attributes ) {
	$toc_headings = array();
	$headline_ids = new SimpleTOC_Headline_Ids();

	foreach ( $headings as $headline ) {
		$this_depth = simpletoc_get_heading_depth( $headline );

		if ( false === $this_depth ) {
			continue;
		}

		$title      = trim( wp_strip_all_tags( $headline ) );
		$custom_id  = extract_id( $headline );
		$link       = $custom_id ? $custom_id : $headline_ids->get_headline_anchor( $title );

		if ( should_exclude_headline( $headline, $attributes, $this_depth ) ) {
			continue;
		}

		$toc_headings[] = array(
			'headline' => $headline,
			'depth'    => $this_depth,
			'title'    => $title,
			'link'     => $link,
		);
	}

	return $toc_headings;
}

/**
 * Renders nested TOC list item markup from already-filtered headings.
 *
 * @param array[] $toc_headings The headings that should be rendered in the table of contents.
 * @param string  $list_type The type of list to be created, either "ul" (unordered list) or "ol".
 * @param string  $absolute_url The optional absolute URL prefix for links.
 * @param int     $min_depth The minimum heading depth included in the table of contents.
 * @return string The rendered nested list item markup.
 */
function render_toc_list_items( $toc_headings, $list_type, $absolute_url, $min_depth ) {
	$list          = '';
	$current_depth = null;

	foreach ( $toc_headings as $toc_heading ) {
		$this_depth = $toc_heading['depth'];

		if ( null === $current_depth ) {
			$current_depth = $this_depth;
			$list         .= '<li>';
		} elseif ( $this_depth > $current_depth ) {
			for ( $current_depth; $current_depth < $this_depth; $current_depth++ ) {
				$list .= "\n<" . $list_type . ">\n<li>";
			}
		} elseif ( $this_depth === $current_depth ) {
			$list .= "</li>\n<li>";
		} else {
			for ( $current_depth; $current_depth > $this_depth; $current_depth-- ) {
				$list .= "</li>\n</" . $list_type . ">\n";
			}
			$list .= "</li>\n<li>";
		}

		$page = get_page_number_from_headline( $toc_heading['headline'] );
		$url  = $absolute_url . $page . '#' . $toc_heading['link'];
		$href = $absolute_url ? esc_url( $url ) : esc_attr( $url );
		$list .= '<a href="' . $href . '">' . esc_html( $toc_heading['title'] ) . '</a>' . PHP_EOL;
	}

	if ( null !== $current_depth ) {
		for ( $current_depth; $current_depth > $min_depth; $current_depth-- ) {
			$list .= "</li>\n</" . $list_type . ">\n";
		}
		$list .= '</li>';
	}

	return $list;
}

/**
 * Adds smooth scrolling styles to the output HTML, if enabled by global option or block attribute.
 *
 * @param string $html The HTML string to which the styles will be added.
 * @param array  $attributes An array of block attributes.
 * @return string The modified HTML string with the added smooth scrolling styles.
 */
function add_smooth( $html, $attributes ) {
	// Add smooth scrolling styles, if enabled by global option or block attribute.
	$is_smooth_enabled = $attributes['add_smooth'] || true === (bool) get_option( 'simpletoc_smooth_enabled', false );
	$html             .= $is_smooth_enabled ? '<style>html { scroll-behavior: smooth; }</style>' : '';

	return $html;
}

/**
 * Enqueues the necessary CSS and JS files for the accordion functionality on the frontend.
 */
function enqueue_accordion_frontend() {
	wp_enqueue_script(
		'simpletoc-accordion',
		plugin_dir_url( __FILE__ ) . 'assets/accordion.js',
		array(),
		SIMPLETOC_VERSION,
		true
	);

	wp_enqueue_style(
		'simpletoc-accordion',
		plugin_dir_url( __FILE__ ) . 'assets/accordion.css',
		array(),
		SIMPLETOC_VERSION
	);
}

/**
 * Adds the opening HTML tag(s) for the hidden markup element and the table of contents title, if applicable.
 *
 * @param string $html The HTML string to add the opening tag(s) to.
 * @param array  $attributes The attributes of the table of contents block.
 * @param int    $itemcount The number of items in the table of contents.
 * @param string $alignclass The alignment class for the table of contents block.
 */
function add_hidden_markup_start( $html, $attributes, $itemcount, $alignclass ) { // phpcs:ignore.
	$is_hidden_enabled = $attributes['hidden'];

	if ( $is_hidden_enabled ) {
		$title_text   = $attributes['title_text'] ? esc_html( trim( $attributes['title_text'] ) ) : esc_html__( 'Table of Contents', 'simpletoc' );
		$hidden_start = '<details class="simpletoc">
        <summary>' . $title_text . '</summary>';
		$html        .= $hidden_start;
	}

	// If there are no items in the table of contents, return an empty string.
	if ( $itemcount < 1 ) {
		return '';
	}

	return $html;
}

/**
 * Adds the closing HTML tag(s) for the hidden markup element if the hidden markup is enabled.
 *
 * @param string $html The HTML string to add the closing tag(s) to.
 * @param array  $attributes The attributes of the table of contents block.
 * @return string The modified HTML string with the closing tag(s) added.
 */
function add_hidden_markup_end( $html, $attributes ) {
	$is_hidden_enabled = $attributes['hidden'];

	if ( $is_hidden_enabled ) {
		$html .= '</details>';
	}

	return $html;
}

/**
 * Adds the opening HTML tag(s) for the accordion element and the table of contents title, if applicable.
 *
 * @param string $html The HTML string to add the opening tag(s) to.
 * @param array  $attributes The attributes of the table of contents block.
 * @param int    $itemcount The number of items in the table of contents.
 * @param string $alignclass The alignment class for the table of contents block.
 */
function add_accordion_start( $html, $attributes, $itemcount, $alignclass ) {
	// Check if accordion is enabled either through the function arguments or the options.
	$is_accordion_enabled = $attributes['accordion'] || true === (bool) get_option( 'simpletoc_accordion_enabled', false );
	$is_hidden_enabled    = $attributes['hidden'];
	$title_text           = $attributes['title_text'] ? esc_html( trim( $attributes['title_text'] ) ) : esc_html__( 'Table of Contents', 'simpletoc' );

	// Start and end HTML for accordion, if enabled.
	$accordion_start = '';
	if ( $is_accordion_enabled ) {
		enqueue_accordion_frontend();
		$accordion_start = '<h2 class="simpletoc-accordion-heading"><button type="button" aria-expanded="false" aria-controls="simpletoc-content-container" class="simpletoc-collapsible">' . $title_text . '<span class="simpletoc-icon" aria-hidden="true"></span></button></h2><div id="simpletoc-content-container" class="simpletoc-content">';
	}

	// Add the accordion start HTML to the output.
	$html .= $accordion_start;

	// Add the table of contents title, if not hidden and not in accordion mode.
	$show_title = ! $attributes['no_title'] && ! $is_accordion_enabled && ! $is_hidden_enabled;
	if ( $show_title ) {
		$title_tag  = $attributes['title_level'] > 0 ? "h{$attributes['title_level']}" : 'p';
		$title_tag  = wp_strip_all_tags( $title_tag );
		$html_class = 'simpletoc-title';

		if ( ! empty( $alignclass ) ) {
			$html_class .= " $alignclass";
		}

		$html = "<$title_tag class=\"$html_class\">$title_text</$title_tag>\n";
	}

	// If there are no items in the table of contents, return an empty string.
	if ( $itemcount < 1 ) {
		return '';
	}

	return $html;
}

/**
 * Adds the closing HTML tag(s) for the accordion element if the accordion is enabled.
 *
 * @param string $html The HTML string to add the closing tag(s) to.
 * @param array  $attributes The attributes of the table of contents block.
 * @return string The modified HTML string with the closing tag(s) added
 */
function add_accordion_end( $html, $attributes ) {
	// Check if accordion is enabled either through the function arguments or the options.
	$is_accordion_enabled = $attributes['accordion'] || true === (bool) get_option( 'simpletoc_accordion_enabled', false );

	if ( $is_accordion_enabled ) {
		$html .= '</div>';
	}

	return $html;
}

/**
 * Extracts the ID value from the provided heading HTML string.
 *
 * @param string $headline The heading HTML string to extract the ID value from.
 * @return string|false Returns the extracted ID value, or false if no ID value is found.
 */
function extract_id( $headline ) {
	$processor = simpletoc_get_html_tag_processor( $headline );

	if ( $processor ) {

		while ( $processor->next_tag() ) {
			if ( ! in_array( $processor->get_tag(), array( 'H1', 'H2', 'H3', 'H4', 'H5', 'H6' ), true ) ) {
				continue;
			}

			$id_value = $processor->get_attribute( 'id' );

			if ( is_string( $id_value ) ) {
				return $id_value;
			}
		}
	}

	return false;
}

/**
 * Gets the page number from a headline string.
 *
 * @param string $headline The headline string.
 * @return string The page number (in the format "X/") if it exists and is greater than 1, or an empty string otherwise.
 */
function get_page_number_from_headline( $headline ) {
	$processor = simpletoc_get_html_tag_processor( $headline );

	if ( $processor ) {

		while ( $processor->next_tag() ) {
			if ( ! in_array( $processor->get_tag(), array( 'H1', 'H2', 'H3', 'H4', 'H5', 'H6' ), true ) ) {
				continue;
			}

			$page_number = (int) $processor->get_attribute( 'data-page' );

			if ( $page_number > 1 ) {
				return esc_html( $page_number . '/' );
			}
		}

		return '';
	}

	return '';
}

```
