defaults = array(
'title' => __( 'Table of Contents', 'advance-widget-pack' ),
'min' => '2',
'max' => '3',
);
$widget_ops = array(
'classname' => 'awp_toc awp_widget',
'description' => __( 'Widget to display a table of contents for the current post.', 'advance-widget-pack' ),
);
$control_ops = array(
'id_base' => 'awp_toc',
'width' => 200,
'height' => 250,
);
parent::__construct( 'awp_toc', __( 'AWP Table of Contents', 'advance-widget-pack' ), $widget_ops, $control_ops );
}
public function widget( $args, $instance ) {
$instance = wp_parse_args( (array) $instance, $this->defaults );
$title = apply_filters( 'widget_title', $instance['title'] );
$post_id = get_the_ID();
if ( ! $post_id ) {
return;
}
$headings = self::get_headings( $post_id, absint( $instance['min'] ), absint( $instance['max'] ) );
if ( ! $headings ) {
return;
}
echo $args['before_widget']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- sidebar-provided wrapper markup, trusted per WP core convention.
if ( ! empty( $title ) ) {
echo $args['before_title'] . esc_html( $title ) . $args['after_title']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- title text is esc_html()'d; before/after_title are sidebar-provided wrapper markup.
}
self::render_list( $headings );
echo $args['after_widget']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- sidebar-provided wrapper markup, trusted per WP core convention.
}
/**
* Extracts headings (with pre-assigned anchor ids) from a post's content,
* within the given heading-level range (2-6).
*
* Cached two ways: a request-scoped static cache (so rendering both the
* widget and the block for the same post in one request only parses
* once), and a transient (keyed by awp_cache_generation() like every
* other AWP query) so repeat page views skip the do_blocks()/do_shortcode()
* + regex work entirely, which is otherwise the most expensive per-request
* operation in the plugin on posts with substantial content.
*
* @param int $post_id Post to extract headings from.
* @param int $min Minimum heading level (e.g. 2 for H2).
* @param int $max Maximum heading level (e.g. 3 for H3).
* @return array[] List of array( 'level' => int, 'text' => string, 'anchor' => string ).
*/
public static function get_headings( $post_id, $min = 2, $max = 3 ) {
$min = max( 2, min( 6, absint( $min ) ?: 2 ) );
$max = max( $min, min( 6, absint( $max ) ?: 3 ) );
static $request_cache = array();
// Includes the generation so an in-request post edit (which bumps the
// generation) can't serve a stale result from before the edit — the
// static cache must invalidate exactly when the transient would.
$request_key = awp_cache_generation() . '_' . $post_id . '_' . $min . '_' . $max;
if ( isset( $request_cache[ $request_key ] ) ) {
return $request_cache[ $request_key ];
}
// Re-entrancy guard: do_blocks() below re-renders the post's full
// block tree, including this TOC block itself if it's placed inside
// the same post it's summarizing. That second render_block() call
// reaches this same method again before the first call has finished
// (and therefore before $request_cache above is populated), which
// without this guard recurses until PHP's memory limit is exhausted.
// Returning an empty list on the nested call is safe: it only
// affects what do_blocks() renders internally for its own throwaway
// copy of the content, not the actual headings list returned to the
// original caller once do_blocks() finishes below.
static $in_progress = array();
if ( ! empty( $in_progress[ $post_id ] ) ) {
return array();
}
$cache_key = 'awp_toc_' . $request_key;
$headings = get_transient( $cache_key );
if ( false === $headings ) {
$post = get_post( $post_id );
if ( ! $post ) {
return array();
}
$in_progress[ $post_id ] = true;
// try/finally so a throwing block render_callback or shortcode
// handler still clears the flag — otherwise it would stay stuck
// "true" for the rest of the request, silently making every
// later TOC render for this post_id return an empty list.
try {
// Run blocks/shortcodes through do_blocks()+do_shortcode() to
// get real rendered heading markup, then always (re)assign
// anchor ids ourselves — the_content's own
// awp_toc_filter_the_content is gated to the main singular
// loop and won't have run for this call.
$content = do_blocks( $post->post_content );
$content = do_shortcode( $content );
$content = awp_toc_add_heading_ids( $content );
} finally {
unset( $in_progress[ $post_id ] );
}
$pattern = '#
]*)>(.*?)#is', function ( $match ) use ( &$used_anchors ) { // Leave existing ids alone (theme/other plugin may have already set one). if ( preg_match( '#\bid=["\'][^"\']+["\']#i', $match[2] ) ) { return $match[0]; } $text = wp_strip_all_tags( $match[3] ); $anchor = sanitize_title( $text ); if ( '' === $anchor ) { $anchor = 'section'; } $base = $anchor; $i = 2; while ( isset( $used_anchors[ $anchor ] ) ) { $anchor = $base . '-' . $i; ++$i; } $used_anchors[ $anchor ] = true; return '