table-of-contents.php

I’ll build this as a complete advanced WordPress shortcode file for inc/shortcodes/table-of-contents.php, with shortcode, auto-injection, heading parsing, accessibility, smooth scroll, nested list, admin-friendly filters, schema-ready output, and safe escaping.

Below is a complete advanced inc/shortcodes/table-of-contents.php file for your RX Theme.

It includes:

Shortcode: [rx_toc]
Auto heading detection from post content
Nested H2–H6 table of contents
Smooth scroll
Active heading highlight
Sticky option
Collapsible option
Custom title
Numbering option
Back-to-top links
Accessibility attributes
Duplicate heading ID protection
Auto inject before first heading
Exclude headings by text/class
Frontend CSS and JS included safely
Developer filters for future extension

<?php
/**
 * RX Theme - Advanced Table of Contents Shortcode
 *
 * File: inc/shortcodes/table-of-contents.php
 *
 * Usage:
 * [rx_toc]
 * [rx_toc title="Article Contents" min="2" max="4" numbered="yes" sticky="yes"]
 * [rx_toc collapsible="yes" collapsed="no" smooth="yes" back_to_top="yes"]
 *
 * Add this file in functions.php:
 * require_once get_template_directory() . '/inc/shortcodes/table-of-contents.php';
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

if ( ! class_exists( 'RX_Theme_Table_Of_Contents' ) ) :

final class RX_Theme_Table_Of_Contents {

	/**
	 * Class instance.
	 *
	 * @var RX_Theme_Table_Of_Contents|null
	 */
	private static $instance = null;

	/**
	 * Heading IDs collected per post render.
	 *
	 * @var array
	 */
	private $used_ids = array();

	/**
	 * Current page headings.
	 *
	 * @var array
	 */
	private $headings = array();

	/**
	 * Whether assets already printed.
	 *
	 * @var bool
	 */
	private $assets_printed = false;

	/**
	 * Get instance.
	 */
	public static function instance() {
		if ( null === self::$instance ) {
			self::$instance = new self();
		}

		return self::$instance;
	}

	/**
	 * Constructor.
	 */
	private function __construct() {
		add_shortcode( 'rx_toc', array( $this, 'shortcode' ) );

		/*
		 * Auto-add IDs to headings inside post content.
		 * Priority 12 keeps it after most shortcode/content formatting but before later layout filters.
		 */
		add_filter( 'the_content', array( $this, 'add_heading_ids_to_content' ), 12 );

		/*
		 * Optional auto injection.
		 * Disabled by default. Enable with:
		 * add_filter( 'rx_toc_auto_insert_enabled', '__return_true' );
		 */
		add_filter( 'the_content', array( $this, 'maybe_auto_insert_toc' ), 20 );

		add_action( 'wp_footer', array( $this, 'print_assets' ), 30 );
	}

	/**
	 * Default shortcode attributes.
	 */
	private function defaults() {
		return apply_filters(
			'rx_toc_defaults',
			array(
				'title'             => __( 'Table of Contents', 'rx-theme' ),
				'min'               => 2,
				'max'               => 4,
				'numbered'          => 'yes',
				'smooth'            => 'yes',
				'sticky'            => 'no',
				'collapsible'       => 'yes',
				'collapsed'         => 'no',
				'back_to_top'       => 'no',
				'auto_ids'          => 'yes',
				'min_headings'      => 2,
				'class'             => '',
				'list_type'         => 'ol',
				'exclude'           => '',
				'include_h1'        => 'no',
				'show_count'        => 'yes',
				'aria_label'        => __( 'Article table of contents', 'rx-theme' ),
				'empty'             => 'hide',
				'depth_class'       => 'yes',
				'active_highlight'  => 'yes',
			)
		);
	}

	/**
	 * Shortcode callback.
	 *
	 * @param array $atts Shortcode attributes.
	 */
	public function shortcode( $atts = array() ) {
		if ( is_admin() && ! wp_doing_ajax() ) {
			return '';
		}

		$atts = shortcode_atts( $this->defaults(), $atts, 'rx_toc' );
		$atts = $this->sanitize_atts( $atts );

		$content = $this->get_current_post_content();

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

		$content  = $this->add_heading_ids_to_html( $content, $atts );
		$headings = $this->extract_headings( $content, $atts );

		if ( count( $headings ) < absint( $atts['min_headings'] ) ) {
			return 'show' === $atts['empty'] ? $this->empty_toc_message() : '';
		}

		$this->headings = $headings;

		return $this->render_toc( $headings, $atts );
	}

	/**
	 * Sanitize shortcode attributes.
	 *
	 * @param array $atts Raw attributes.
	 */
	private function sanitize_atts( $atts ) {
		$atts['title']        = sanitize_text_field( $atts['title'] );
		$atts['min']          = absint( $atts['min'] );
		$atts['max']          = absint( $atts['max'] );
		$atts['min_headings'] = absint( $atts['min_headings'] );
		$atts['class']        = sanitize_html_class( $atts['class'] );
		$atts['aria_label']   = sanitize_text_field( $atts['aria_label'] );
		$atts['exclude']      = sanitize_text_field( $atts['exclude'] );

		if ( 'yes' === $atts['include_h1'] ) {
			$atts['min'] = 1;
		}

		$atts['min'] = max( 1, min( 6, $atts['min'] ) );
		$atts['max'] = max( 1, min( 6, $atts['max'] ) );

		if ( $atts['min'] > $atts['max'] ) {
			$temporary   = $atts['min'];
			$atts['min'] = $atts['max'];
			$atts['max'] = $temporary;
		}

		$yes_no_fields = array(
			'numbered',
			'smooth',
			'sticky',
			'collapsible',
			'collapsed',
			'back_to_top',
			'auto_ids',
			'include_h1',
			'show_count',
			'depth_class',
			'active_highlight',
		);

		foreach ( $yes_no_fields as $field ) {
			$atts[ $field ] = $this->normalize_yes_no( $atts[ $field ] );
		}

		$atts['list_type'] = in_array( $atts['list_type'], array( 'ol', 'ul' ), true ) ? $atts['list_type'] : 'ol';
		$atts['empty']     = in_array( $atts['empty'], array( 'hide', 'show' ), true ) ? $atts['empty'] : 'hide';

		return apply_filters( 'rx_toc_sanitized_atts', $atts );
	}

	/**
	 * Normalize yes/no values.
	 *
	 * @param string $value Value.
	 */
	private function normalize_yes_no( $value ) {
		$value = strtolower( trim( (string) $value ) );

		return in_array( $value, array( '1', 'true', 'yes', 'on' ), true ) ? 'yes' : 'no';
	}

	/**
	 * Get current post content without causing recursive shortcode rendering.
	 */
	private function get_current_post_content() {
		global $post;

		if ( ! $post || empty( $post->post_content ) ) {
			return '';
		}

		$content = $post->post_content;

		/*
		 * Remove current TOC shortcode before scanning headings.
		 */
		$content = preg_replace( '/\[rx_toc[^\]]*\]/i', '', $content );

		return apply_filters( 'rx_toc_source_content', $content, $post );
	}

	/**
	 * Add IDs to headings inside the_content.
	 *
	 * @param string $content Post content.
	 */
	public function add_heading_ids_to_content( $content ) {
		if ( is_admin() || ! is_singular() || empty( $content ) ) {
			return $content;
		}

		$atts = $this->sanitize_atts( $this->defaults() );

		if ( 'yes' !== $atts['auto_ids'] ) {
			return $content;
		}

		return $this->add_heading_ids_to_html( $content, $atts );
	}

	/**
	 * Add unique IDs to headings in raw HTML.
	 *
	 * @param string $html HTML.
	 * @param array  $atts Attributes.
	 */
	private function add_heading_ids_to_html( $html, $atts ) {
		if ( empty( $html ) ) {
			return $html;
		}

		$min = absint( $atts['min'] );
		$max = absint( $atts['max'] );

		$pattern = '/<h([1-6])([^>]*)>(.*?)<\/h\1>/is';

		$html = preg_replace_callback(
			$pattern,
			function ( $matches ) use ( $min, $max ) {
				$level      = absint( $matches[1] );
				$attributes = $matches[2];
				$inner_html  = $matches[3];

				if ( $level < $min || $level > $max ) {
					return $matches[0];
				}

				if ( preg_match( '/\sid=["\']([^"\']+)["\']/i', $attributes ) ) {
					return $matches[0];
				}

				$text = trim( wp_strip_all_tags( $inner_html ) );

				if ( '' === $text ) {
					return $matches[0];
				}

				$id = $this->generate_unique_id( $text );

				return sprintf(
					'<h%d%s id="%s">%s</h%d>',
					$level,
					$attributes,
					esc_attr( $id ),
					$inner_html,
					$level
				);
			},
			$html
		);

		return $html;
	}

	/**
	 * Extract headings from content.
	 *
	 * @param string $html HTML.
	 * @param array  $atts Attributes.
	 */
	private function extract_headings( $html, $atts ) {
		$headings = array();

		if ( empty( $html ) ) {
			return $headings;
		}

		$min     = absint( $atts['min'] );
		$max     = absint( $atts['max'] );
		$exclude = $this->parse_exclude_list( $atts['exclude'] );

		$pattern = '/<h([1-6])([^>]*)>(.*?)<\/h\1>/is';

		if ( ! preg_match_all( $pattern, $html, $matches, PREG_SET_ORDER ) ) {
			return $headings;
		}

		foreach ( $matches as $match ) {
			$level      = absint( $match[1] );
			$attributes = $match[2];
			$inner_html  = $match[3];

			if ( $level < $min || $level > $max ) {
				continue;
			}

			$text = trim( wp_strip_all_tags( $inner_html ) );

			if ( '' === $text ) {
				continue;
			}

			if ( $this->is_heading_excluded( $text, $attributes, $exclude ) ) {
				continue;
			}

			$id = '';

			if ( preg_match( '/\sid=["\']([^"\']+)["\']/i', $attributes, $id_match ) ) {
				$id = sanitize_title( $id_match[1] );
			}

			if ( empty( $id ) ) {
				$id = $this->generate_unique_id( $text );
			}

			$headings[] = array(
				'level' => $level,
				'id'    => $id,
				'text'  => $text,
			);
		}

		return apply_filters( 'rx_toc_extracted_headings', $headings, $html, $atts );
	}

	/**
	 * Render TOC.
	 *
	 * @param array $headings Headings.
	 * @param array $atts Attributes.
	 */
	private function render_toc( $headings, $atts ) {
		$count = count( $headings );

		$classes = array(
			'rx-toc',
			'rx-toc--level-' . absint( $atts['min'] ) . '-' . absint( $atts['max'] ),
		);

		if ( 'yes' === $atts['sticky'] ) {
			$classes[] = 'rx-toc--sticky';
		}

		if ( 'yes' === $atts['collapsible'] ) {
			$classes[] = 'rx-toc--collapsible';
		}

		if ( 'yes' === $atts['collapsed'] ) {
			$classes[] = 'is-collapsed';
		}

		if ( 'yes' === $atts['numbered'] ) {
			$classes[] = 'rx-toc--numbered';
		}

		if ( 'yes' === $atts['active_highlight'] ) {
			$classes[] = 'rx-toc--active-enabled';
		}

		if ( ! empty( $atts['class'] ) ) {
			$classes[] = $atts['class'];
		}

		$classes = apply_filters( 'rx_toc_wrapper_classes', $classes, $atts, $headings );

		ob_start();
		?>
		<nav
			class="<?php echo esc_attr( implode( ' ', array_map( 'sanitize_html_class', $classes ) ) ); ?>"
			aria-label="<?php echo esc_attr( $atts['aria_label'] ); ?>"
			data-rx-toc
			data-smooth="<?php echo esc_attr( $atts['smooth'] ); ?>"
			data-active="<?php echo esc_attr( $atts['active_highlight'] ); ?>"
		>
			<div class="rx-toc__header">
				<div class="rx-toc__title-wrap">
					<span class="rx-toc__icon" aria-hidden="true"></span>
					<strong class="rx-toc__title"><?php echo esc_html( $atts['title'] ); ?></strong>

					<?php if ( 'yes' === $atts['show_count'] ) : ?>
						<span class="rx-toc__count">
							<?php
							printf(
								esc_html( _n( '%s section', '%s sections', $count, 'rx-theme' ) ),
								esc_html( number_format_i18n( $count ) )
							);
							?>
						</span>
					<?php endif; ?>
				</div>

				<?php if ( 'yes' === $atts['collapsible'] ) : ?>
					<button
						type="button"
						class="rx-toc__toggle"
						aria-expanded="<?php echo 'yes' === $atts['collapsed'] ? 'false' : 'true'; ?>"
					>
						<span class="rx-toc__toggle-text">
							<?php echo 'yes' === $atts['collapsed'] ? esc_html__( 'Show', 'rx-theme' ) : esc_html__( 'Hide', 'rx-theme' ); ?>
						</span>
					</button>
				<?php endif; ?>
			</div>

			<div class="rx-toc__body">
				<?php echo $this->build_nested_list( $headings, $atts ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
			</div>
		</nav>
		<?php

		return apply_filters( 'rx_toc_html', ob_get_clean(), $headings, $atts );
	}

	/**
	 * Build nested list.
	 *
	 * @param array $headings Headings.
	 * @param array $atts Attributes.
	 */
	private function build_nested_list( $headings, $atts ) {
		if ( empty( $headings ) ) {
			return '';
		}

		$list_tag      = 'ul' === $atts['list_type'] ? 'ul' : 'ol';
		$html          = '';
		$current_level = 0;
		$first_level   = absint( $headings[0]['level'] );

		foreach ( $headings as $index => $heading ) {
			$level = absint( $heading['level'] );

			if ( 0 === $current_level ) {
				$html .= '<' . esc_html( $list_tag ) . ' class="rx-toc__list rx-toc__list--root">';
				$current_level = $level;
			}

			if ( $level > $current_level ) {
				while ( $level > $current_level ) {
					$current_level++;
					$html .= '<' . esc_html( $list_tag ) . ' class="rx-toc__list rx-toc__list--nested">';
				}
			} elseif ( $level < $current_level ) {
				while ( $level < $current_level ) {
					$html .= '</li></' . esc_html( $list_tag ) . '>';
					$current_level--;
				}
				$html .= '</li>';
			} elseif ( $index > 0 ) {
				$html .= '</li>';
			}

			$item_classes = array(
				'rx-toc__item',
				'rx-toc__item--h' . $level,
			);

			if ( 'yes' === $atts['depth_class'] ) {
				$item_classes[] = 'rx-toc__item--depth-' . max( 1, $level - $first_level + 1 );
			}

			$html .= sprintf(
				'<li class="%1$s"><a class="rx-toc__link" href="#%2$s" data-rx-toc-link="%2$s"><span class="rx-toc__link-text">%3$s</span></a>',
				esc_attr( implode( ' ', array_map( 'sanitize_html_class', $item_classes ) ) ),
				esc_attr( $heading['id'] ),
				esc_html( $heading['text'] )
			);
		}

		while ( $current_level > 0 ) {
			$html .= '</li></' . esc_html( $list_tag ) . '>';
			$current_level--;
		}

		return $html;
	}

	/**
	 * Maybe auto insert TOC in content.
	 *
	 * @param string $content Content.
	 */
	public function maybe_auto_insert_toc( $content ) {
		if ( is_admin() || ! is_singular() || empty( $content ) ) {
			return $content;
		}

		$enabled = apply_filters( 'rx_toc_auto_insert_enabled', false );

		if ( ! $enabled ) {
			return $content;
		}

		if ( has_shortcode( $content, 'rx_toc' ) ) {
			return $content;
		}

		$atts = $this->sanitize_atts( $this->defaults() );
		$toc  = $this->shortcode( $atts );

		if ( empty( $toc ) ) {
			return $content;
		}

		$position = apply_filters( 'rx_toc_auto_insert_position', 'before_first_heading' );

		if ( 'after_first_paragraph' === $position ) {
			return $this->insert_after_first_paragraph( $toc, $content );
		}

		return $this->insert_before_first_heading( $toc, $content );
	}

	/**
	 * Insert TOC before first heading.
	 *
	 * @param string $toc TOC HTML.
	 * @param string $content Content.
	 */
	private function insert_before_first_heading( $toc, $content ) {
		$pattern = '/<h[1-6][^>]*>/i';

		if ( preg_match( $pattern, $content, $match, PREG_OFFSET_CAPTURE ) ) {
			$position = $match[0][1];
			return substr_replace( $content, $toc, $position, 0 );
		}

		return $toc . $content;
	}

	/**
	 * Insert TOC after first paragraph.
	 *
	 * @param string $toc TOC HTML.
	 * @param string $content Content.
	 */
	private function insert_after_first_paragraph( $toc, $content ) {
		$closing_p = '</p>';
		$position  = strpos( $content, $closing_p );

		if ( false !== $position ) {
			$position += strlen( $closing_p );
			return substr_replace( $content, $toc, $position, 0 );
		}

		return $toc . $content;
	}

	/**
	 * Generate unique heading ID.
	 *
	 * @param string $text Heading text.
	 */
	private function generate_unique_id( $text ) {
		$base = sanitize_title( $text );

		if ( empty( $base ) ) {
			$base = 'rx-section';
		}

		$id = $base;
		$i  = 2;

		while ( in_array( $id, $this->used_ids, true ) ) {
			$id = $base . '-' . $i;
			$i++;
		}

		$this->used_ids[] = $id;

		return apply_filters( 'rx_toc_heading_id', $id, $text );
	}

	/**
	 * Parse exclude list.
	 *
	 * @param string $exclude Exclude string.
	 */
	private function parse_exclude_list( $exclude ) {
		if ( empty( $exclude ) ) {
			return array();
		}

		$items = array_map( 'trim', explode( ',', $exclude ) );
		$items = array_filter( $items );

		return array_map( 'strtolower', $items );
	}

	/**
	 * Check whether a heading is excluded.
	 *
	 * Supports:
	 * exclude="Overview,FAQ"
	 * exclude=".no-toc,.skip-toc"
	 *
	 * @param string $text Heading text.
	 * @param string $attributes Heading attributes.
	 * @param array  $exclude Exclude list.
	 */
	private function is_heading_excluded( $text, $attributes, $exclude ) {
		if ( empty( $exclude ) ) {
			return false;
		}

		$text_lower = strtolower( $text );

		foreach ( $exclude as $item ) {
			if ( '' === $item ) {
				continue;
			}

			if ( 0 === strpos( $item, '.' ) ) {
				$class_name = substr( $item, 1 );

				if ( preg_match( '/class=["\']([^"\']+)["\']/i', $attributes, $class_match ) ) {
					$classes = array_map( 'trim', explode( ' ', strtolower( $class_match[1] ) ) );

					if ( in_array( strtolower( $class_name ), $classes, true ) ) {
						return true;
					}
				}
			} elseif ( false !== strpos( $text_lower, $item ) ) {
				return true;
			}
		}

		return apply_filters( 'rx_toc_is_heading_excluded', false, $text, $attributes, $exclude );
	}

	/**
	 * Empty TOC message.
	 */
	private function empty_toc_message() {
		return '<div class="rx-toc rx-toc--empty">' . esc_html__( 'No table of contents available for this article.', 'rx-theme' ) . '</div>';
	}

	/**
	 * Print frontend CSS and JS.
	 */
	public function print_assets() {
		if ( $this->assets_printed ) {
			return;
		}

		if ( is_admin() ) {
			return;
		}

		$this->assets_printed = true;
		?>
		<style id="rx-toc-style">
			.rx-toc {
				--rx-toc-bg: #ffffff;
				--rx-toc-border: #e5e7eb;
				--rx-toc-text: #111827;
				--rx-toc-muted: #6b7280;
				--rx-toc-link: #2563eb;
				--rx-toc-link-hover: #1d4ed8;
				--rx-toc-active-bg: #eff6ff;
				--rx-toc-radius: 14px;
				--rx-toc-shadow: 0 10px 28px rgba(15, 23, 42, 0.08);

				background: var(--rx-toc-bg);
				border: 1px solid var(--rx-toc-border);
				border-radius: var(--rx-toc-radius);
				box-shadow: var(--rx-toc-shadow);
				color: var(--rx-toc-text);
				margin: 24px 0;
				overflow: hidden;
			}

			.rx-toc--sticky {
				position: sticky;
				top: 24px;
				z-index: 20;
			}

			.admin-bar .rx-toc--sticky {
				top: 56px;
			}

			.rx-toc__header {
				align-items: center;
				background: linear-gradient(180deg, rgba(249,250,251,0.95), rgba(255,255,255,0.95));
				border-bottom: 1px solid var(--rx-toc-border);
				display: flex;
				justify-content: space-between;
				gap: 12px;
				padding: 14px 16px;
			}

			.rx-toc__title-wrap {
				align-items: center;
				display: flex;
				flex-wrap: wrap;
				gap: 8px;
				min-width: 0;
			}

			.rx-toc__icon {
				display: inline-flex;
				font-size: 17px;
				line-height: 1;
			}

			.rx-toc__title {
				font-size: 16px;
				font-weight: 700;
				line-height: 1.35;
			}

			.rx-toc__count {
				color: var(--rx-toc-muted);
				font-size: 13px;
				font-weight: 500;
			}

			.rx-toc__toggle {
				background: #f3f4f6;
				border: 1px solid var(--rx-toc-border);
				border-radius: 999px;
				color: var(--rx-toc-text);
				cursor: pointer;
				font-size: 13px;
				font-weight: 600;
				line-height: 1;
				padding: 8px 12px;
				transition: background 0.2s ease, border-color 0.2s ease;
			}

			.rx-toc__toggle:hover,
			.rx-toc__toggle:focus {
				background: #e5e7eb;
				border-color: #d1d5db;
				outline: none;
			}

			.rx-toc__body {
				padding: 14px 16px 16px;
			}

			.rx-toc.is-collapsed .rx-toc__body {
				display: none;
			}

			.rx-toc__list {
				margin: 0;
				padding-left: 20px;
			}

			.rx-toc__list--root {
				padding-left: 18px;
			}

			.rx-toc__list--nested {
				margin-top: 6px;
			}

			.rx-toc__item {
				margin: 6px 0;
				padding-left: 2px;
			}

			.rx-toc__item::marker {
				color: var(--rx-toc-muted);
				font-weight: 600;
			}

			.rx-toc__link {
				border-radius: 8px;
				color: var(--rx-toc-link);
				display: inline-block;
				font-size: 14px;
				line-height: 1.45;
				padding: 3px 6px;
				text-decoration: none;
				transition: background 0.2s ease, color 0.2s ease, transform 0.2s ease;
			}

			.rx-toc__link:hover,
			.rx-toc__link:focus {
				background: var(--rx-toc-active-bg);
				color: var(--rx-toc-link-hover);
				outline: none;
				text-decoration: underline;
			}

			.rx-toc__link.is-active {
				background: var(--rx-toc-active-bg);
				color: var(--rx-toc-link-hover);
				font-weight: 700;
			}

			.rx-toc__item--h3 .rx-toc__link,
			.rx-toc__item--h4 .rx-toc__link,
			.rx-toc__item--h5 .rx-toc__link,
			.rx-toc__item--h6 .rx-toc__link {
				font-size: 13.5px;
			}

			.rx-toc--empty {
				padding: 14px 16px;
			}

			.rx-toc-back-to-top {
				align-items: center;
				background: #111827;
				border-radius: 999px;
				bottom: 22px;
				color: #ffffff;
				display: none;
				font-size: 13px;
				font-weight: 700;
				height: 40px;
				justify-content: center;
				position: fixed;
				right: 22px;
				text-decoration: none;
				width: 40px;
				z-index: 999;
			}

			.rx-toc-back-to-top.is-visible {
				display: inline-flex;
			}

			html.rx-toc-smooth-scroll {
				scroll-behavior: smooth;
			}

			@media (max-width: 782px) {
				.rx-toc {
					border-radius: 12px;
					margin: 18px 0;
				}

				.rx-toc--sticky {
					position: relative;
					top: auto;
				}

				.rx-toc__header {
					padding: 12px 14px;
				}

				.rx-toc__body {
					padding: 12px 14px 14px;
				}

				.rx-toc__link {
					font-size: 14px;
				}
			}

			@media (prefers-reduced-motion: reduce) {
				html.rx-toc-smooth-scroll {
					scroll-behavior: auto;
				}

				.rx-toc__link,
				.rx-toc__toggle {
					transition: none;
				}
			}
		</style>

		<script id="rx-toc-script">
			(function () {
				'use strict';

				var tocBlocks = document.querySelectorAll('[data-rx-toc]');

				if (!tocBlocks.length) {
					return;
				}

				function closestToc(element) {
					while (element && element !== document) {
						if (element.hasAttribute && element.hasAttribute('data-rx-toc')) {
							return element;
						}
						element = element.parentNode;
					}
					return null;
				}

				function getAdminOffset() {
					var adminBar = document.getElementById('wpadminbar');
					return adminBar ? adminBar.offsetHeight : 0;
				}

				function scrollToHeading(id) {
					var target = document.getElementById(id);

					if (!target) {
						return;
					}

					var offset = getAdminOffset() + 16;
					var position = target.getBoundingClientRect().top + window.pageYOffset - offset;

					window.scrollTo({
						top: position,
						behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth'
					});

					target.setAttribute('tabindex', '-1');
					target.focus({ preventScroll: true });

					if (history.pushState) {
						history.pushState(null, '', '#' + id);
					}
				}

				tocBlocks.forEach(function (toc) {
					var smooth = toc.getAttribute('data-smooth') === 'yes';
					var active = toc.getAttribute('data-active') === 'yes';
					var toggle = toc.querySelector('.rx-toc__toggle');
					var toggleText = toc.querySelector('.rx-toc__toggle-text');
					var links = toc.querySelectorAll('.rx-toc__link');

					if (smooth) {
						document.documentElement.classList.add('rx-toc-smooth-scroll');
					}

					if (toggle) {
						toggle.addEventListener('click', function () {
							var collapsed = toc.classList.toggle('is-collapsed');
							toggle.setAttribute('aria-expanded', collapsed ? 'false' : 'true');

							if (toggleText) {
								toggleText.textContent = collapsed ? 'Show' : 'Hide';
							}
						});
					}

					links.forEach(function (link) {
						link.addEventListener('click', function (event) {
							var href = link.getAttribute('href');

							if (!href || href.charAt(0) !== '#') {
								return;
							}

							var id = href.substring(1);

							if (!id) {
								return;
							}

							if (smooth) {
								event.preventDefault();
								scrollToHeading(id);
							}
						});
					});

					if (active && 'IntersectionObserver' in window) {
						var headingIds = Array.prototype.map.call(links, function (link) {
							return link.getAttribute('data-rx-toc-link');
						}).filter(Boolean);

						var headings = headingIds.map(function (id) {
							return document.getElementById(id);
						}).filter(Boolean);

						var linkMap = {};

						links.forEach(function (link) {
							linkMap[link.getAttribute('data-rx-toc-link')] = link;
						});

						var observer = new IntersectionObserver(function (entries) {
							entries.forEach(function (entry) {
								if (!entry.isIntersecting) {
									return;
								}

								links.forEach(function (link) {
									link.classList.remove('is-active');
								});

								var activeLink = linkMap[entry.target.id];

								if (activeLink) {
									activeLink.classList.add('is-active');
								}
							});
						}, {
							rootMargin: '-20% 0px -70% 0px',
							threshold: 0
						});

						headings.forEach(function (heading) {
							observer.observe(heading);
						});
					}
				});
			})();
		</script>
		<?php
	}
}

endif;

RX_Theme_Table_Of_Contents::instance();

Add this line in your theme functions.php:

require_once get_template_directory() . '/inc/shortcodes/table-of-contents.php';

Use inside any post/page:

[rx_toc]

Advanced examples:

[rx_toc title="Article Contents" min="2" max="5" numbered="yes" sticky="yes"]
[rx_toc title="Quick Navigation" collapsible="yes" collapsed="yes" list_type="ul"]
[rx_toc exclude="FAQ,References,.no-toc" min_headings="3"]

To enable automatic TOC insertion without shortcode, add this in functions.php:

add_filter( 'rx_toc_auto_insert_enabled', '__return_true' );

To insert after the first paragraph instead of before the first heading:

add_filter( 'rx_toc_auto_insert_position', function() {
	return 'after_first_paragraph';
} );

Leave a Reply

Your email address will not be published. Required fields are marked *