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 = '#]*\bid=["\']([^"\']+)["\'][^>]*>(.*?)#is'; $headings = array(); if ( preg_match_all( $pattern, $content, $matches, PREG_SET_ORDER ) ) { foreach ( $matches as $match ) { $headings[] = array( 'level' => (int) $match[1], 'anchor' => $match[2], 'text' => wp_strip_all_tags( $match[3] ), ); } } set_transient( $cache_key, $headings, 15 * MINUTE_IN_SECONDS ); } $request_cache[ $request_key ] = $headings; return $headings; } /** * Renders a nested