PluginProbe
SimpleTOC – Table of Contents Block / 7.4.1
SimpleTOC – Table of Contents Block v7.4.1
7.4.1 7.4.0 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 All 162 releases
← All changes | plugin.php +308 -198 7.0.6 → 7.4.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.4.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.4.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.
@@ -54,9 +57,10 @@
54 57 wp_add_inline_script(
55 58 'simpletoc-toc-editor-script',
56 59 'window.simpletocEditorSettings = ' . wp_json_encode(
57 60 array(
58 - 'settingsUrl' => admin_url( 'options-general.php?page=simpletoc' ),
61 + 'settingsUrl' => admin_url( 'options-general.php?page=simpletoc' ),
62 + 'scrollSpyEnabled' => simpletoc_scroll_spy_enabled(),
59 63 )
60 64 ) . ';',
61 65 'before'
62 66 );
@@ -74,8 +78,10 @@
74 78 */
75 79 function add_simpletoc_block_editor_settings( $editor_settings, $editor_context ) {
76 80 $editor_settings['simpletocSettingsUrl'] = admin_url( 'options-general.php?page=simpletoc' );
77 81
82 + $editor_settings['simpletocScrollSpyEnabled'] = simpletoc_scroll_spy_enabled();
83 +
78 84 return $editor_settings;
79 85 }
80 86
81 87 add_filter( 'block_editor_settings_all', __NAMESPACE__ . '\add_simpletoc_block_editor_settings', 10, 2 );
@@ -160,11 +166,11 @@
160 166 2
161 167 );
162 168
163 169 /**
164 - * Filter to add plugins to the TOC list for Rank Math plugin
170 + * Filter to add plugins to the TOC list for Rank Math plugin.
165 171 *
166 - * @param array TOC plugins.
172 + * @param array $toc_plugins TOC plugins.
167 173 */
168 174 add_filter(
169 175 'rank_math/researches/toc_plugins',
170 176 function ( $toc_plugins ) {
@@ -191,9 +197,9 @@
191 197
192 198 return $content;
193 199 }
194 200
195 -add_filter( 'the_content', __NAMESPACE__ . '\simpletoc_add_ids_to_content', 1 );
201 +add_filter( 'the_content', __NAMESPACE__ . '\simpletoc_add_ids_to_content', 1 );
196 202
197 203 /**
198 204 * Recursively adds IDs to the headings of a nested block structure.
199 205 *
@@ -214,20 +220,22 @@
214 220 * @param array $supported_blocks The array of supported blocks.
215 221 */
216 222 $supported_blocks = apply_filters( 'simpletoc_supported_blocks_for_ids', $supported_blocks );
217 223
218 - // Need two separate instances so that IDs aren't double coubnted.
224 + // Need two separate instances so that IDs aren't double counted.
219 225 $inner_html_id_instance = new SimpleTOC_Headline_Ids();
220 226 $inner_content_id_instance = new SimpleTOC_Headline_Ids();
221 227
228 + $ignored_blocks = simpletoc_get_excluded_blocks();
229 +
222 230 foreach ( $blocks as &$block ) {
231 + if ( isset( $block['blockName'] ) && in_array( $block['blockName'], $ignored_blocks, true ) ) {
232 + continue;
233 + }
234 +
223 235 if ( isset( $block['blockName'] ) && in_array( $block['blockName'], $supported_blocks, true ) && isset( $block['innerHTML'] ) && isset( $block['innerContent'] ) && isset( $block['innerContent'][0] ) ) {
224 236 $block['innerHTML'] = add_anchor_attribute( $block['innerHTML'], $inner_html_id_instance, $block );
225 237 $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 238 } elseif ( ! empty( $block['innerBlocks'] ) ) {
231 239 // search in groups.
232 240 $block['innerBlocks'] = add_ids_to_blocks_recursive( $block['innerBlocks'] );
233 241 }
@@ -245,21 +253,30 @@
245 253 function render_callback_simpletoc( $attributes ) {
246 254 $is_backend = defined( 'REST_REQUEST' ) && REST_REQUEST && 'edit' === filter_input( INPUT_GET, 'context' );
247 255 $title_text = $attributes['title_text'] ? esc_html( trim( $attributes['title_text'] ) ) : __( 'Table of Contents', 'simpletoc' );
248 256 $alignclass = ! empty( $attributes['align'] ) ? 'align' . $attributes['align'] : '';
249 - $class_name = ! empty( $attributes['className'] ) ? wp_strip_all_tags( $attributes['className'] ) : '';
250 257 $title_level = $attributes['title_level'];
251 258 $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'] );
259 + $legacy_box_style_enabled = ! empty( $attributes['box_style'] );
260 + $typography_enabled = ! empty( $attributes['fontSize'] ) || ! empty( $attributes['style']['typography'] );
253 261 $wrapper_classes = array( 'simpletoc' );
254 262 $wrapper_style = '';
255 263
256 - if ( $box_style_enabled ) {
264 + if ( simpletoc_scroll_spy_enabled() || ! empty( $attributes['scroll_spy'] ) ) {
265 + $wrapper_classes[] = 'has-simpletoc-scroll-spy';
266 + }
267 +
268 + if ( $typography_enabled ) {
269 + $wrapper_classes[] = 'has-simpletoc-typography';
270 + }
271 +
272 + if ( $global_box_style_enabled || $legacy_box_style_enabled ) {
273 + $wrapper_classes[] = 'is-style-boxed';
257 274 $wrapper_classes[] = 'has-simpletoc-box-style';
258 275
259 276 if ( $global_box_style_enabled ) {
260 277 $wrapper_classes[] = 'has-background';
261 - $wrapper_style = safecss_filter_attr( 'background-color:' . DEFAULT_BOX_COLOR . ';' );
278 + $wrapper_style = safecss_filter_attr( 'background-color:' . DEFAULT_BOX_COLOR . ';' );
262 279 } elseif ( ! empty( $attributes['box_color'] ) ) {
263 280 $wrapper_classes[] = 'has-background';
264 281 $wrapper_style = safecss_filter_attr( 'background-color:' . $attributes['box_color'] . ';' );
265 282 } else {
@@ -267,9 +284,8 @@
267 284 $wrapper_style = safecss_filter_attr( 'background-color:' . DEFAULT_BOX_COLOR . ';' );
268 285 }
269 286 }
270 287
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 288 $wrapper_attrs = get_block_wrapper_attributes(
273 289 array(
274 290 'class' => implode( ' ', $wrapper_classes ),
275 291 'style' => $wrapper_style,
@@ -274,11 +290,10 @@
274 290 'class' => implode( ' ', $wrapper_classes ),
275 291 'style' => $wrapper_style,
276 292 )
277 293 );
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>' : '';
294 + $pre_html = '<div role="navigation" aria-label="' . esc_attr__( 'Table of Contents', 'simpletoc' ) . '" ' . $wrapper_attrs . '>';
295 + $post_html = '</div>';
281 296
282 297 $post = get_post();
283 298 $blocks = ! is_null( $post ) && ! is_null( $post->post_content ) ? parse_blocks( $post->post_content ) : '';
284 299
@@ -287,17 +302,17 @@
287 302 $headings_clean = array_map( 'trim', $headings );
288 303 $toc_html = generate_toc( $headings_clean, $attributes );
289 304
290 305 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 );
306 + 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 307 }
293 308
294 309 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 );
310 + 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 311 }
297 312
298 313 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 );
314 + 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 315 }
301 316
302 317 return $pre_html . $toc_html . $post_html;
303 318 }
@@ -311,27 +326,20 @@
311 326 * @param string $alignclass The CSS class for alignment of the Table of Contents block.
312 327 * @param string $title_text The text for the Table of Contents title.
313 328 * @param string $warning_text1 The first part of the warning message to be displayed.
314 329 * @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.
330 + * @param string $wrapper_attrs Block wrapper attributes.
317 331 *
318 332 * @return string The HTML output for the empty blocks message.
319 333 */
320 -function get_empty_blocks_message( $is_backend, $attributes, $title_level, $alignclass, $title_text, $warning_text1, $warning_text2, $wrapper_attrs = '', $has_wrapper = false ) {
334 +function get_empty_blocks_message( $is_backend, $attributes, $title_level, $alignclass, $title_text, $warning_text1, $warning_text2, $wrapper_attrs = '' ) {
321 335 $html = '';
322 336
323 337 if ( $is_backend ) {
324 - if ( $has_wrapper ) {
325 - $html .= '<div role="navigation" aria-label="' . esc_attr__( 'Table of Contents', 'simpletoc' ) . '" ' . $wrapper_attrs . '>';
326 - }
327 -
338 + $html .= '<div role="navigation" aria-label="' . esc_attr__( 'Table of Contents', 'simpletoc' ) . '" ' . $wrapper_attrs . '>';
328 339 $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 - }
340 + $html .= sprintf( '<p class="components-notice is-warning %s">%s %s</p>', esc_attr( $alignclass ), esc_html( $warning_text1 ), esc_html( $warning_text2 ) );
341 + $html .= '</div>';
334 342 }
335 343
336 344 return $html;
337 345 }
@@ -359,9 +367,9 @@
359 367 if ( isset( $blocks[ $block ]['blockName'] ) && 'core/heading' === $blocks[ $block ]['blockName'] ) {
360 368 // make sure its a headline.
361 369 foreach ( $headings as $heading => &$inner_heading ) {
362 370 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'] );
371 + $inner_heading = simpletoc_add_page_number_to_headline( $blocks[ $block ]['innerHTML'], $pages );
364 372 }
365 373 }
366 374 }
367 375 }
@@ -368,8 +376,22 @@
368 376 return $headings;
369 377 }
370 378
371 379 /**
380 + * Gets blocks excluded from both anchor generation and TOC collection.
381 + *
382 + * Query templates render repeatedly in a different post context.
383 + *
384 + * @return string[] Excluded block names.
385 + */
386 +function simpletoc_get_excluded_blocks() {
387 + return apply_filters(
388 + 'simpletoc_excluded_blocks',
389 + array( 'core/query', 'generateblocks/query-loop', 'generateblocks/query' )
390 + );
391 +}
392 +
393 +/**
372 394 * Return all headings with a recursive walk through all blocks.
373 395 * This includes groups and reusable block with groups within reusable blocks.
374 396 *
375 397 * @param array[] $blocks The blocks to filter headings from.
@@ -382,9 +404,9 @@
382 404 return $arr;
383 405 }
384 406
385 407 // allow developers to ignore specific blocks.
386 - $ignored_blocks = apply_filters( 'simpletoc_excluded_blocks', array() );
408 + $ignored_blocks = simpletoc_get_excluded_blocks();
387 409
388 410 foreach ( $blocks as $inner_block ) {
389 411 if ( is_array( $inner_block ) ) {
390 412 // if block is ignored, skip.
@@ -403,13 +425,10 @@
403 425 // search in groups.
404 426 $arr = array_merge( filter_headings_recursive( $inner_block ), $arr );
405 427 }
406 428 } 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 - }
429 + if ( isset( $blocks['blockName'] ) && ( 'core/heading' === $blocks['blockName'] ) && 'core/heading' !== $inner_block && simpletoc_is_heading_html( $inner_block ) ) {
430 + $arr[] = $inner_block;
412 431 }
413 432
414 433 $supported_third_party_blocks = array(
415 434 'generateblocks/headline', /* GenerateBlocks 1.x */
@@ -426,14 +445,11 @@
426 445 'simpletoc_supported_third_party_blocks',
427 446 $supported_third_party_blocks
428 447 );
429 448
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 - }
449 + if ( isset( $blocks['blockName'] ) && in_array( $blocks['blockName'], $supported_third_party_blocks, true ) && 'core/heading' !== $inner_block && simpletoc_is_heading_html( $inner_block ) ) {
450 + $inner_block = simpletoc_maybe_replace_generateblocks_dynamic_tags( $inner_block, $blocks );
451 + $arr[] = $inner_block;
436 452 }
437 453 }
438 454 }
439 455
@@ -486,12 +502,11 @@
486 502 $html_wo_nbs = str_replace( '&nbsp;', ' ', $zero_punctuation );
487 503 // remove umlauts and accents.
488 504 $string_without_accents = remove_accents( $html_wo_nbs );
489 505 // 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;
506 + // Already returns a URL-safe, percent-encoded slug for non-ASCII input, so no
507 + // further rawurlencode() is needed (that would double-encode the string).
508 + return sanitize_title_with_dashes( $string_without_accents );
494 509 }
495 510
496 511 /**
497 512 * Add additional plugin meta links to the SimpleTOC plugin page.
@@ -511,8 +526,134 @@
511 526 return $links;
512 527 }
513 528
514 529 /**
530 + * Loads the WordPress HTML Tag Processor when available.
531 + *
532 + * @return bool True when the HTML Tag Processor can be used.
533 + */
534 +function simpletoc_load_html_tag_processor() {
535 + if ( class_exists( '\WP_HTML_Tag_Processor' ) ) {
536 + return true;
537 + }
538 +
539 + if ( defined( 'ABSPATH' ) && defined( 'WPINC' ) ) {
540 + $html_tag_processor_file = ABSPATH . WPINC . '/html-api/class-wp-html-tag-processor.php';
541 +
542 + if ( file_exists( $html_tag_processor_file ) ) {
543 + require_once $html_tag_processor_file;
544 + }
545 + }
546 +
547 + return class_exists( '\WP_HTML_Tag_Processor' );
548 +}
549 +
550 +/**
551 + * Creates an HTML Tag Processor for a valid HTML fragment.
552 + *
553 + * Parsed block content can contain non-string placeholders for nested blocks.
554 + * The WordPress HTML API accepts strings only.
555 + *
556 + * @param mixed $html The HTML fragment to inspect.
557 + * @return \WP_HTML_Tag_Processor|null The processor, or null when unavailable or invalid.
558 + */
559 +function simpletoc_get_html_tag_processor( $html ) {
560 + if ( ! is_string( $html ) || ! simpletoc_load_html_tag_processor() ) {
561 + return null;
562 + }
563 +
564 + return new \WP_HTML_Tag_Processor( $html );
565 +}
566 +
567 +/**
568 + * Returns true when the provided HTML contains a heading tag.
569 + *
570 + * @param string $html The HTML to inspect.
571 + * @return bool True when the HTML contains a heading tag.
572 + */
573 +function simpletoc_is_heading_html( $html ) {
574 + return false !== simpletoc_get_heading_depth( $html );
575 +}
576 +
577 +/**
578 + * Gets the first heading depth from an HTML fragment.
579 + *
580 + * @param string $html The HTML to inspect.
581 + * @return int|false The heading depth, or false when no heading was found.
582 + */
583 +function simpletoc_get_heading_depth( $html ) {
584 + $processor = simpletoc_get_html_tag_processor( $html );
585 +
586 + if ( $processor ) {
587 +
588 + while ( $processor->next_tag() ) {
589 + $tag_name = $processor->get_tag();
590 +
591 + if ( in_array( $tag_name, array( 'H1', 'H2', 'H3', 'H4', 'H5', 'H6' ), true ) ) {
592 + return (int) substr( $tag_name, 1 );
593 + }
594 + }
595 + }
596 +
597 + return false;
598 +}
599 +
600 +/**
601 + * Adds a data-page attribute to the first heading in an HTML fragment.
602 + *
603 + * @param string $html The heading HTML.
604 + * @param int $page_number The page number to set.
605 + * @return string The updated HTML.
606 + */
607 +function simpletoc_add_page_number_to_headline( $html, $page_number ) {
608 + $processor = simpletoc_get_html_tag_processor( $html );
609 +
610 + if ( $processor ) {
611 +
612 + while ( $processor->next_tag() ) {
613 + if ( ! in_array( $processor->get_tag(), array( 'H1', 'H2', 'H3', 'H4', 'H5', 'H6' ), true ) ) {
614 + continue;
615 + }
616 +
617 + $processor->set_attribute( 'data-page', (string) $page_number );
618 + return $processor->get_updated_html();
619 + }
620 + }
621 +
622 + return $html;
623 +}
624 +
625 +/**
626 + * Checks whether the first heading in an HTML fragment has the provided class.
627 + *
628 + * @param string $html The heading HTML.
629 + * @param string $class_name The class name to find.
630 + * @return bool True when the class exists.
631 + */
632 +function simpletoc_heading_has_class( $html, $class_name ) {
633 + $processor = simpletoc_get_html_tag_processor( $html );
634 +
635 + if ( $processor ) {
636 +
637 + while ( $processor->next_tag() ) {
638 + if ( ! in_array( $processor->get_tag(), array( 'H1', 'H2', 'H3', 'H4', 'H5', 'H6' ), true ) ) {
639 + continue;
640 + }
641 +
642 + $class_attribute = $processor->get_attribute( 'class' );
643 +
644 + if ( ! is_string( $class_attribute ) ) {
645 + return false;
646 + }
647 +
648 + return in_array( $class_name, preg_split( '/\s+/', trim( $class_attribute ) ), true );
649 + }
650 + }
651 +
652 + return false;
653 +}
654 +
655 +/**
515 656 * Adds an ID attribute to all Heading tags in the provided HTML.
516 657 *
517 658 * @param string $html The HTML content to modify.
518 659 * @param SimpleTOC_Headline_Ids $headline_class_instance The instance of the SimpleTOC_Headline_Ids class.
@@ -519,8 +660,11 @@
519 660 * @param array $block The parsed block data.
520 661 * @return string The modified HTML content with ID attributes added to the Heading tags
521 662 */
522 663 function add_anchor_attribute( $html, $headline_class_instance = null, $block = array() ) {
664 + if ( ! is_string( $html ) ) {
665 + return $html;
666 + }
523 667
524 668 // remove non-breaking space entites from input HTML.
525 669 $html_wo_nbs = str_replace( '&nbsp;', ' ', $html );
526 670
@@ -528,19 +672,12 @@
528 672 if ( ! $html_wo_nbs ) {
529 673 return $html;
530 674 }
531 675
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';
676 + $processor = simpletoc_get_html_tag_processor( $html_wo_nbs );
534 677
535 - if ( file_exists( $html_tag_processor_file ) ) {
536 - require_once $html_tag_processor_file;
537 - }
538 - }
678 + if ( $processor ) {
539 679
540 - if ( class_exists( '\WP_HTML_Tag_Processor' ) ) {
541 - $processor = new \WP_HTML_Tag_Processor( $html_wo_nbs );
542 -
543 680 while ( $processor->next_tag() ) {
544 681 if ( ! in_array( $processor->get_tag(), array( 'H1', 'H2', 'H3', 'H4', 'H5', 'H6' ), true ) ) {
545 682 continue;
546 683 }
@@ -558,37 +695,9 @@
558 695
559 696 return $processor->get_updated_html();
560 697 }
561 698
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;
699 + return $html;
591 700 }
592 701
593 702 /**
594 703 * Generates a table of contents based on the provided headings and attributes
@@ -607,30 +716,14 @@
607 716 $list_type = $attributes['use_ol'] ? 'ol' : 'ul';
608 717 $global_absolut_urls_enabled = get_option( 'simpletoc_absolute_urls_enabled', false );
609 718 $absolute_url = $attributes['use_absolute_urls'] || $global_absolut_urls_enabled ? get_permalink() : '';
610 719
611 - list($min_depth, $initial_depth) = find_min_depth( $headings, $attributes );
720 + $toc_headings = get_included_toc_headings( $headings, $attributes );
721 + list($min_depth, $initial_depth) = find_min_depth( $toc_headings, $attributes );
612 722
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;
723 + $item_count = count( $toc_headings );
724 + $list = render_toc_list_items( $toc_headings, $list_type, $absolute_url, $min_depth );
627 725
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 726 $html = add_accordion_start( $html, $attributes, $item_count, $align_class );
634 727 $html = add_hidden_markup_start( $html, $attributes, $item_count, $align_class );
635 728 $html = add_smooth( $html, $attributes );
636 729
@@ -645,9 +738,9 @@
645 738 if ( ! empty( $styles ) ) {
646 739 $html_style = " $styles";
647 740 }
648 741
649 - $html .= "<$list_type class=\"$html_class\"$html_style>\n$list</li></$list_type>";
742 + $html .= "<$list_type class=\"$html_class\"$html_style>\n$list</$list_type>";
650 743 }
651 744
652 745 $html = add_accordion_end( $html, $attributes );
653 746 $html = add_hidden_markup_end( $html, $attributes );
@@ -671,10 +764,11 @@
671 764 $min_depth = 6;
672 765 $initial_depth = 6;
673 766
674 767 foreach ( $headings as $line => $headline ) {
675 - if ( $min_depth > $headings[ $line ][2] ) {
676 - $min_depth = (int) $headings[ $line ][2];
768 + $this_depth = is_array( $headline ) && isset( $headline['depth'] ) ? (int) $headline['depth'] : (int) $headings[ $line ][2];
769 + if ( $min_depth > $this_depth ) {
770 + $min_depth = $this_depth;
677 771 $initial_depth = $min_depth;
678 772 }
679 773 }
680 774
@@ -694,92 +788,96 @@
694 788 * @param int $this_depth The depth level of the headline.
695 789 * @return bool True if the headline should be excluded, false otherwise.
696 790 */
697 791 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 - }
792 + $exclude_headline = simpletoc_heading_has_class( $headline, 'simpletoc-hidden' );
703 793
704 794 return ( $this_depth > $attributes['max_level'] || $exclude_headline || $this_depth < $attributes['min_level'] );
705 795 }
706 796
707 797 /**
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.
798 + * Filters headings down to TOC-visible entries while preserving anchor generation order.
709 799 *
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.
800 + * @param array $headings An array of headings to include in the table of contents.
801 + * @param array $attributes An array of attributes to customize the output.
802 + * @return array[] The headings that should be rendered in the table of contents.
715 803 */
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";
804 +function get_included_toc_headings( $headings, $attributes ) {
805 + $toc_headings = array();
806 + $headline_ids = new SimpleTOC_Headline_Ids();
807 +
808 + foreach ( $headings as $headline ) {
809 + $this_depth = simpletoc_get_heading_depth( $headline );
810 +
811 + if ( false === $this_depth ) {
812 + continue;
722 813 }
814 +
815 + $title = trim( wp_strip_all_tags( $headline ) );
816 + $custom_id = extract_id( $headline );
817 + $link = $custom_id ? $custom_id : $headline_ids->get_headline_anchor( $title );
818 +
819 + if ( should_exclude_headline( $headline, $attributes, $this_depth ) ) {
820 + continue;
821 + }
822 +
823 + $toc_headings[] = array(
824 + 'headline' => $headline,
825 + 'depth' => $this_depth,
826 + 'title' => $title,
827 + 'link' => $link,
828 + );
723 829 }
830 +
831 + return $toc_headings;
724 832 }
725 833
726 834 /**
727 - * Closes an HTML list tag and updates the list string and minimum depth variable as necessary.
835 + * Renders nested TOC list item markup from already-filtered headings.
728 836 *
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
837 + * @param array[] $toc_headings The headings that should be rendered in the table of contents.
838 + * @param string $list_type The type of list to be created, either "ul" (unordered list) or "ol".
839 + * @param string $absolute_url The optional absolute URL prefix for links.
840 + * @param int $min_depth The minimum heading depth included in the table of contents.
841 + * @return string The rendered nested list item markup.
740 842 */
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.
843 +function render_toc_list_items( $toc_headings, $list_type, $absolute_url, $min_depth ) {
844 + $list = '';
845 + $current_depth = null;
846 +
847 + foreach ( $toc_headings as $toc_heading ) {
848 + $this_depth = $toc_heading['depth'];
849 +
850 + if ( null === $current_depth ) {
851 + $current_depth = $this_depth;
852 + $list .= '<li>';
853 + } elseif ( $this_depth > $current_depth ) {
854 + for ( $current_depth; $current_depth < $this_depth; $current_depth++ ) {
855 + $list .= "\n<" . $list_type . ">\n<li>";
754 856 }
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";
857 + } elseif ( $this_depth === $current_depth ) {
858 + $list .= "</li>\n<li>";
859 + } else {
860 + for ( $current_depth; $current_depth > $this_depth; $current_depth-- ) {
861 + $list .= "</li>\n</" . $list_type . ">\n";
764 862 }
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 - }
863 + $list .= "</li>\n<li>";
774 864 }
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";
865 +
866 + $page = get_page_number_from_headline( $toc_heading['headline'] );
867 + $url = $absolute_url . $page . '#' . $toc_heading['link'];
868 + $href = $absolute_url ? esc_url( $url ) : esc_attr( $url );
869 + $list .= '<a href="' . $href . '">' . esc_html( $toc_heading['title'] ) . '</a>' . PHP_EOL;
870 + }
871 +
872 + if ( null !== $current_depth ) {
873 + for ( $current_depth; $current_depth > $min_depth; $current_depth-- ) {
874 + $list .= "</li>\n</" . $list_type . ">\n";
780 875 }
876 + $list .= '</li>';
781 877 }
878 +
879 + return $list;
782 880 }
783 881
784 882 /**
785 883 * Adds smooth scrolling styles to the output HTML, if enabled by global option or block attribute.
@@ -803,9 +901,9 @@
803 901 wp_enqueue_script(
804 902 'simpletoc-accordion',
805 903 plugin_dir_url( __FILE__ ) . 'assets/accordion.js',
806 904 array(),
807 - '6.9.0',
905 + SIMPLETOC_VERSION,
808 906 true
809 907 );
810 908
811 909 wp_enqueue_style(
@@ -811,9 +909,9 @@
811 909 wp_enqueue_style(
812 910 'simpletoc-accordion',
813 911 plugin_dir_url( __FILE__ ) . 'assets/accordion.css',
814 912 array(),
815 - '6.9.0'
913 + SIMPLETOC_VERSION
816 914 );
817 915 }
818 916
819 917 /**
@@ -829,9 +927,9 @@
829 927
830 928 if ( $is_hidden_enabled ) {
831 929 $title_text = $attributes['title_text'] ? esc_html( trim( $attributes['title_text'] ) ) : esc_html__( 'Table of Contents', 'simpletoc' );
832 930 $hidden_start = '<details class="simpletoc">
833 - <summary style="cursor: pointer;">' . $title_text . '</summary>';
931 + <summary>' . $title_text . '</summary>';
834 932 $html .= $hidden_start;
835 933 }
836 934
837 935 // If there are no items in the table of contents, return an empty string.
@@ -876,9 +974,9 @@
876 974 // Start and end HTML for accordion, if enabled.
877 975 $accordion_start = '';
878 976 if ( $is_accordion_enabled ) {
879 977 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">';
978 + $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 979 }
882 980
883 981 // Add the accordion start HTML to the output.
884 982 $html .= $accordion_start;
@@ -926,17 +1024,26 @@
926 1024 /**
927 1025 * Extracts the ID value from the provided heading HTML string.
928 1026 *
929 1027 * @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.
1028 + * @return string|false Returns the extracted ID value, or false if no ID value is found.
931 1029 */
932 1030 function extract_id( $headline ) {
933 - $pattern = '/id="([^"]*)"/';
934 - preg_match( $pattern, $headline, $matches );
935 - $id_value = $matches[1] ?? false;
1031 + $processor = simpletoc_get_html_tag_processor( $headline );
936 1032
937 - if ( false !== $id_value ) {
938 - return $id_value;
1033 + if ( $processor ) {
1034 +
1035 + while ( $processor->next_tag() ) {
1036 + if ( ! in_array( $processor->get_tag(), array( 'H1', 'H2', 'H3', 'H4', 'H5', 'H6' ), true ) ) {
1037 + continue;
1038 + }
1039 +
1040 + $id_value = $processor->get_attribute( 'id' );
1041 +
1042 + if ( is_string( $id_value ) ) {
1043 + return $id_value;
1044 + }
1045 + }
939 1046 }
940 1047
941 1048 return false;
942 1049 }
@@ -947,22 +1054,25 @@
947 1054 * @param string $headline The headline string.
948 1055 * @return string The page number (in the format "X/") if it exists and is greater than 1, or an empty string otherwise.
949 1056 */
950 1057 function get_page_number_from_headline( $headline ) {
951 - $dom = new \DOMDocument();
1058 + $processor = simpletoc_get_html_tag_processor( $headline );
952 1059
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 - }
1060 + if ( $processor ) {
958 1061
959 - $xpath = new \DOMXPath( $dom );
960 - $nodes = $xpath->query( '//*/@data-page' );
1062 + while ( $processor->next_tag() ) {
1063 + if ( ! in_array( $processor->get_tag(), array( 'H1', 'H2', 'H3', 'H4', 'H5', 'H6' ), true ) ) {
1064 + continue;
1065 + }
961 1066
962 - if ( isset( $nodes[0] ) && $nodes[0]->nodeValue > 1 ) {
963 - $page_number = $nodes[0]->nodeValue . '/';
964 - return esc_html( $page_number );
965 - } else {
1067 + $page_number = (int) $processor->get_attribute( 'data-page' );
1068 +
1069 + if ( $page_number > 1 ) {
1070 + return esc_html( $page_number . '/' );
1071 + }
1072 + }
1073 +
966 1074 return '';
967 1075 }
1076 +
1077 + return '';
968 1078 }