PluginProbe
SimpleTOC – Table of Contents Block / 7.3.1
SimpleTOC – Table of Contents Block v7.3.1
7.3.1 7.2.0 7.3.0 7.1.1 7.1.0 7.0.10 7.0.9 7.0.8 7.0.7 7.0.6 7.0.4 7.0.5 5.0.21.1 5.0.22 5.0.23 5.0.24 5.0.25 5.0.25.1 5.0.27 5.0.28 5.0.29 5.0.3 5.0.30 5.0.31 5.0.32 All 160 releases
← All changes | plugin.php +279 -196 7.0.67.3.1 View file →
@@ -2,9 +2,11 @@
2 2 /**
3 3 * Plugin Name: SimpleTOC - Table of Contents Block
4 4 * Plugin URI: https://marc.tv/simpletoc-wordpress-inhaltsverzeichnis-plugin-gutenberg/
5 5 * Description: SEO-friendly Table of Contents Gutenberg block. No JavaScript or CSS by default.
6 - * Version: 7.0.6
6 + * Version: 7.3.1
7 + * Requires at least: 6.2
8 + * Requires PHP: 7.3
7 9 * Author: Marc Tönsing
8 10 * Author URI: https://toensing.com
9 11 * Text Domain: simpletoc
10 12 * License: GPL v2 or later
@@ -18,8 +20,9 @@
18 20 require_once __DIR__ . '/simpletoc-admin-settings.php';
19 21 require_once __DIR__ . '/simpletoc-class-headline-ids.php';
20 22
21 23 const DEFAULT_BOX_COLOR = '#ebebeb';
24 +const SIMPLETOC_VERSION = '7.3.1';
22 25
23 26 /**
24 27 * Prevents direct execution of the plugin file.
25 28 * If a WordPress function does not exist, it means that the file has not been run by WordPress.
@@ -160,11 +163,11 @@
160 163 2
161 164 );
162 165
163 166 /**
164 - * Filter to add plugins to the TOC list for Rank Math plugin
167 + * Filter to add plugins to the TOC list for Rank Math plugin.
165 168 *
166 - * @param array TOC plugins.
169 + * @param array $toc_plugins TOC plugins.
167 170 */
168 171 add_filter(
169 172 'rank_math/researches/toc_plugins',
170 173 function ( $toc_plugins ) {
@@ -191,9 +194,9 @@
191 194
192 195 return $content;
193 196 }
194 197
195 -add_filter( 'the_content', __NAMESPACE__ . '\simpletoc_add_ids_to_content', 1 );
198 +add_filter( 'the_content', __NAMESPACE__ . '\simpletoc_add_ids_to_content', 1 );
196 199
197 200 /**
198 201 * Recursively adds IDs to the headings of a nested block structure.
199 202 *
@@ -214,9 +217,9 @@
214 217 * @param array $supported_blocks The array of supported blocks.
215 218 */
216 219 $supported_blocks = apply_filters( 'simpletoc_supported_blocks_for_ids', $supported_blocks );
217 220
218 - // Need two separate instances so that IDs aren't double coubnted.
221 + // Need two separate instances so that IDs aren't double counted.
219 222 $inner_html_id_instance = new SimpleTOC_Headline_Ids();
220 223 $inner_content_id_instance = new SimpleTOC_Headline_Ids();
221 224
222 225 foreach ( $blocks as &$block ) {
@@ -222,12 +225,8 @@
222 225 foreach ( $blocks as &$block ) {
223 226 if ( isset( $block['blockName'] ) && in_array( $block['blockName'], $supported_blocks, true ) && isset( $block['innerHTML'] ) && isset( $block['innerContent'] ) && isset( $block['innerContent'][0] ) ) {
224 227 $block['innerHTML'] = add_anchor_attribute( $block['innerHTML'], $inner_html_id_instance, $block );
225 228 $block['innerContent'][0] = add_anchor_attribute( $block['innerContent'][0], $inner_content_id_instance, $block );
226 - } elseif ( isset( $block['attrs']['ref'] ) ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedElseif
227 - // search in reusable blocks (this is not finished because I ran out of ideas.)
228 - // $reusable_block_id = $block['attrs']['ref'];
229 - // $reusable_block_content = parse_blocks(get_post($reusable_block_id)->post_content);.
230 229 } elseif ( ! empty( $block['innerBlocks'] ) ) {
231 230 // search in groups.
232 231 $block['innerBlocks'] = add_ids_to_blocks_recursive( $block['innerBlocks'] );
233 232 }
@@ -245,21 +244,26 @@
245 244 function render_callback_simpletoc( $attributes ) {
246 245 $is_backend = defined( 'REST_REQUEST' ) && REST_REQUEST && 'edit' === filter_input( INPUT_GET, 'context' );
247 246 $title_text = $attributes['title_text'] ? esc_html( trim( $attributes['title_text'] ) ) : __( 'Table of Contents', 'simpletoc' );
248 247 $alignclass = ! empty( $attributes['align'] ) ? 'align' . $attributes['align'] : '';
249 - $class_name = ! empty( $attributes['className'] ) ? wp_strip_all_tags( $attributes['className'] ) : '';
250 248 $title_level = $attributes['title_level'];
251 249 $global_box_style_enabled = apply_filters( 'simpletoc_box_style_enabled', false ) || true === (bool) get_option( 'simpletoc_box_style_enabled', false );
252 - $box_style_enabled = $global_box_style_enabled || ! empty( $attributes['box_style'] );
250 + $legacy_box_style_enabled = ! empty( $attributes['box_style'] );
251 + $typography_enabled = ! empty( $attributes['fontSize'] ) || ! empty( $attributes['style']['typography'] );
253 252 $wrapper_classes = array( 'simpletoc' );
254 253 $wrapper_style = '';
255 254
256 - if ( $box_style_enabled ) {
255 + if ( $typography_enabled ) {
256 + $wrapper_classes[] = 'has-simpletoc-typography';
257 + }
258 +
259 + if ( $global_box_style_enabled || $legacy_box_style_enabled ) {
260 + $wrapper_classes[] = 'is-style-boxed';
257 261 $wrapper_classes[] = 'has-simpletoc-box-style';
258 262
259 263 if ( $global_box_style_enabled ) {
260 264 $wrapper_classes[] = 'has-background';
261 - $wrapper_style = safecss_filter_attr( 'background-color:' . DEFAULT_BOX_COLOR . ';' );
265 + $wrapper_style = safecss_filter_attr( 'background-color:' . DEFAULT_BOX_COLOR . ';' );
262 266 } elseif ( ! empty( $attributes['box_color'] ) ) {
263 267 $wrapper_classes[] = 'has-background';
264 268 $wrapper_style = safecss_filter_attr( 'background-color:' . $attributes['box_color'] . ';' );
265 269 } else {
@@ -267,9 +271,8 @@
267 271 $wrapper_style = safecss_filter_attr( 'background-color:' . DEFAULT_BOX_COLOR . ';' );
268 272 }
269 273 }
270 274
271 - $wrapper_enabled = apply_filters( 'simpletoc_wrapper_enabled', false ) || true === (bool) get_option( 'simpletoc_wrapper_enabled', false ) || true === (bool) get_option( 'simpletoc_accordion_enabled', false );
272 275 $wrapper_attrs = get_block_wrapper_attributes(
273 276 array(
274 277 'class' => implode( ' ', $wrapper_classes ),
275 278 'style' => $wrapper_style,
@@ -274,11 +277,10 @@
274 277 'class' => implode( ' ', $wrapper_classes ),
275 278 'style' => $wrapper_style,
276 279 )
277 280 );
278 - $has_wrapper = ! empty( $class_name ) || $wrapper_enabled || $attributes['accordion'] || $attributes['wrapper'] || $box_style_enabled;
279 - $pre_html = $has_wrapper ? '<div role="navigation" aria-label="' . __( 'Table of Contents', 'simpletoc' ) . '" ' . $wrapper_attrs . '>' : '';
280 - $post_html = $has_wrapper ? '</div>' : '';
281 + $pre_html = '<div role="navigation" aria-label="' . esc_attr__( 'Table of Contents', 'simpletoc' ) . '" ' . $wrapper_attrs . '>';
282 + $post_html = '</div>';
281 283
282 284 $post = get_post();
283 285 $blocks = ! is_null( $post ) && ! is_null( $post->post_content ) ? parse_blocks( $post->post_content ) : '';
284 286
@@ -287,17 +289,17 @@
287 289 $headings_clean = array_map( 'trim', $headings );
288 290 $toc_html = generate_toc( $headings_clean, $attributes );
289 291
290 292 if ( empty( $blocks ) ) {
291 - return get_empty_blocks_message( $is_backend, $attributes, $title_level, $alignclass, $title_text, __( 'No blocks found.', 'simpletoc' ), __( 'Save or update post first.', 'simpletoc' ), $wrapper_attrs, $has_wrapper );
293 + return get_empty_blocks_message( $is_backend, $attributes, $title_level, $alignclass, $title_text, __( 'No blocks found.', 'simpletoc' ), __( 'Save or update post first.', 'simpletoc' ), $wrapper_attrs );
292 294 }
293 295
294 296 if ( empty( $headings_clean ) ) {
295 - return get_empty_blocks_message( $is_backend, $attributes, $title_level, $alignclass, $title_text, __( 'No headings found.', 'simpletoc' ), __( 'Save or update post first.', 'simpletoc' ), $wrapper_attrs, $has_wrapper );
297 + return get_empty_blocks_message( $is_backend, $attributes, $title_level, $alignclass, $title_text, __( 'No headings found.', 'simpletoc' ), __( 'Save or update post first.', 'simpletoc' ), $wrapper_attrs );
296 298 }
297 299
298 300 if ( empty( $toc_html ) ) {
299 - return get_empty_blocks_message( $is_backend, $attributes, $title_level, $alignclass, $title_text, __( 'No headings found.', 'simpletoc' ), __( 'Check minimal and maximum level block settings.', 'simpletoc' ), $wrapper_attrs, $has_wrapper );
301 + return get_empty_blocks_message( $is_backend, $attributes, $title_level, $alignclass, $title_text, __( 'No headings found.', 'simpletoc' ), __( 'Check minimal and maximum level block settings.', 'simpletoc' ), $wrapper_attrs );
300 302 }
301 303
302 304 return $pre_html . $toc_html . $post_html;
303 305 }
@@ -311,27 +313,20 @@
311 313 * @param string $alignclass The CSS class for alignment of the Table of Contents block.
312 314 * @param string $title_text The text for the Table of Contents title.
313 315 * @param string $warning_text1 The first part of the warning message to be displayed.
314 316 * @param string $warning_text2 The second part of the warning message to be displayed.
315 - * @param string $wrapper_attrs Wrapper attributes for the optional block wrapper.
316 - * @param bool $has_wrapper Indicates if the wrapper should be rendered.
317 + * @param string $wrapper_attrs Block wrapper attributes.
317 318 *
318 319 * @return string The HTML output for the empty blocks message.
319 320 */
320 -function get_empty_blocks_message( $is_backend, $attributes, $title_level, $alignclass, $title_text, $warning_text1, $warning_text2, $wrapper_attrs = '', $has_wrapper = false ) {
321 +function get_empty_blocks_message( $is_backend, $attributes, $title_level, $alignclass, $title_text, $warning_text1, $warning_text2, $wrapper_attrs = '' ) {
321 322 $html = '';
322 323
323 324 if ( $is_backend ) {
324 - if ( $has_wrapper ) {
325 - $html .= '<div role="navigation" aria-label="' . esc_attr__( 'Table of Contents', 'simpletoc' ) . '" ' . $wrapper_attrs . '>';
326 - }
327 -
325 + $html .= '<div role="navigation" aria-label="' . esc_attr__( 'Table of Contents', 'simpletoc' ) . '" ' . $wrapper_attrs . '>';
328 326 $html .= sprintf( '<h%d class="%s">%s</h%d>', $title_level, esc_attr( trim( 'simpletoc-title ' . $alignclass ) ), $title_text, $title_level );
329 - $html .= sprintf( '<p class="components-notice is-warning %s">%s %s</p>', $alignclass, $warning_text1, $warning_text2 );
330 -
331 - if ( $has_wrapper ) {
332 - $html .= '</div>';
333 - }
327 + $html .= sprintf( '<p class="components-notice is-warning %s">%s %s</p>', esc_attr( $alignclass ), esc_html( $warning_text1 ), esc_html( $warning_text2 ) );
328 + $html .= '</div>';
334 329 }
335 330
336 331 return $html;
337 332 }
@@ -359,9 +354,9 @@
359 354 if ( isset( $blocks[ $block ]['blockName'] ) && 'core/heading' === $blocks[ $block ]['blockName'] ) {
360 355 // make sure its a headline.
361 356 foreach ( $headings as $heading => &$inner_heading ) {
362 357 if ( $inner_heading === $blocks[ $block ]['innerHTML'] ) {
363 - $inner_heading = preg_replace( '/(<h1|<h2|<h3|<h4|<h5|<h6)/i', '$1 data-page="' . $pages . '"', $blocks[ $block ]['innerHTML'] );
358 + $inner_heading = simpletoc_add_page_number_to_headline( $blocks[ $block ]['innerHTML'], $pages );
364 359 }
365 360 }
366 361 }
367 362 }
@@ -403,13 +398,10 @@
403 398 // search in groups.
404 399 $arr = array_merge( filter_headings_recursive( $inner_block ), $arr );
405 400 }
406 401 } else {
407 - if ( isset( $blocks['blockName'] ) && ( 'core/heading' === $blocks['blockName'] ) && 'core/heading' !== $inner_block ) {
408 - // make sure it's a headline.
409 - if ( preg_match( '/(<h1|<h2|<h3|<h4|<h5|<h6)/i', $inner_block ) ) {
410 - $arr[] = $inner_block;
411 - }
402 + if ( isset( $blocks['blockName'] ) && ( 'core/heading' === $blocks['blockName'] ) && 'core/heading' !== $inner_block && simpletoc_is_heading_html( $inner_block ) ) {
403 + $arr[] = $inner_block;
412 404 }
413 405
414 406 $supported_third_party_blocks = array(
415 407 'generateblocks/headline', /* GenerateBlocks 1.x */
@@ -426,14 +418,11 @@
426 418 'simpletoc_supported_third_party_blocks',
427 419 $supported_third_party_blocks
428 420 );
429 421
430 - if ( isset( $blocks['blockName'] ) && in_array( $blocks['blockName'], $supported_third_party_blocks, true ) && 'core/heading' !== $inner_block ) {
431 - // make sure it's a headline.
432 - if ( preg_match( '/(<h1|<h2|<h3|<h4|<h5|<h6)/i', $inner_block ) ) {
433 - $inner_block = simpletoc_maybe_replace_generateblocks_dynamic_tags( $inner_block, $blocks );
434 - $arr[] = $inner_block;
435 - }
422 + if ( isset( $blocks['blockName'] ) && in_array( $blocks['blockName'], $supported_third_party_blocks, true ) && 'core/heading' !== $inner_block && simpletoc_is_heading_html( $inner_block ) ) {
423 + $inner_block = simpletoc_maybe_replace_generateblocks_dynamic_tags( $inner_block, $blocks );
424 + $arr[] = $inner_block;
436 425 }
437 426 }
438 427 }
439 428
@@ -486,12 +475,11 @@
486 475 $html_wo_nbs = str_replace( '&nbsp;', ' ', $zero_punctuation );
487 476 // remove umlauts and accents.
488 477 $string_without_accents = remove_accents( $html_wo_nbs );
489 478 // Sanitizes a title, replacing whitespace and a few other characters with dashes.
490 - $sanitized_string = sanitize_title_with_dashes( $string_without_accents );
491 - // Encode for use in an url.
492 - $urlencoded = rawurlencode( $sanitized_string );
493 - return $urlencoded;
479 + // Already returns a URL-safe, percent-encoded slug for non-ASCII input, so no
480 + // further rawurlencode() is needed (that would double-encode the string).
481 + return sanitize_title_with_dashes( $string_without_accents );
494 482 }
495 483
496 484 /**
497 485 * Add additional plugin meta links to the SimpleTOC plugin page.
@@ -511,8 +499,134 @@
511 499 return $links;
512 500 }
513 501
514 502 /**
503 + * Loads the WordPress HTML Tag Processor when available.
504 + *
505 + * @return bool True when the HTML Tag Processor can be used.
506 + */
507 +function simpletoc_load_html_tag_processor() {
508 + if ( class_exists( '\WP_HTML_Tag_Processor' ) ) {
509 + return true;
510 + }
511 +
512 + if ( defined( 'ABSPATH' ) && defined( 'WPINC' ) ) {
513 + $html_tag_processor_file = ABSPATH . WPINC . '/html-api/class-wp-html-tag-processor.php';
514 +
515 + if ( file_exists( $html_tag_processor_file ) ) {
516 + require_once $html_tag_processor_file;
517 + }
518 + }
519 +
520 + return class_exists( '\WP_HTML_Tag_Processor' );
521 +}
522 +
523 +/**
524 + * Creates an HTML Tag Processor for a valid HTML fragment.
525 + *
526 + * Parsed block content can contain non-string placeholders for nested blocks.
527 + * The WordPress HTML API accepts strings only.
528 + *
529 + * @param mixed $html The HTML fragment to inspect.
530 + * @return \WP_HTML_Tag_Processor|null The processor, or null when unavailable or invalid.
531 + */
532 +function simpletoc_get_html_tag_processor( $html ) {
533 + if ( ! is_string( $html ) || ! simpletoc_load_html_tag_processor() ) {
534 + return null;
535 + }
536 +
537 + return new \WP_HTML_Tag_Processor( $html );
538 +}
539 +
540 +/**
541 + * Returns true when the provided HTML contains a heading tag.
542 + *
543 + * @param string $html The HTML to inspect.
544 + * @return bool True when the HTML contains a heading tag.
545 + */
546 +function simpletoc_is_heading_html( $html ) {
547 + return false !== simpletoc_get_heading_depth( $html );
548 +}
549 +
550 +/**
551 + * Gets the first heading depth from an HTML fragment.
552 + *
553 + * @param string $html The HTML to inspect.
554 + * @return int|false The heading depth, or false when no heading was found.
555 + */
556 +function simpletoc_get_heading_depth( $html ) {
557 + $processor = simpletoc_get_html_tag_processor( $html );
558 +
559 + if ( $processor ) {
560 +
561 + while ( $processor->next_tag() ) {
562 + $tag_name = $processor->get_tag();
563 +
564 + if ( in_array( $tag_name, array( 'H1', 'H2', 'H3', 'H4', 'H5', 'H6' ), true ) ) {
565 + return (int) substr( $tag_name, 1 );
566 + }
567 + }
568 + }
569 +
570 + return false;
571 +}
572 +
573 +/**
574 + * Adds a data-page attribute to the first heading in an HTML fragment.
575 + *
576 + * @param string $html The heading HTML.
577 + * @param int $page_number The page number to set.
578 + * @return string The updated HTML.
579 + */
580 +function simpletoc_add_page_number_to_headline( $html, $page_number ) {
581 + $processor = simpletoc_get_html_tag_processor( $html );
582 +
583 + if ( $processor ) {
584 +
585 + while ( $processor->next_tag() ) {
586 + if ( ! in_array( $processor->get_tag(), array( 'H1', 'H2', 'H3', 'H4', 'H5', 'H6' ), true ) ) {
587 + continue;
588 + }
589 +
590 + $processor->set_attribute( 'data-page', (string) $page_number );
591 + return $processor->get_updated_html();
592 + }
593 + }
594 +
595 + return $html;
596 +}
597 +
598 +/**
599 + * Checks whether the first heading in an HTML fragment has the provided class.
600 + *
601 + * @param string $html The heading HTML.
602 + * @param string $class_name The class name to find.
603 + * @return bool True when the class exists.
604 + */
605 +function simpletoc_heading_has_class( $html, $class_name ) {
606 + $processor = simpletoc_get_html_tag_processor( $html );
607 +
608 + if ( $processor ) {
609 +
610 + while ( $processor->next_tag() ) {
611 + if ( ! in_array( $processor->get_tag(), array( 'H1', 'H2', 'H3', 'H4', 'H5', 'H6' ), true ) ) {
612 + continue;
613 + }
614 +
615 + $class_attribute = $processor->get_attribute( 'class' );
616 +
617 + if ( ! is_string( $class_attribute ) ) {
618 + return false;
619 + }
620 +
621 + return in_array( $class_name, preg_split( '/\s+/', trim( $class_attribute ) ), true );
622 + }
623 + }
624 +
625 + return false;
626 +}
627 +
628 +/**
515 629 * Adds an ID attribute to all Heading tags in the provided HTML.
516 630 *
517 631 * @param string $html The HTML content to modify.
518 632 * @param SimpleTOC_Headline_Ids $headline_class_instance The instance of the SimpleTOC_Headline_Ids class.
@@ -519,8 +633,11 @@
519 633 * @param array $block The parsed block data.
520 634 * @return string The modified HTML content with ID attributes added to the Heading tags
521 635 */
522 636 function add_anchor_attribute( $html, $headline_class_instance = null, $block = array() ) {
637 + if ( ! is_string( $html ) ) {
638 + return $html;
639 + }
523 640
524 641 // remove non-breaking space entites from input HTML.
525 642 $html_wo_nbs = str_replace( '&nbsp;', ' ', $html );
526 643
@@ -528,19 +645,12 @@
528 645 if ( ! $html_wo_nbs ) {
529 646 return $html;
530 647 }
531 648
532 - if ( ! class_exists( '\WP_HTML_Tag_Processor' ) && defined( 'ABSPATH' ) && defined( 'WPINC' ) ) {
533 - $html_tag_processor_file = ABSPATH . WPINC . '/html-api/class-wp-html-tag-processor.php';
649 + $processor = simpletoc_get_html_tag_processor( $html_wo_nbs );
534 650
535 - if ( file_exists( $html_tag_processor_file ) ) {
536 - require_once $html_tag_processor_file;
537 - }
538 - }
651 + if ( $processor ) {
539 652
540 - if ( class_exists( '\WP_HTML_Tag_Processor' ) ) {
541 - $processor = new \WP_HTML_Tag_Processor( $html_wo_nbs );
542 -
543 653 while ( $processor->next_tag() ) {
544 654 if ( ! in_array( $processor->get_tag(), array( 'H1', 'H2', 'H3', 'H4', 'H5', 'H6' ), true ) ) {
545 655 continue;
546 656 }
@@ -558,37 +668,9 @@
558 668
559 669 return $processor->get_updated_html();
560 670 }
561 671
562 - libxml_use_internal_errors( true );
563 - $dom = new \DOMDocument();
564 - try {
565 - $dom->loadHTML( '<?xml version="1.0" encoding="UTF-8"?>' . "\n" . $html_wo_nbs, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD );
566 - } catch ( \Exception $e ) {
567 - return $html;
568 - }
569 -
570 - // use xpath to select the Heading html tags.
571 - $xpath = new \DOMXPath( $dom );
572 - $tags = $xpath->evaluate( '//*[self::h1 or self::h2 or self::h3 or self::h4 or self::h5 or self::h6]' );
573 -
574 - // Loop through all the found tags.
575 - foreach ( $tags as $tag ) {
576 - // if tag already has an attribute "id" defined, no need for creating a new one.
577 - if ( ! empty( $tag->getAttribute( 'id' ) ) ) {
578 - continue;
579 - }
580 - // Set id attribute.
581 - $heading_html = simpletoc_get_heading_html_for_anchor( $html, $block );
582 - $heading_text = trim( wp_strip_all_tags( $heading_html ) );
583 - $anchor = $headline_class_instance->get_headline_anchor( $heading_text );
584 - $tag->setAttribute( 'id', $anchor );
585 - }
586 -
587 - // Save the HTML changes.
588 - $content = $dom->saveHTML( $dom->documentElement ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
589 -
590 - return $content;
672 + return $html;
591 673 }
592 674
593 675 /**
594 676 * Generates a table of contents based on the provided headings and attributes
@@ -607,30 +689,14 @@
607 689 $list_type = $attributes['use_ol'] ? 'ol' : 'ul';
608 690 $global_absolut_urls_enabled = get_option( 'simpletoc_absolute_urls_enabled', false );
609 691 $absolute_url = $attributes['use_absolute_urls'] || $global_absolut_urls_enabled ? get_permalink() : '';
610 692
611 - list($min_depth, $initial_depth) = find_min_depth( $headings, $attributes );
693 + $toc_headings = get_included_toc_headings( $headings, $attributes );
694 + list($min_depth, $initial_depth) = find_min_depth( $toc_headings, $attributes );
612 695
613 - $item_count = 0;
614 - $headline_ids = new SimpleTOC_Headline_Ids();
615 - foreach ( $headings as $line => $headline ) {
616 - $this_depth = (int) $headings[ $line ][2];
617 - $next_depth = isset( $headings[ $line + 1 ][2] ) ? (int) $headings[ $line + 1 ][2] : '';
618 - $exclude_headline = should_exclude_headline( $headline, $attributes, $this_depth );
619 - $title = trim( wp_strip_all_tags( $headline ) );
620 - $custom_id = extract_id( $headline );
621 - $link = $custom_id ? $custom_id : $headline_ids->get_headline_anchor( $title );
622 - if ( ! $exclude_headline ) {
623 - ++$item_count;
624 - open_list( $list, $list_type, $min_depth, $this_depth );
625 - $page = get_page_number_from_headline( $headline );
626 - $list .= '<a href="' . $absolute_url . $page . '#' . $link . '">' . $title . '</a>' . PHP_EOL;
696 + $item_count = count( $toc_headings );
697 + $list = render_toc_list_items( $toc_headings, $list_type, $absolute_url, $min_depth );
627 698
628 - }
629 - close_list( $list, $list_type, $min_depth, $attributes['min_level'], $attributes['max_level'], $next_depth, $line, count( $headings ) - 1, $initial_depth, $this_depth );
630 -
631 - }
632 -
633 699 $html = add_accordion_start( $html, $attributes, $item_count, $align_class );
634 700 $html = add_hidden_markup_start( $html, $attributes, $item_count, $align_class );
635 701 $html = add_smooth( $html, $attributes );
636 702
@@ -645,9 +711,9 @@
645 711 if ( ! empty( $styles ) ) {
646 712 $html_style = " $styles";
647 713 }
648 714
649 - $html .= "<$list_type class=\"$html_class\"$html_style>\n$list</li></$list_type>";
715 + $html .= "<$list_type class=\"$html_class\"$html_style>\n$list</$list_type>";
650 716 }
651 717
652 718 $html = add_accordion_end( $html, $attributes );
653 719 $html = add_hidden_markup_end( $html, $attributes );
@@ -671,10 +737,11 @@
671 737 $min_depth = 6;
672 738 $initial_depth = 6;
673 739
674 740 foreach ( $headings as $line => $headline ) {
675 - if ( $min_depth > $headings[ $line ][2] ) {
676 - $min_depth = (int) $headings[ $line ][2];
741 + $this_depth = is_array( $headline ) && isset( $headline['depth'] ) ? (int) $headline['depth'] : (int) $headings[ $line ][2];
742 + if ( $min_depth > $this_depth ) {
743 + $min_depth = $this_depth;
677 744 $initial_depth = $min_depth;
678 745 }
679 746 }
680 747
@@ -694,92 +761,96 @@
694 761 * @param int $this_depth The depth level of the headline.
695 762 * @return bool True if the headline should be excluded, false otherwise.
696 763 */
697 764 function should_exclude_headline( $headline, $attributes, $this_depth ) {
698 - $exclude_headline = false;
699 - preg_match( '/class="([^"]+)"/', $headline, $matches );
700 - if ( ! empty( $matches[1] ) && strpos( $matches[1], 'simpletoc-hidden' ) !== false ) {
701 - $exclude_headline = true;
702 - }
765 + $exclude_headline = simpletoc_heading_has_class( $headline, 'simpletoc-hidden' );
703 766
704 767 return ( $this_depth > $attributes['max_level'] || $exclude_headline || $this_depth < $attributes['min_level'] );
705 768 }
706 769
707 770 /**
708 - * The open_list function appends a new list item to the global $list variable, adding necessary opening tags if needed to maintain the correct nesting of the list.
771 + * Filters headings down to TOC-visible entries while preserving anchor generation order.
709 772 *
710 - * @param string &$list_to_append_to The global list variable to append the new list item to.
711 - * @param string $list_type The type of list to be created, either "ul" (unordered list) or "ol" (ordered list).
712 - * @param int &$min_depth The minimum depth of headings that should be included in the table of contents.
713 - * @param int $this_depth The depth of the current heading being processed.
714 - * @return void The function modifies the input $list_to_append_to variable directly.
773 + * @param array $headings An array of headings to include in the table of contents.
774 + * @param array $attributes An array of attributes to customize the output.
775 + * @return array[] The headings that should be rendered in the table of contents.
715 776 */
716 -function open_list( &$list_to_append_to, $list_type, &$min_depth, $this_depth ) {
717 - if ( $this_depth === $min_depth ) {
718 - $list_to_append_to .= '<li>';
719 - } else {
720 - for ( $min_depth; $min_depth < $this_depth; $min_depth++ ) {
721 - $list_to_append_to .= "\n<" . $list_type . "><li>\n";
777 +function get_included_toc_headings( $headings, $attributes ) {
778 + $toc_headings = array();
779 + $headline_ids = new SimpleTOC_Headline_Ids();
780 +
781 + foreach ( $headings as $headline ) {
782 + $this_depth = simpletoc_get_heading_depth( $headline );
783 +
784 + if ( false === $this_depth ) {
785 + continue;
722 786 }
787 +
788 + $title = trim( wp_strip_all_tags( $headline ) );
789 + $custom_id = extract_id( $headline );
790 + $link = $custom_id ? $custom_id : $headline_ids->get_headline_anchor( $title );
791 +
792 + if ( should_exclude_headline( $headline, $attributes, $this_depth ) ) {
793 + continue;
794 + }
795 +
796 + $toc_headings[] = array(
797 + 'headline' => $headline,
798 + 'depth' => $this_depth,
799 + 'title' => $title,
800 + 'link' => $link,
801 + );
723 802 }
803 +
804 + return $toc_headings;
724 805 }
725 806
726 807 /**
727 - * Closes an HTML list tag and updates the list string and minimum depth variable as necessary.
808 + * Renders nested TOC list item markup from already-filtered headings.
728 809 *
729 - * @param string $list_to_append_to A reference to the list string being built.
730 - * @param string $list_type The type of list tag being used (ul or ol).
731 - * @param int $min_depth A reference to the minimum depth variable.
732 - * @param int $min_level The minimum depth level of the headings.
733 - * @param int $max_level Maximum depth setting, which is a high number like 6.
734 - * @param int|null $next_depth The depth of the next list item, or null if this is the last item.
735 - * @param int $line The index of the current list item.
736 - * @param int $last_line The index of the last list item.
737 - * @param int $initial_depth The initial depth of the list.
738 - * @param int $this_depth The depth of the current list item.
739 - * @return void
810 + * @param array[] $toc_headings The headings that should be rendered in the table of contents.
811 + * @param string $list_type The type of list to be created, either "ul" (unordered list) or "ol".
812 + * @param string $absolute_url The optional absolute URL prefix for links.
813 + * @param int $min_depth The minimum heading depth included in the table of contents.
814 + * @return string The rendered nested list item markup.
740 815 */
741 -function close_list( &$list_to_append_to, $list_type, &$min_depth, $min_level, $max_level, $next_depth, $line, $last_line, $initial_depth, $this_depth ) {
742 - if ( $line !== $last_line ) {
743 - $list_to_append_to .= PHP_EOL;
744 - if ( $next_depth < $this_depth ) {
745 - // Next heading goes back shallower in the ToC!
746 - if ( $next_depth >= $min_level ) {
747 - // Next heading is within min depth bounds and WILL get ToC'd
748 - // Close this item and step back shallower in the ToC.
749 - for ( $min_depth; $min_depth > $next_depth; $min_depth-- ) {
750 - $list_to_append_to .= "</li>\n</" . $list_type . ">\n";
751 - }
752 - } else { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedElse
753 - // SKIP CLOSING! Next heading won't be included in the ToC at all.
816 +function render_toc_list_items( $toc_headings, $list_type, $absolute_url, $min_depth ) {
817 + $list = '';
818 + $current_depth = null;
819 +
820 + foreach ( $toc_headings as $toc_heading ) {
821 + $this_depth = $toc_heading['depth'];
822 +
823 + if ( null === $current_depth ) {
824 + $current_depth = $this_depth;
825 + $list .= '<li>';
826 + } elseif ( $this_depth > $current_depth ) {
827 + for ( $current_depth; $current_depth < $this_depth; $current_depth++ ) {
828 + $list .= "\n<" . $list_type . ">\n<li>";
754 829 }
755 - } elseif ( $next_depth === $this_depth ) {
756 - // Next heading is exactly as deep. Not going shallower or deeper in the ToC hierarchy.
757 - // E.g. this is h3, next is h3.
758 - if ( $next_depth < $min_level ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedIf
759 - // E.g. this is h3, next is h3, min is h2
760 - // This heading didn't open a ToC item. Nothing to close.
761 - } else {
762 - // SKIP CLOSING! Next heading will open a new sub-list in the ToC.
763 - $list_to_append_to .= "</li>\n";
830 + } elseif ( $this_depth === $current_depth ) {
831 + $list .= "</li>\n<li>";
832 + } else {
833 + for ( $current_depth; $current_depth > $this_depth; $current_depth-- ) {
834 + $list .= "</li>\n</" . $list_type . ">\n";
764 835 }
765 - } else { // phpcs:ignore.
766 - // Next heading is deeper in the ToC.
767 - if ( $next_depth <= $max_level ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedIf
768 - // Next deeper heading is within bounds and will open a new sub-list. Leave this one open.
769 - // E.g. this is h3, next is h4, min is h2, max is h5.
770 - } else { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedElse
771 - // Next heading is too deep and will be ignored. We'll close out coming up or finishing the ToC.
772 - // E.g. this is h3, next is h4, max is h3.
773 - }
836 + $list .= "</li>\n<li>";
774 837 }
775 - } else {
776 - // This is the last line of the ToC. Close out the whole thing.
777 - // IMPORTANT NOTE: The overall ToC list will be wrapped in a list element and closed out.
778 - for ( $initial_depth; $initial_depth < $this_depth; $initial_depth++ ) {
779 - $list_to_append_to .= "</li>\n</" . $list_type . ">\n";
838 +
839 + $page = get_page_number_from_headline( $toc_heading['headline'] );
840 + $url = $absolute_url . $page . '#' . $toc_heading['link'];
841 + $href = $absolute_url ? esc_url( $url ) : esc_attr( $url );
842 + $list .= '<a href="' . $href . '">' . esc_html( $toc_heading['title'] ) . '</a>' . PHP_EOL;
843 + }
844 +
845 + if ( null !== $current_depth ) {
846 + for ( $current_depth; $current_depth > $min_depth; $current_depth-- ) {
847 + $list .= "</li>\n</" . $list_type . ">\n";
780 848 }
849 + $list .= '</li>';
781 850 }
851 +
852 + return $list;
782 853 }
783 854
784 855 /**
785 856 * Adds smooth scrolling styles to the output HTML, if enabled by global option or block attribute.
@@ -803,9 +874,9 @@
803 874 wp_enqueue_script(
804 875 'simpletoc-accordion',
805 876 plugin_dir_url( __FILE__ ) . 'assets/accordion.js',
806 877 array(),
807 - '6.9.0',
878 + SIMPLETOC_VERSION,
808 879 true
809 880 );
810 881
811 882 wp_enqueue_style(
@@ -811,9 +882,9 @@
811 882 wp_enqueue_style(
812 883 'simpletoc-accordion',
813 884 plugin_dir_url( __FILE__ ) . 'assets/accordion.css',
814 885 array(),
815 - '6.9.0'
886 + SIMPLETOC_VERSION
816 887 );
817 888 }
818 889
819 890 /**
@@ -829,9 +900,9 @@
829 900
830 901 if ( $is_hidden_enabled ) {
831 902 $title_text = $attributes['title_text'] ? esc_html( trim( $attributes['title_text'] ) ) : esc_html__( 'Table of Contents', 'simpletoc' );
832 903 $hidden_start = '<details class="simpletoc">
833 - <summary style="cursor: pointer;">' . $title_text . '</summary>';
904 + <summary>' . $title_text . '</summary>';
834 905 $html .= $hidden_start;
835 906 }
836 907
837 908 // If there are no items in the table of contents, return an empty string.
@@ -876,9 +947,9 @@
876 947 // Start and end HTML for accordion, if enabled.
877 948 $accordion_start = '';
878 949 if ( $is_accordion_enabled ) {
879 950 enqueue_accordion_frontend();
880 - $accordion_start = '<h2 style="margin: 0;"><button type="button" aria-expanded="false" aria-controls="simpletoc-content-container" class="simpletoc-collapsible">' . $title_text . '<span class="simpletoc-icon" aria-hidden="true"></span></button></h2><div id="simpletoc-content-container" class="simpletoc-content">';
951 + $accordion_start = '<h2 class="simpletoc-accordion-heading"><button type="button" aria-expanded="false" aria-controls="simpletoc-content-container" class="simpletoc-collapsible">' . $title_text . '<span class="simpletoc-icon" aria-hidden="true"></span></button></h2><div id="simpletoc-content-container" class="simpletoc-content">';
881 952 }
882 953
883 954 // Add the accordion start HTML to the output.
884 955 $html .= $accordion_start;
@@ -926,17 +997,26 @@
926 997 /**
927 998 * Extracts the ID value from the provided heading HTML string.
928 999 *
929 1000 * @param string $headline The heading HTML string to extract the ID value from.
930 - * @return mixed Returns the extracted ID value, or false if no ID value is found.
1001 + * @return string|false Returns the extracted ID value, or false if no ID value is found.
931 1002 */
932 1003 function extract_id( $headline ) {
933 - $pattern = '/id="([^"]*)"/';
934 - preg_match( $pattern, $headline, $matches );
935 - $id_value = $matches[1] ?? false;
1004 + $processor = simpletoc_get_html_tag_processor( $headline );
936 1005
937 - if ( false !== $id_value ) {
938 - return $id_value;
1006 + if ( $processor ) {
1007 +
1008 + while ( $processor->next_tag() ) {
1009 + if ( ! in_array( $processor->get_tag(), array( 'H1', 'H2', 'H3', 'H4', 'H5', 'H6' ), true ) ) {
1010 + continue;
1011 + }
1012 +
1013 + $id_value = $processor->get_attribute( 'id' );
1014 +
1015 + if ( is_string( $id_value ) ) {
1016 + return $id_value;
1017 + }
1018 + }
939 1019 }
940 1020
941 1021 return false;
942 1022 }
@@ -947,22 +1027,25 @@
947 1027 * @param string $headline The headline string.
948 1028 * @return string The page number (in the format "X/") if it exists and is greater than 1, or an empty string otherwise.
949 1029 */
950 1030 function get_page_number_from_headline( $headline ) {
951 - $dom = new \DOMDocument();
1031 + $processor = simpletoc_get_html_tag_processor( $headline );
952 1032
953 - try {
954 - $dom->loadHTML( '<?xml version="1.0" encoding="UTF-8"?>' . "\n" . $headline, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD );
955 - } catch ( \Exception $e ) {
956 - return '';
957 - }
1033 + if ( $processor ) {
958 1034
959 - $xpath = new \DOMXPath( $dom );
960 - $nodes = $xpath->query( '//*/@data-page' );
1035 + while ( $processor->next_tag() ) {
1036 + if ( ! in_array( $processor->get_tag(), array( 'H1', 'H2', 'H3', 'H4', 'H5', 'H6' ), true ) ) {
1037 + continue;
1038 + }
961 1039
962 - if ( isset( $nodes[0] ) && $nodes[0]->nodeValue > 1 ) {
963 - $page_number = $nodes[0]->nodeValue . '/';
964 - return esc_html( $page_number );
965 - } else {
1040 + $page_number = (int) $processor->get_attribute( 'data-page' );
1041 +
1042 + if ( $page_number > 1 ) {
1043 + return esc_html( $page_number . '/' );
1044 + }
1045 + }
1046 +
966 1047 return '';
967 1048 }
1049 +
1050 + return '';
968 1051 }