I’ll build this as a strong standalone WordPress widget file for inc/widgets/medical-toc-widget.php, with admin options, auto heading detection, smooth scroll, schema-friendly output, accessibility, caching, styling, and shortcode support.
Below is an advanced complete file for your RX Theme:
inc/widgets/medical-toc-widget.php
It adds a powerful Medical Table of Contents Widget with:
- Auto TOC from post headings
- H2/H3/H4/H5/H6 support
- Sticky TOC option
- Collapsible TOC
- Smooth scroll
- Active section highlight
- Reading progress bar
- Copy section link button
- Estimated reading time
- Word count
- Medical article summary box
- Schema-friendly navigation
- Mobile-friendly layout
- Widget settings
- Shortcode support:
[rx_medical_toc] - Elementor/sidebar compatible
- Safe escaping and sanitization
- Lightweight inline CSS/JS
- Cache support with post modified time
- Exclude headings by text/class
- Numbered or plain TOC
- Scroll offset control for sticky headers
<?php
/**
* RX Theme - Advanced Medical Table of Contents Widget
*
* File: inc/widgets/medical-toc-widget.php
*
* @package RX_Theme
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
if ( ! class_exists( 'RX_Medical_TOC_Widget' ) ) :
class RX_Medical_TOC_Widget extends WP_Widget {
/**
* Widget defaults.
*/
protected $defaults = array(
'title' => 'Article Contents',
'intro_text' => 'Quickly jump to the important medical sections of this article.',
'show_on_post_types' => 'post,page',
'heading_levels' => 'h2,h3,h4',
'min_headings' => 3,
'exclude_headings' => '',
'exclude_classes' => 'no-toc,rx-no-toc,screen-reader-text',
'numbered' => 1,
'nested' => 1,
'collapsible' => 1,
'collapsed_default' => 0,
'sticky' => 1,
'smooth_scroll' => 1,
'active_highlight' => 1,
'show_reading_time' => 1,
'show_word_count' => 1,
'show_progress' => 1,
'show_copy_links' => 1,
'show_summary' => 1,
'scroll_offset' => 90,
'max_width' => '',
'accent_color' => '#0b7d77',
'background_color' => '#ffffff',
'border_color' => '#dfe9e7',
'text_color' => '#1f2937',
'cache_enabled' => 1,
'custom_class' => '',
);
/**
* Constructor.
*/
public function __construct() {
parent::__construct(
'rx_medical_toc_widget',
esc_html__( 'RX Medical Table of Contents', 'rx-theme' ),
array(
'classname' => 'rx_medical_toc_widget',
'description' => esc_html__( 'Advanced medical article table of contents with sticky, collapsible, progress, reading time, and active heading highlight.', 'rx-theme' ),
'customize_selective_refresh' => true,
)
);
add_filter( 'the_content', array( $this, 'inject_heading_ids_into_content' ), 20 );
add_shortcode( 'rx_medical_toc', array( $this, 'shortcode_output' ) );
add_action( 'wp_enqueue_scripts', array( $this, 'register_assets' ) );
}
/**
* Register empty handles for inline CSS/JS.
*/
public function register_assets() {
wp_register_style( 'rx-medical-toc-widget', false, array(), '1.0.0' );
wp_register_script( 'rx-medical-toc-widget', false, array(), '1.0.0', true );
}
/**
* Widget frontend output.
*/
public function widget( $args, $instance ) {
if ( ! is_singular() ) {
return;
}
$instance = wp_parse_args( (array) $instance, $this->defaults );
if ( ! $this->is_allowed_post_type( $instance ) ) {
return;
}
$output = $this->build_toc_output( $instance, 'widget' );
if ( empty( $output ) ) {
return;
}
echo isset( $args['before_widget'] ) ? $args['before_widget'] : ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo $output; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo isset( $args['after_widget'] ) ? $args['after_widget'] : ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
/**
* Widget backend form.
*/
public function form( $instance ) {
$instance = wp_parse_args( (array) $instance, $this->defaults );
?>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'title' ) ); ?>">
<?php esc_html_e( 'Title:', 'rx-theme' ); ?>
</label>
<input class="widefat"
id="<?php echo esc_attr( $this->get_field_id( 'title' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'title' ) ); ?>"
type="text"
value="<?php echo esc_attr( $instance['title'] ); ?>">
</p>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'intro_text' ) ); ?>">
<?php esc_html_e( 'Intro Text:', 'rx-theme' ); ?>
</label>
<textarea class="widefat"
id="<?php echo esc_attr( $this->get_field_id( 'intro_text' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'intro_text' ) ); ?>"
rows="3"><?php echo esc_textarea( $instance['intro_text'] ); ?></textarea>
</p>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'show_on_post_types' ) ); ?>">
<?php esc_html_e( 'Show on post types:', 'rx-theme' ); ?>
</label>
<input class="widefat"
id="<?php echo esc_attr( $this->get_field_id( 'show_on_post_types' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'show_on_post_types' ) ); ?>"
type="text"
value="<?php echo esc_attr( $instance['show_on_post_types'] ); ?>">
<small><?php esc_html_e( 'Comma separated. Example: post,page,rx_medical', 'rx-theme' ); ?></small>
</p>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'heading_levels' ) ); ?>">
<?php esc_html_e( 'Heading levels:', 'rx-theme' ); ?>
</label>
<input class="widefat"
id="<?php echo esc_attr( $this->get_field_id( 'heading_levels' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'heading_levels' ) ); ?>"
type="text"
value="<?php echo esc_attr( $instance['heading_levels'] ); ?>">
<small><?php esc_html_e( 'Example: h2,h3,h4', 'rx-theme' ); ?></small>
</p>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'min_headings' ) ); ?>">
<?php esc_html_e( 'Minimum headings required:', 'rx-theme' ); ?>
</label>
<input class="small-text"
id="<?php echo esc_attr( $this->get_field_id( 'min_headings' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'min_headings' ) ); ?>"
type="number"
min="1"
value="<?php echo esc_attr( absint( $instance['min_headings'] ) ); ?>">
</p>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'exclude_headings' ) ); ?>">
<?php esc_html_e( 'Exclude headings containing:', 'rx-theme' ); ?>
</label>
<textarea class="widefat"
id="<?php echo esc_attr( $this->get_field_id( 'exclude_headings' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'exclude_headings' ) ); ?>"
rows="3"><?php echo esc_textarea( $instance['exclude_headings'] ); ?></textarea>
<small><?php esc_html_e( 'Comma separated words or phrases. Example: References, Disclaimer, Related Posts', 'rx-theme' ); ?></small>
</p>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'exclude_classes' ) ); ?>">
<?php esc_html_e( 'Exclude heading classes:', 'rx-theme' ); ?>
</label>
<input class="widefat"
id="<?php echo esc_attr( $this->get_field_id( 'exclude_classes' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'exclude_classes' ) ); ?>"
type="text"
value="<?php echo esc_attr( $instance['exclude_classes'] ); ?>">
</p>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'scroll_offset' ) ); ?>">
<?php esc_html_e( 'Scroll offset in px:', 'rx-theme' ); ?>
</label>
<input class="small-text"
id="<?php echo esc_attr( $this->get_field_id( 'scroll_offset' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'scroll_offset' ) ); ?>"
type="number"
min="0"
value="<?php echo esc_attr( absint( $instance['scroll_offset'] ) ); ?>">
</p>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'accent_color' ) ); ?>">
<?php esc_html_e( 'Accent color:', 'rx-theme' ); ?>
</label>
<input class="widefat"
id="<?php echo esc_attr( $this->get_field_id( 'accent_color' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'accent_color' ) ); ?>"
type="text"
value="<?php echo esc_attr( $instance['accent_color'] ); ?>">
</p>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'background_color' ) ); ?>">
<?php esc_html_e( 'Background color:', 'rx-theme' ); ?>
</label>
<input class="widefat"
id="<?php echo esc_attr( $this->get_field_id( 'background_color' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'background_color' ) ); ?>"
type="text"
value="<?php echo esc_attr( $instance['background_color'] ); ?>">
</p>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'border_color' ) ); ?>">
<?php esc_html_e( 'Border color:', 'rx-theme' ); ?>
</label>
<input class="widefat"
id="<?php echo esc_attr( $this->get_field_id( 'border_color' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'border_color' ) ); ?>"
type="text"
value="<?php echo esc_attr( $instance['border_color'] ); ?>">
</p>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'text_color' ) ); ?>">
<?php esc_html_e( 'Text color:', 'rx-theme' ); ?>
</label>
<input class="widefat"
id="<?php echo esc_attr( $this->get_field_id( 'text_color' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'text_color' ) ); ?>"
type="text"
value="<?php echo esc_attr( $instance['text_color'] ); ?>">
</p>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'custom_class' ) ); ?>">
<?php esc_html_e( 'Custom CSS class:', 'rx-theme' ); ?>
</label>
<input class="widefat"
id="<?php echo esc_attr( $this->get_field_id( 'custom_class' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'custom_class' ) ); ?>"
type="text"
value="<?php echo esc_attr( $instance['custom_class'] ); ?>">
</p>
<hr>
<?php
$this->checkbox_field( $instance, 'numbered', __( 'Show numbered TOC', 'rx-theme' ) );
$this->checkbox_field( $instance, 'nested', __( 'Use nested heading structure', 'rx-theme' ) );
$this->checkbox_field( $instance, 'collapsible', __( 'Enable collapse button', 'rx-theme' ) );
$this->checkbox_field( $instance, 'collapsed_default', __( 'Collapsed by default', 'rx-theme' ) );
$this->checkbox_field( $instance, 'sticky', __( 'Sticky TOC on desktop', 'rx-theme' ) );
$this->checkbox_field( $instance, 'smooth_scroll', __( 'Enable smooth scroll', 'rx-theme' ) );
$this->checkbox_field( $instance, 'active_highlight', __( 'Highlight active section', 'rx-theme' ) );
$this->checkbox_field( $instance, 'show_reading_time', __( 'Show reading time', 'rx-theme' ) );
$this->checkbox_field( $instance, 'show_word_count', __( 'Show word count', 'rx-theme' ) );
$this->checkbox_field( $instance, 'show_progress', __( 'Show reading progress bar', 'rx-theme' ) );
$this->checkbox_field( $instance, 'show_copy_links', __( 'Show copy section link buttons', 'rx-theme' ) );
$this->checkbox_field( $instance, 'show_summary', __( 'Show medical summary box', 'rx-theme' ) );
$this->checkbox_field( $instance, 'cache_enabled', __( 'Enable TOC cache', 'rx-theme' ) );
}
/**
* Checkbox helper.
*/
protected function checkbox_field( $instance, $key, $label ) {
?>
<p>
<input class="checkbox"
type="checkbox"
<?php checked( ! empty( $instance[ $key ] ) ); ?>
id="<?php echo esc_attr( $this->get_field_id( $key ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( $key ) ); ?>"
value="1">
<label for="<?php echo esc_attr( $this->get_field_id( $key ) ); ?>">
<?php echo esc_html( $label ); ?>
</label>
</p>
<?php
}
/**
* Save widget options.
*/
public function update( $new_instance, $old_instance ) {
$instance = array();
$text_fields = array(
'title',
'intro_text',
'show_on_post_types',
'heading_levels',
'exclude_headings',
'exclude_classes',
'custom_class',
);
foreach ( $text_fields as $field ) {
$instance[ $field ] = isset( $new_instance[ $field ] ) ? sanitize_text_field( $new_instance[ $field ] ) : $this->defaults[ $field ];
}
$color_fields = array(
'accent_color',
'background_color',
'border_color',
'text_color',
);
foreach ( $color_fields as $field ) {
$instance[ $field ] = isset( $new_instance[ $field ] ) ? sanitize_hex_color( $new_instance[ $field ] ) : $this->defaults[ $field ];
if ( empty( $instance[ $field ] ) ) {
$instance[ $field ] = $this->defaults[ $field ];
}
}
$instance['min_headings'] = isset( $new_instance['min_headings'] ) ? max( 1, absint( $new_instance['min_headings'] ) ) : 3;
$instance['scroll_offset'] = isset( $new_instance['scroll_offset'] ) ? max( 0, absint( $new_instance['scroll_offset'] ) ) : 90;
$checkboxes = array(
'numbered',
'nested',
'collapsible',
'collapsed_default',
'sticky',
'smooth_scroll',
'active_highlight',
'show_reading_time',
'show_word_count',
'show_progress',
'show_copy_links',
'show_summary',
'cache_enabled',
);
foreach ( $checkboxes as $field ) {
$instance[ $field ] = ! empty( $new_instance[ $field ] ) ? 1 : 0;
}
$this->clear_post_cache();
return $instance;
}
/**
* Shortcode output.
*
* Usage:
* [rx_medical_toc]
* [rx_medical_toc title="Contents" levels="h2,h3" sticky="0"]
*/
public function shortcode_output( $atts ) {
if ( ! is_singular() ) {
return '';
}
$atts = shortcode_atts(
array(
'title' => $this->defaults['title'],
'intro' => $this->defaults['intro_text'],
'levels' => $this->defaults['heading_levels'],
'min' => $this->defaults['min_headings'],
'numbered' => $this->defaults['numbered'],
'nested' => $this->defaults['nested'],
'collapsible' => $this->defaults['collapsible'],
'collapsed' => $this->defaults['collapsed_default'],
'sticky' => 0,
'reading_time' => $this->defaults['show_reading_time'],
'word_count' => $this->defaults['show_word_count'],
'progress' => $this->defaults['show_progress'],
'copy_links' => $this->defaults['show_copy_links'],
'summary' => $this->defaults['show_summary'],
'offset' => $this->defaults['scroll_offset'],
'accent' => $this->defaults['accent_color'],
'background' => $this->defaults['background_color'],
'border' => $this->defaults['border_color'],
'text' => $this->defaults['text_color'],
'class' => '',
),
$atts,
'rx_medical_toc'
);
$instance = wp_parse_args(
array(
'title' => sanitize_text_field( $atts['title'] ),
'intro_text' => sanitize_text_field( $atts['intro'] ),
'heading_levels' => sanitize_text_field( $atts['levels'] ),
'min_headings' => absint( $atts['min'] ),
'numbered' => absint( $atts['numbered'] ),
'nested' => absint( $atts['nested'] ),
'collapsible' => absint( $atts['collapsible'] ),
'collapsed_default' => absint( $atts['collapsed'] ),
'sticky' => absint( $atts['sticky'] ),
'show_reading_time' => absint( $atts['reading_time'] ),
'show_word_count' => absint( $atts['word_count'] ),
'show_progress' => absint( $atts['progress'] ),
'show_copy_links' => absint( $atts['copy_links'] ),
'show_summary' => absint( $atts['summary'] ),
'scroll_offset' => absint( $atts['offset'] ),
'accent_color' => sanitize_hex_color( $atts['accent'] ),
'background_color' => sanitize_hex_color( $atts['background'] ),
'border_color' => sanitize_hex_color( $atts['border'] ),
'text_color' => sanitize_hex_color( $atts['text'] ),
'custom_class' => sanitize_html_class( $atts['class'] ),
'cache_enabled' => 1,
),
$this->defaults
);
return $this->build_toc_output( $instance, 'shortcode' );
}
/**
* Check allowed post type.
*/
protected function is_allowed_post_type( $instance ) {
$post_type = get_post_type();
if ( ! $post_type ) {
return false;
}
$allowed = $this->csv_to_array( $instance['show_on_post_types'] );
return in_array( $post_type, $allowed, true );
}
/**
* Build TOC HTML.
*/
protected function build_toc_output( $instance, $context = 'widget' ) {
global $post;
if ( empty( $post ) || empty( $post->post_content ) ) {
return '';
}
$instance = wp_parse_args( (array) $instance, $this->defaults );
$cache_key = 'rx_medical_toc_' . $post->ID . '_' . md5( wp_json_encode( $instance ) . get_post_modified_time( 'U', true, $post ) );
if ( ! empty( $instance['cache_enabled'] ) ) {
$cached = get_transient( $cache_key );
if ( false !== $cached ) {
$this->enqueue_inline_assets( $instance );
return $cached;
}
}
$headings = $this->extract_headings( $post->post_content, $instance );
if ( count( $headings ) < absint( $instance['min_headings'] ) ) {
return '';
}
$reading_time = $this->get_reading_time( $post->post_content );
$word_count = $this->get_word_count( $post->post_content );
$uid = 'rx-medical-toc-' . absint( $post->ID ) . '-' . wp_rand( 1000, 9999 );
$classes = array(
'rx-medical-toc',
'rx-medical-toc-context-' . sanitize_html_class( $context ),
);
if ( ! empty( $instance['sticky'] ) ) {
$classes[] = 'rx-medical-toc-sticky';
}
if ( ! empty( $instance['collapsed_default'] ) ) {
$classes[] = 'rx-medical-toc-collapsed';
}
if ( ! empty( $instance['custom_class'] ) ) {
$classes[] = sanitize_html_class( $instance['custom_class'] );
}
$style_vars = sprintf(
'--rx-toc-accent:%1$s;--rx-toc-bg:%2$s;--rx-toc-border:%3$s;--rx-toc-text:%4$s;--rx-toc-offset:%5$dpx;',
esc_attr( $instance['accent_color'] ),
esc_attr( $instance['background_color'] ),
esc_attr( $instance['border_color'] ),
esc_attr( $instance['text_color'] ),
absint( $instance['scroll_offset'] )
);
ob_start();
?>
<nav id="<?php echo esc_attr( $uid ); ?>"
class="<?php echo esc_attr( implode( ' ', $classes ) ); ?>"
style="<?php echo esc_attr( $style_vars ); ?>"
aria-label="<?php echo esc_attr__( 'Medical article table of contents', 'rx-theme' ); ?>"
data-rx-toc
data-offset="<?php echo esc_attr( absint( $instance['scroll_offset'] ) ); ?>"
data-smooth="<?php echo esc_attr( ! empty( $instance['smooth_scroll'] ) ? '1' : '0' ); ?>"
data-active="<?php echo esc_attr( ! empty( $instance['active_highlight'] ) ? '1' : '0' ); ?>">
<?php if ( ! empty( $instance['show_progress'] ) ) : ?>
<div class="rx-toc-progress-wrap" aria-hidden="true">
<span class="rx-toc-progress-bar"></span>
</div>
<?php endif; ?>
<div class="rx-toc-header">
<div class="rx-toc-title-wrap">
<?php if ( ! empty( $instance['title'] ) ) : ?>
<strong class="rx-toc-title"><?php echo esc_html( $instance['title'] ); ?></strong>
<?php endif; ?>
<?php if ( ! empty( $instance['intro_text'] ) ) : ?>
<p class="rx-toc-intro"><?php echo esc_html( $instance['intro_text'] ); ?></p>
<?php endif; ?>
</div>
<?php if ( ! empty( $instance['collapsible'] ) ) : ?>
<button type="button"
class="rx-toc-toggle"
aria-expanded="<?php echo empty( $instance['collapsed_default'] ) ? 'true' : 'false'; ?>"
aria-controls="<?php echo esc_attr( $uid ); ?>-body">
<span class="rx-toc-toggle-open"><?php esc_html_e( 'Hide', 'rx-theme' ); ?></span>
<span class="rx-toc-toggle-close"><?php esc_html_e( 'Show', 'rx-theme' ); ?></span>
</button>
<?php endif; ?>
</div>
<?php if ( ! empty( $instance['show_reading_time'] ) || ! empty( $instance['show_word_count'] ) || ! empty( $instance['show_summary'] ) ) : ?>
<div class="rx-toc-meta">
<?php if ( ! empty( $instance['show_reading_time'] ) ) : ?>
<span class="rx-toc-meta-item">
<?php echo esc_html( sprintf( _n( '%s min read', '%s min read', $reading_time, 'rx-theme' ), number_format_i18n( $reading_time ) ) ); ?>
</span>
<?php endif; ?>
<?php if ( ! empty( $instance['show_word_count'] ) ) : ?>
<span class="rx-toc-meta-item">
<?php echo esc_html( sprintf( __( '%s words', 'rx-theme' ), number_format_i18n( $word_count ) ) ); ?>
</span>
<?php endif; ?>
<span class="rx-toc-meta-item">
<?php echo esc_html( sprintf( _n( '%s section', '%s sections', count( $headings ), 'rx-theme' ), number_format_i18n( count( $headings ) ) ) ); ?>
</span>
</div>
<?php endif; ?>
<?php if ( ! empty( $instance['show_summary'] ) ) : ?>
<div class="rx-toc-summary">
<?php
echo esc_html(
sprintf(
__( 'This medical guide is organized into %1$s main sections for easier reading and faster navigation.', 'rx-theme' ),
number_format_i18n( count( $headings ) )
)
);
?>
</div>
<?php endif; ?>
<div id="<?php echo esc_attr( $uid ); ?>-body" class="rx-toc-body">
<?php echo $this->render_toc_list( $headings, $instance ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</div>
</nav>
<?php
$html = ob_get_clean();
if ( ! empty( $instance['cache_enabled'] ) ) {
set_transient( $cache_key, $html, DAY_IN_SECONDS );
}
$this->enqueue_inline_assets( $instance );
return $html;
}
/**
* Extract headings from post content.
*/
protected function extract_headings( $content, $instance ) {
$levels = $this->normalize_heading_levels( $instance['heading_levels'] );
$exclude_texts = $this->csv_to_array( $instance['exclude_headings'] );
$exclude_classes = $this->csv_to_array( $instance['exclude_classes'] );
if ( empty( $levels ) ) {
$levels = array( 'h2', 'h3', 'h4' );
}
$pattern = '/<(' . implode( '|', array_map( 'preg_quote', $levels ) ) . ')([^>]*)>(.*?)<\/\1>/is';
preg_match_all( $pattern, $content, $matches, PREG_SET_ORDER );
if ( empty( $matches ) ) {
return array();
}
$headings = array();
foreach ( $matches as $match ) {
$tag = strtolower( $match[1] );
$attributes = $match[2];
$html = $match[3];
$text = trim( wp_strip_all_tags( $html ) );
if ( empty( $text ) ) {
continue;
}
if ( $this->heading_should_be_excluded( $text, $attributes, $exclude_texts, $exclude_classes ) ) {
continue;
}
$id = $this->extract_id_from_attributes( $attributes );
if ( empty( $id ) ) {
$id = $this->generate_heading_id( $text );
}
$headings[] = array(
'tag' => $tag,
'level' => absint( str_replace( 'h', '', $tag ) ),
'id' => sanitize_html_class( $id ),
'text' => $text,
);
}
return $this->make_unique_heading_ids( $headings );
}
/**
* Add IDs to frontend headings.
*/
public function inject_heading_ids_into_content( $content ) {
if ( is_admin() || ! is_singular() || empty( $content ) ) {
return $content;
}
$instance = $this->defaults;
$levels = $this->normalize_heading_levels( $instance['heading_levels'] );
$pattern = '/<(' . implode( '|', array_map( 'preg_quote', $levels ) ) . ')([^>]*)>(.*?)<\/\1>/is';
$used = array();
$content = preg_replace_callback(
$pattern,
function( $matches ) use ( &$used ) {
$tag = strtolower( $matches[1] );
$attributes = $matches[2];
$inner_html = $matches[3];
$text = trim( wp_strip_all_tags( $inner_html ) );
if ( empty( $text ) ) {
return $matches[0];
}
if ( preg_match( '/\sid=["\']([^"\']+)["\']/i', $attributes ) ) {
return $matches[0];
}
$id = $this->generate_heading_id( $text );
if ( isset( $used[ $id ] ) ) {
$used[ $id ]++;
$id = $id . '-' . $used[ $id ];
} else {
$used[ $id ] = 1;
}
$copy_button = '';
/**
* Filter: rx_medical_toc_add_heading_anchor_button
*/
if ( apply_filters( 'rx_medical_toc_add_heading_anchor_button', true ) ) {
$copy_button = sprintf(
' <button class="rx-heading-copy-link" type="button" data-copy-link="#%1$s" aria-label="%2$s">#</button>',
esc_attr( $id ),
esc_attr__( 'Copy link to this section', 'rx-theme' )
);
}
return sprintf(
'<%1$s id="%2$s"%3$s>%4$s%5$s</%1$s>',
tag_escape( $tag ),
esc_attr( $id ),
$attributes, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
$inner_html, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
$copy_button // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
);
},
$content
);
return $content;
}
/**
* Render TOC list.
*/
protected function render_toc_list( $headings, $instance ) {
if ( empty( $headings ) ) {
return '';
}
if ( empty( $instance['nested'] ) ) {
return $this->render_flat_toc_list( $headings, $instance );
}
return $this->render_nested_toc_list( $headings, $instance );
}
/**
* Render flat TOC.
*/
protected function render_flat_toc_list( $headings, $instance ) {
$list_tag = ! empty( $instance['numbered'] ) ? 'ol' : 'ul';
ob_start();
?>
<<?php echo tag_escape( $list_tag ); ?> class="rx-toc-list rx-toc-list-flat">
<?php foreach ( $headings as $index => $heading ) : ?>
<li class="rx-toc-item rx-toc-level-<?php echo esc_attr( $heading['level'] ); ?>">
<a class="rx-toc-link" href="#<?php echo esc_attr( $heading['id'] ); ?>" data-target="<?php echo esc_attr( $heading['id'] ); ?>">
<?php if ( ! empty( $instance['numbered'] ) ) : ?>
<span class="rx-toc-number"><?php echo esc_html( $index + 1 ); ?>.</span>
<?php endif; ?>
<span class="rx-toc-text"><?php echo esc_html( $heading['text'] ); ?></span>
</a>
</li>
<?php endforeach; ?>
</<?php echo tag_escape( $list_tag ); ?>>
<?php
return ob_get_clean();
}
/**
* Render nested TOC.
*/
protected function render_nested_toc_list( $headings, $instance ) {
$list_tag = ! empty( $instance['numbered'] ) ? 'ol' : 'ul';
$output = '';
$current_level = 0;
$counters = array();
foreach ( $headings as $heading ) {
$level = absint( $heading['level'] );
if ( 0 === $current_level ) {
$output .= '<' . tag_escape( $list_tag ) . ' class="rx-toc-list rx-toc-list-nested">';
$current_level = $level;
}
while ( $level > $current_level ) {
$output .= '<' . tag_escape( $list_tag ) . ' class="rx-toc-sub-list">';
$current_level++;
}
while ( $level < $current_level ) {
$output .= '</li></' . tag_escape( $list_tag ) . '>';
$current_level--;
}
if ( isset( $counters[ $level ] ) ) {
$counters[ $level ]++;
} else {
$counters[ $level ] = 1;
}
foreach ( array_keys( $counters ) as $counter_level ) {
if ( $counter_level > $level ) {
unset( $counters[ $counter_level ] );
}
}
$number = implode( '.', array_values( $counters ) );
$output .= '<li class="rx-toc-item rx-toc-level-' . esc_attr( $level ) . '">';
$output .= '<a class="rx-toc-link" href="#' . esc_attr( $heading['id'] ) . '" data-target="' . esc_attr( $heading['id'] ) . '">';
if ( ! empty( $instance['numbered'] ) ) {
$output .= '<span class="rx-toc-number">' . esc_html( $number ) . '.</span>';
}
$output .= '<span class="rx-toc-text">' . esc_html( $heading['text'] ) . '</span>';
$output .= '</a>';
}
while ( $current_level > 0 ) {
$output .= '</li></' . tag_escape( $list_tag ) . '>';
$current_level--;
}
return $output;
}
/**
* Enqueue inline CSS and JS.
*/
protected function enqueue_inline_assets( $instance ) {
wp_enqueue_style( 'rx-medical-toc-widget' );
wp_enqueue_script( 'rx-medical-toc-widget' );
$css = $this->get_inline_css();
$js = $this->get_inline_js();
wp_add_inline_style( 'rx-medical-toc-widget', $css );
wp_add_inline_script( 'rx-medical-toc-widget', $js );
}
/**
* Inline CSS.
*/
protected function get_inline_css() {
return '
.rx-medical-toc {
position: relative;
margin: 24px 0;
padding: 18px;
color: var(--rx-toc-text);
background: var(--rx-toc-bg);
border: 1px solid var(--rx-toc-border);
border-radius: 16px;
box-shadow: 0 8px 24px rgba(15, 23, 42, .06);
overflow: hidden;
}
.rx-medical-toc-sticky {
position: sticky;
top: var(--rx-toc-offset);
z-index: 20;
}
.rx-toc-progress-wrap {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: rgba(15, 23, 42, .08);
}
.rx-toc-progress-bar {
display: block;
width: 0%;
height: 100%;
background: var(--rx-toc-accent);
transition: width .15s linear;
}
.rx-toc-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.rx-toc-title {
display: block;
font-size: 18px;
line-height: 1.3;
color: var(--rx-toc-text);
}
.rx-toc-intro {
margin: 6px 0 0;
font-size: 14px;
line-height: 1.55;
opacity: .82;
}
.rx-toc-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 64px;
padding: 7px 10px;
font-size: 13px;
font-weight: 600;
color: var(--rx-toc-accent);
background: rgba(11, 125, 119, .08);
border: 1px solid rgba(11, 125, 119, .18);
border-radius: 999px;
cursor: pointer;
}
.rx-toc-toggle:hover,
.rx-toc-toggle:focus {
outline: none;
background: rgba(11, 125, 119, .14);
}
.rx-medical-toc-collapsed .rx-toc-body,
.rx-medical-toc-collapsed .rx-toc-summary,
.rx-medical-toc-collapsed .rx-toc-meta {
display: none;
}
.rx-medical-toc .rx-toc-toggle-close {
display: none;
}
.rx-medical-toc-collapsed .rx-toc-toggle-open {
display: none;
}
.rx-medical-toc-collapsed .rx-toc-toggle-close {
display: inline;
}
.rx-toc-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 0 0 12px;
}
.rx-toc-meta-item {
display: inline-flex;
align-items: center;
padding: 5px 9px;
font-size: 12px;
line-height: 1;
border-radius: 999px;
background: rgba(15, 23, 42, .06);
}
.rx-toc-summary {
margin: 0 0 14px;
padding: 10px 12px;
font-size: 14px;
line-height: 1.55;
background: rgba(11, 125, 119, .06);
border-left: 4px solid var(--rx-toc-accent);
border-radius: 10px;
}
.rx-toc-list,
.rx-toc-sub-list {
margin: 0;
padding-left: 20px;
}
.rx-toc-list-flat {
padding-left: 0;
list-style: none;
}
.rx-toc-item {
margin: 7px 0;
line-height: 1.45;
}
.rx-toc-list-flat .rx-toc-item {
list-style: none;
}
.rx-toc-link {
display: flex;
gap: 7px;
align-items: flex-start;
padding: 6px 8px;
color: var(--rx-toc-text);
text-decoration: none;
border-radius: 10px;
transition: background .15s ease, color .15s ease, transform .15s ease;
}
.rx-toc-link:hover,
.rx-toc-link:focus {
color: var(--rx-toc-accent);
background: rgba(11, 125, 119, .08);
outline: none;
transform: translateX(2px);
}
.rx-toc-link.is-active {
color: var(--rx-toc-accent);
background: rgba(11, 125, 119, .12);
font-weight: 700;
}
.rx-toc-number {
flex: 0 0 auto;
font-weight: 700;
color: var(--rx-toc-accent);
}
.rx-toc-text {
flex: 1 1 auto;
}
.rx-toc-level-3 .rx-toc-link {
font-size: 14px;
opacity: .92;
}
.rx-toc-level-4 .rx-toc-link,
.rx-toc-level-5 .rx-toc-link,
.rx-toc-level-6 .rx-toc-link {
font-size: 13px;
opacity: .86;
}
.rx-heading-copy-link {
display: inline-flex;
align-items: center;
justify-content: center;
margin-left: 8px;
width: 24px;
height: 24px;
font-size: 13px;
line-height: 1;
color: var(--rx-toc-accent, #0b7d77);
background: rgba(11, 125, 119, .08);
border: 1px solid rgba(11, 125, 119, .18);
border-radius: 999px;
cursor: pointer;
vertical-align: middle;
opacity: .65;
}
.rx-heading-copy-link:hover,
.rx-heading-copy-link:focus {
opacity: 1;
outline: none;
}
html {
scroll-behavior: smooth;
}
@media (max-width: 782px) {
.rx-medical-toc,
.rx-medical-toc-sticky {
position: relative;
top: auto;
border-radius: 14px;
padding: 15px;
}
.rx-toc-header {
align-items: center;
}
.rx-toc-title {
font-size: 16px;
}
.rx-toc-link {
padding: 8px;
}
}
@media print {
.rx-medical-toc {
box-shadow: none;
break-inside: avoid;
}
.rx-toc-toggle,
.rx-heading-copy-link,
.rx-toc-progress-wrap {
display: none !important;
}
}
';
}
/**
* Inline JS.
*/
protected function get_inline_js() {
return '
(function() {
"use strict";
function ready(fn) {
if (document.readyState !== "loading") {
fn();
} else {
document.addEventListener("DOMContentLoaded", fn);
}
}
function copyToClipboard(text) {
if (navigator.clipboard && window.isSecureContext) {
return navigator.clipboard.writeText(text);
}
var textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.left = "-9999px";
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
try {
document.execCommand("copy");
} catch (e) {}
document.body.removeChild(textarea);
return Promise.resolve();
}
ready(function() {
var tocBoxes = document.querySelectorAll("[data-rx-toc]");
var links = [];
tocBoxes.forEach(function(toc) {
var offset = parseInt(toc.getAttribute("data-offset") || "90", 10);
var smooth = toc.getAttribute("data-smooth") === "1";
var active = toc.getAttribute("data-active") === "1";
var progress = toc.querySelector(".rx-toc-progress-bar");
var toggle = toc.querySelector(".rx-toc-toggle");
if (toggle) {
toggle.addEventListener("click", function() {
var collapsed = toc.classList.toggle("rx-medical-toc-collapsed");
toggle.setAttribute("aria-expanded", collapsed ? "false" : "true");
});
}
toc.querySelectorAll(".rx-toc-link").forEach(function(link) {
links.push(link);
link.addEventListener("click", function(e) {
var id = link.getAttribute("data-target");
var target = id ? document.getElementById(id) : null;
if (!target) {
return;
}
e.preventDefault();
var top = target.getBoundingClientRect().top + window.pageYOffset - offset;
window.scrollTo({
top: top,
behavior: smooth ? "smooth" : "auto"
});
if (history.pushState) {
history.pushState(null, "", "#" + id);
} else {
window.location.hash = id;
}
});
});
function updateProgress() {
if (!progress) {
return;
}
var doc = document.documentElement;
var scrollTop = doc.scrollTop || document.body.scrollTop;
var scrollHeight = doc.scrollHeight - doc.clientHeight;
var percent = scrollHeight > 0 ? (scrollTop / scrollHeight) * 100 : 0;
progress.style.width = Math.min(100, Math.max(0, percent)) + "%";
}
function updateActive() {
if (!active) {
return;
}
var currentId = "";
var headings = [];
toc.querySelectorAll(".rx-toc-link").forEach(function(link) {
var id = link.getAttribute("data-target");
var heading = id ? document.getElementById(id) : null;
if (heading) {
headings.push(heading);
}
});
headings.forEach(function(heading) {
var top = heading.getBoundingClientRect().top;
if (top <= offset + 20) {
currentId = heading.id;
}
});
toc.querySelectorAll(".rx-toc-link").forEach(function(link) {
link.classList.toggle("is-active", link.getAttribute("data-target") === currentId);
});
}
window.addEventListener("scroll", function() {
updateProgress();
updateActive();
}, { passive: true });
window.addEventListener("resize", function() {
updateProgress();
updateActive();
});
updateProgress();
updateActive();
});
document.querySelectorAll(".rx-heading-copy-link").forEach(function(button) {
button.addEventListener("click", function() {
var hash = button.getAttribute("data-copy-link");
if (!hash) {
return;
}
var url = window.location.origin + window.location.pathname + hash;
copyToClipboard(url).then(function() {
var original = button.textContent;
button.textContent = "✓";
setTimeout(function() {
button.textContent = original;
}, 1300);
});
});
});
});
})();
';
}
/**
* Generate heading ID.
*/
protected function generate_heading_id( $text ) {
$id = sanitize_title( $text );
if ( empty( $id ) ) {
$id = 'rx-section-' . wp_rand( 1000, 9999 );
}
return $id;
}
/**
* Make heading IDs unique.
*/
protected function make_unique_heading_ids( $headings ) {
$used = array();
foreach ( $headings as $index => $heading ) {
$id = $heading['id'];
if ( isset( $used[ $id ] ) ) {
$used[ $id ]++;
$headings[ $index ]['id'] = $id . '-' . $used[ $id ];
} else {
$used[ $id ] = 1;
}
}
return $headings;
}
/**
* Extract ID from heading attributes.
*/
protected function extract_id_from_attributes( $attributes ) {
if ( preg_match( '/\sid=["\']([^"\']+)["\']/i', $attributes, $matches ) ) {
return sanitize_html_class( $matches[1] );
}
return '';
}
/**
* Check excluded heading.
*/
protected function heading_should_be_excluded( $text, $attributes, $exclude_texts, $exclude_classes ) {
foreach ( $exclude_texts as $exclude ) {
if ( '' !== $exclude && false !== stripos( $text, $exclude ) ) {
return true;
}
}
if ( ! empty( $exclude_classes ) && preg_match( '/\sclass=["\']([^"\']+)["\']/i', $attributes, $matches ) ) {
$classes = preg_split( '/\s+/', $matches[1] );
foreach ( $classes as $class ) {
if ( in_array( $class, $exclude_classes, true ) ) {
return true;
}
}
}
return false;
}
/**
* Normalize heading levels.
*/
protected function normalize_heading_levels( $levels ) {
$levels = $this->csv_to_array( $levels );
$valid = array();
foreach ( $levels as $level ) {
$level = strtolower( trim( $level ) );
if ( preg_match( '/^h[2-6]$/', $level ) ) {
$valid[] = $level;
}
}
return array_values( array_unique( $valid ) );
}
/**
* CSV to clean array.
*/
protected function csv_to_array( $value ) {
if ( empty( $value ) ) {
return array();
}
$items = explode( ',', $value );
$items = array_map( 'trim', $items );
$items = array_filter( $items, 'strlen' );
return array_values( array_unique( $items ) );
}
/**
* Get word count.
*/
protected function get_word_count( $content ) {
$text = wp_strip_all_tags( strip_shortcodes( $content ) );
return str_word_count( $text );
}
/**
* Get reading time.
*/
protected function get_reading_time( $content ) {
$word_count = $this->get_word_count( $content );
$minutes = ceil( $word_count / 220 );
return max( 1, absint( $minutes ) );
}
/**
* Clear cache for current site.
*/
protected function clear_post_cache() {
global $wpdb;
if ( ! $wpdb ) {
return;
}
$wpdb->query(
"DELETE FROM {$wpdb->options}
WHERE option_name LIKE '_transient_rx_medical_toc_%'
OR option_name LIKE '_transient_timeout_rx_medical_toc_%'"
);
}
}
endif;
/**
* Register widget.
*/
if ( ! function_exists( 'rx_register_medical_toc_widget' ) ) {
function rx_register_medical_toc_widget() {
register_widget( 'RX_Medical_TOC_Widget' );
}
}
add_action( 'widgets_init', 'rx_register_medical_toc_widget' );
Now include this file in your theme functions.php:
/**
* RX Theme Widgets.
*/
require_once get_template_directory() . '/inc/widgets/medical-toc-widget.php';
Use shortcode anywhere in post/page:
[rx_medical_toc]
Advanced shortcode example:
[rx_medical_toc title="Medical Article Contents" levels="h2,h3,h4" numbered="1" sticky="0" progress="1" copy_links="1" summary="1" offset="100"]
Important: for best result, your article should use proper headings like:
<h2>Definition</h2>
<h2>Causes</h2>
<h2>Symptoms</h2>
<h2>Diagnostic Tests</h2>
<h2>Treatments</h2>
<h2>Prevention</h2>
<h2>FAQs</h2>