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';
} );