PluginProbe
SimpleTOC – Table of Contents Block / 7.2.0
SimpleTOC – Table of Contents Block v7.2.0
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 5.0.33 All 159 releases
simpletoc / plugin.php

plugin.php in SimpleTOC – Table of Contents Block 7.2.0, at plugin.php

1,052 lines 35.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Plugin Name: SimpleTOC - Table of Contents Block
4 * Plugin URI: https://marc.tv/simpletoc-wordpress-inhaltsverzeichnis-plugin-gutenberg/
5 * Description: SEO-friendly Table of Contents Gutenberg block. No JavaScript or CSS by default.
6 * Version: 7.2.0
7 * Requires at least: 6.2
8 * Requires PHP: 7.3
9 * Author: Marc Tönsing
10 * Author URI: https://toensing.com
11 * Text Domain: simpletoc
12 * License: GPL v2 or later
13 * License URI: http://www.gnu.org/licenses/gpl-2.0.html
14 *
15 * @package simpletoc
16 */
17
18 namespace MToensing\SimpleTOC;
19
20 require_once __DIR__ . '/simpletoc-admin-settings.php';
21 require_once __DIR__ . '/simpletoc-class-headline-ids.php';
22
23 const DEFAULT_BOX_COLOR = '#ebebeb';
24 const SIMPLETOC_VERSION = '7.2.0';
25
26 /**
27 * Prevents direct execution of the plugin file.
28 * If a WordPress function does not exist, it means that the file has not been run by WordPress.
29 */
30 if ( ! defined( 'ABSPATH' ) || ! function_exists( 'add_filter' ) ) {
31 header( 'Status: 403 Forbidden' );
32 header( 'HTTP/1.1 403 Forbidden' );
33 exit;
34 }
35
36 /**
37 * Registers the SimpleTOC block, adds a filter for plugin row meta, and sets script translations.
38 *
39 * This function registers the SimpleTOC block by specifying the build directory and render callback function.
40 * It also sets the script translations for the block editor script and adds a filter for the plugin row meta.
41 */
42 function register_simpletoc_block() {
43
44 if ( function_exists( 'wp_set_script_translations' ) ) {
45 wp_set_script_translations( 'simpletoc-toc-editor-script', 'simpletoc' );
46 }
47
48 add_filter( 'plugin_row_meta', __NAMESPACE__ . '\simpletoc_plugin_meta', 10, 2 );
49
50 register_block_type(
51 __DIR__ . '/build',
52 array(
53 'render_callback' => __NAMESPACE__ . '\render_callback_simpletoc',
54 )
55 );
56
57 wp_add_inline_script(
58 'simpletoc-toc-editor-script',
59 'window.simpletocEditorSettings = ' . wp_json_encode(
60 array(
61 'settingsUrl' => admin_url( 'options-general.php?page=simpletoc' ),
62 )
63 ) . ';',
64 'before'
65 );
66 }
67
68 add_action( 'init', __NAMESPACE__ . '\register_simpletoc_block' );
69
70 /**
71 * Adds SimpleTOC-specific block editor settings.
72 *
73 * @param array $editor_settings Default editor settings.
74 * @param \WP_Block_Editor_Context $editor_context Editor context.
75 *
76 * @return array
77 */
78 function add_simpletoc_block_editor_settings( $editor_settings, $editor_context ) {
79 $editor_settings['simpletocSettingsUrl'] = admin_url( 'options-general.php?page=simpletoc' );
80
81 return $editor_settings;
82 }
83
84 add_filter( 'block_editor_settings_all', __NAMESPACE__ . '\add_simpletoc_block_editor_settings', 10, 2 );
85
86 /**
87 * Inject potentially missing translations into the block-editor i18n
88 * collection.
89 *
90 * This keeps the plugin backwards compatible, in case the user did not
91 * update translations on their website (yet).
92 *
93 * @param string|false|null $translations JSON-encoded translation data. Default null.
94 * @param string|false $file Path to the translation file to load. False if there isn't one.
95 * @param string $handle Name of the script to register a translation domain to.
96 * @param string $domain The text domain.
97 *
98 * @return string|false|null JSON string
99 */
100 add_filter(
101 'load_script_translations',
102 function ( $translations, $file, $handle, $domain ) {
103 if ( 'simpletoc' === $domain && $translations ) {
104 // List of translations that we inject into the block-editor JS.
105 $dynamic_translations = array(
106 'Table of Contents' => __( 'Table of Contents', 'simpletoc' ),
107 );
108
109 $changed = false;
110 $obj = json_decode( $translations, true );
111
112 // Confirm that the translation JSON is valid.
113 if ( isset( $obj['locale_data'] ) && isset( $obj['locale_data']['messages'] ) ) {
114 $messages = $obj['locale_data']['messages'];
115
116 // Inject dynamic translations, when needed.
117 foreach ( $dynamic_translations as $key => $locale ) {
118 if ( empty( $messages[ $key ] )
119 || ! is_array( $messages[ $key ] )
120 || ! array_key_exists( 0, $messages[ $key ] )
121 || $locale !== $messages[ $key ][0]
122 ) {
123 $messages[ $key ] = array( $locale );
124 $changed = true;
125 }
126 }
127
128 // Only modify the translations string when locales did change.
129 if ( $changed ) {
130 $obj['locale_data']['messages'] = $messages;
131 $translations = wp_json_encode( $obj );
132 }
133 }
134 }
135
136 return $translations;
137 },
138 10,
139 4
140 );
141
142 /**
143 * Sets the default value of translatable attributes.
144 *
145 * Values inside block.json are static strings that are not translated. This
146 * filter inserts relevant translations i
147 *
148 * @param array $settings Array of determined settings for registering a block type.
149 * @param array $metadata Metadata provided for registering a block type.
150 *
151 * @return array Modified settings array.
152 */
153 add_filter(
154 'block_type_metadata_settings',
155 function ( $settings, $metadata ) {
156 if ( 'simpletoc/toc' === $metadata['name'] ) {
157 $settings['attributes']['title_text']['default'] = __( 'Table of Contents', 'simpletoc' );
158 }
159
160 return $settings;
161 },
162 10,
163 2
164 );
165
166 /**
167 * Filter to add plugins to the TOC list for Rank Math plugin.
168 *
169 * @param array $toc_plugins TOC plugins.
170 */
171 add_filter(
172 'rank_math/researches/toc_plugins',
173 function ( $toc_plugins ) {
174 $toc_plugins['simpletoc/plugin.php'] = 'SimpleTOC';
175 return $toc_plugins;
176 }
177 );
178
179
180
181 /**
182 * Adds IDs to the headings of the provided post content using a recursive block structure.
183 *
184 * @param string $content The content to add IDs to.
185 * @return string The content with IDs added to its headings
186 */
187 function simpletoc_add_ids_to_content( $content ) {
188
189 $blocks = parse_blocks( $content );
190
191 $blocks = add_ids_to_blocks_recursive( $blocks );
192
193 $content = serialize_blocks( $blocks );
194
195 return $content;
196 }
197
198 add_filter( 'the_content', __NAMESPACE__ . '\simpletoc_add_ids_to_content', 1 );
199
200 /**
201 * Recursively adds IDs to the headings of a nested block structure.
202 *
203 * @param array $blocks The blocks to add IDs to.
204 * @return array The blocks with IDs added to their headings
205 */
206 function add_ids_to_blocks_recursive( $blocks ) {
207
208 $supported_blocks = array(
209 'core/heading',
210 'generateblocks/text',
211 'generateblocks/headline',
212 );
213
214 /**
215 * Filter to add supported blocks for IDs.
216 *
217 * @param array $supported_blocks The array of supported blocks.
218 */
219 $supported_blocks = apply_filters( 'simpletoc_supported_blocks_for_ids', $supported_blocks );
220
221 // Need two separate instances so that IDs aren't double counted.
222 $inner_html_id_instance = new SimpleTOC_Headline_Ids();
223 $inner_content_id_instance = new SimpleTOC_Headline_Ids();
224
225 foreach ( $blocks as &$block ) {
226 if ( isset( $block['blockName'] ) && in_array( $block['blockName'], $supported_blocks, true ) && isset( $block['innerHTML'] ) && isset( $block['innerContent'] ) && isset( $block['innerContent'][0] ) ) {
227 $block['innerHTML'] = add_anchor_attribute( $block['innerHTML'], $inner_html_id_instance, $block );
228 $block['innerContent'][0] = add_anchor_attribute( $block['innerContent'][0], $inner_content_id_instance, $block );
229 } elseif ( ! empty( $block['innerBlocks'] ) ) {
230 // search in groups.
231 $block['innerBlocks'] = add_ids_to_blocks_recursive( $block['innerBlocks'] );
232 }
233 }
234
235 return $blocks;
236 }
237
238 /**
239 * Renders a Table of Contents block for a post
240 *
241 * @param array $attributes An array of attributes for the Table of Contents block.
242 * @return string The HTML output for the Table of Contents block
243 */
244 function render_callback_simpletoc( $attributes ) {
245 $is_backend = defined( 'REST_REQUEST' ) && REST_REQUEST && 'edit' === filter_input( INPUT_GET, 'context' );
246 $title_text = $attributes['title_text'] ? esc_html( trim( $attributes['title_text'] ) ) : __( 'Table of Contents', 'simpletoc' );
247 $alignclass = ! empty( $attributes['align'] ) ? 'align' . $attributes['align'] : '';
248 $title_level = $attributes['title_level'];
249 $global_box_style_enabled = apply_filters( 'simpletoc_box_style_enabled', false ) || true === (bool) get_option( 'simpletoc_box_style_enabled', false );
250 $box_style_enabled = $global_box_style_enabled || ! empty( $attributes['box_style'] );
251 $typography_enabled = ! empty( $attributes['fontSize'] ) || ! empty( $attributes['style']['typography'] );
252 $wrapper_classes = array( 'simpletoc' );
253 $wrapper_style = '';
254
255 if ( $typography_enabled ) {
256 $wrapper_classes[] = 'has-simpletoc-typography';
257 }
258
259 if ( $box_style_enabled ) {
260 $wrapper_classes[] = 'has-simpletoc-box-style';
261
262 if ( $global_box_style_enabled ) {
263 $wrapper_classes[] = 'has-background';
264 $wrapper_style = safecss_filter_attr( 'background-color:' . DEFAULT_BOX_COLOR . ';' );
265 } elseif ( ! empty( $attributes['box_color'] ) ) {
266 $wrapper_classes[] = 'has-background';
267 $wrapper_style = safecss_filter_attr( 'background-color:' . $attributes['box_color'] . ';' );
268 } else {
269 $wrapper_classes[] = 'has-background';
270 $wrapper_style = safecss_filter_attr( 'background-color:' . DEFAULT_BOX_COLOR . ';' );
271 }
272 }
273
274 $wrapper_attrs = get_block_wrapper_attributes(
275 array(
276 'class' => implode( ' ', $wrapper_classes ),
277 'style' => $wrapper_style,
278 )
279 );
280 $pre_html = '<div role="navigation" aria-label="' . esc_attr__( 'Table of Contents', 'simpletoc' ) . '" ' . $wrapper_attrs . '>';
281 $post_html = '</div>';
282
283 $post = get_post();
284 $blocks = ! is_null( $post ) && ! is_null( $post->post_content ) ? parse_blocks( $post->post_content ) : '';
285
286 $headings = array_reverse( filter_headings_recursive( $blocks ) );
287 $headings = simpletoc_add_pagenumber( $blocks, $headings );
288 $headings_clean = array_map( 'trim', $headings );
289 $toc_html = generate_toc( $headings_clean, $attributes );
290
291 if ( empty( $blocks ) ) {
292 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 );
293 }
294
295 if ( empty( $headings_clean ) ) {
296 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 );
297 }
298
299 if ( empty( $toc_html ) ) {
300 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 );
301 }
302
303 return $pre_html . $toc_html . $post_html;
304 }
305
306 /**
307 * Generates an HTML message for empty blocks cases in the Table of Contents.
308 *
309 * @param bool $is_backend Indicates if the request is from the backend (i.e., the WordPress editor).
310 * @param array $attributes An array of attributes for the Table of Contents block.
311 * @param int $title_level The heading level for the Table of Contents title.
312 * @param string $alignclass The CSS class for alignment of the Table of Contents block.
313 * @param string $title_text The text for the Table of Contents title.
314 * @param string $warning_text1 The first part of the warning message to be displayed.
315 * @param string $warning_text2 The second part of the warning message to be displayed.
316 * @param string $wrapper_attrs Block wrapper attributes.
317 *
318 * @return string The HTML output for the empty blocks message.
319 */
320 function get_empty_blocks_message( $is_backend, $attributes, $title_level, $alignclass, $title_text, $warning_text1, $warning_text2, $wrapper_attrs = '' ) {
321 $html = '';
322
323 if ( $is_backend ) {
324 $html .= '<div role="navigation" aria-label="' . esc_attr__( 'Table of Contents', 'simpletoc' ) . '" ' . $wrapper_attrs . '>';
325 $html .= sprintf( '<h%d class="%s">%s</h%d>', $title_level, esc_attr( trim( 'simpletoc-title ' . $alignclass ) ), $title_text, $title_level );
326 $html .= sprintf( '<p class="components-notice is-warning %s">%s %s</p>', esc_attr( $alignclass ), esc_html( $warning_text1 ), esc_html( $warning_text2 ) );
327 $html .= '</div>';
328 }
329
330 return $html;
331 }
332
333 /**
334 * Adds page numbers to headings in the provided blocks array.
335 *
336 * @param array $blocks The array of blocks to process.
337 * @param array $headings The array of headings to add page numbers to.
338 * @return array The modified headings array with page numbers added.
339 */
340 function simpletoc_add_pagenumber( $blocks, $headings ) {
341 $pages = 1;
342
343 if ( ! is_array( $blocks ) ) {
344 return $headings;
345 }
346
347 foreach ( $blocks as $block => $inner_block ) {
348 // count nextpage blocks.
349 if ( isset( $blocks[ $block ]['blockName'] ) && 'core/nextpage' === $blocks[ $block ]['blockName'] ) {
350 ++$pages;
351 }
352
353 if ( isset( $blocks[ $block ]['blockName'] ) && 'core/heading' === $blocks[ $block ]['blockName'] ) {
354 // make sure its a headline.
355 foreach ( $headings as $heading => &$inner_heading ) {
356 if ( $inner_heading === $blocks[ $block ]['innerHTML'] ) {
357 $inner_heading = simpletoc_add_page_number_to_headline( $blocks[ $block ]['innerHTML'], $pages );
358 }
359 }
360 }
361 }
362 return $headings;
363 }
364
365 /**
366 * Return all headings with a recursive walk through all blocks.
367 * This includes groups and reusable block with groups within reusable blocks.
368 *
369 * @param array[] $blocks The blocks to filter headings from.
370 * @return array[]
371 */
372 function filter_headings_recursive( $blocks ) {
373 $arr = array();
374
375 if ( ! is_array( $blocks ) ) {
376 return $arr;
377 }
378
379 // allow developers to ignore specific blocks.
380 $ignored_blocks = apply_filters( 'simpletoc_excluded_blocks', array() );
381
382 foreach ( $blocks as $inner_block ) {
383 if ( is_array( $inner_block ) ) {
384 // if block is ignored, skip.
385 if ( isset( $inner_block['blockName'] ) && in_array( $inner_block['blockName'], $ignored_blocks, true ) ) {
386 continue;
387 }
388
389 if ( isset( $inner_block['attrs']['ref'] ) ) {
390 // search in reusable blocks.
391 $post = get_post( $inner_block['attrs']['ref'] );
392 if ( $post ) {
393 $e_arr = parse_blocks( $post->post_content );
394 $arr = array_merge( filter_headings_recursive( $e_arr ), $arr );
395 }
396 } else {
397 // search in groups.
398 $arr = array_merge( filter_headings_recursive( $inner_block ), $arr );
399 }
400 } else {
401 if ( isset( $blocks['blockName'] ) && ( 'core/heading' === $blocks['blockName'] ) && 'core/heading' !== $inner_block && simpletoc_is_heading_html( $inner_block ) ) {
402 $arr[] = $inner_block;
403 }
404
405 $supported_third_party_blocks = array(
406 'generateblocks/headline', /* GenerateBlocks 1.x */
407 'generateblocks/text', /* GenerateBlocks 2.0 */
408 );
409
410 /**
411 * Filter to add supported third party blocks.
412 *
413 * @param array $supported_third_party_blocks The array of supported third party blocks.
414 * @return array The modified array of supported third party blocks.
415 */
416 $supported_third_party_blocks = apply_filters(
417 'simpletoc_supported_third_party_blocks',
418 $supported_third_party_blocks
419 );
420
421 if ( isset( $blocks['blockName'] ) && in_array( $blocks['blockName'], $supported_third_party_blocks, true ) && 'core/heading' !== $inner_block && simpletoc_is_heading_html( $inner_block ) ) {
422 $inner_block = simpletoc_maybe_replace_generateblocks_dynamic_tags( $inner_block, $blocks );
423 $arr[] = $inner_block;
424 }
425 }
426 }
427
428 return $arr;
429 }
430
431 /**
432 * Replaces GenerateBlocks dynamic tags in heading HTML before SimpleTOC uses it in the TOC.
433 *
434 * @param string $html The heading HTML.
435 * @param array $block The parsed block data.
436 * @return string The heading HTML with GenerateBlocks dynamic tags resolved when available.
437 */
438 function simpletoc_maybe_replace_generateblocks_dynamic_tags( $html, $block ) {
439 if ( ! class_exists( '\GenerateBlocks_Register_Dynamic_Tag' ) || false === strpos( $html, '{{' ) ) {
440 return $html;
441 }
442
443 return \GenerateBlocks_Register_Dynamic_Tag::replace_tags( $html, $block, null );
444 }
445
446 /**
447 * Gets heading HTML used for anchor generation.
448 *
449 * @param string $html The original heading HTML.
450 * @param array $block The parsed block data.
451 * @return string The heading HTML to use for anchor generation.
452 */
453 function simpletoc_get_heading_html_for_anchor( $html, $block ) {
454 $heading_html = simpletoc_maybe_replace_generateblocks_dynamic_tags( $html, $block );
455
456 if ( '' === trim( wp_strip_all_tags( $heading_html ) ) ) {
457 return $html;
458 }
459
460 return $heading_html;
461 }
462
463 /**
464 * Sanitizes a string to be used as an anchor attribute in HTML by removing punctuation, non-breaking spaces, umlauts, and accents,
465 * and replacing whitespace and other characters with dashes.
466 *
467 * @param string $string_to_sanitize The input string to be sanitized.
468 * @return string The sanitized string encoded for use in a URL.
469 */
470 function simpletoc_sanitize_string( $string_to_sanitize ) {
471 // remove punctuation.
472 $zero_punctuation = preg_replace( '/\p{P}/u', '', $string_to_sanitize );
473 // remove non-breaking spaces.
474 $html_wo_nbs = str_replace( '&nbsp;', ' ', $zero_punctuation );
475 // remove umlauts and accents.
476 $string_without_accents = remove_accents( $html_wo_nbs );
477 // Sanitizes a title, replacing whitespace and a few other characters with dashes.
478 $sanitized_string = sanitize_title_with_dashes( $string_without_accents );
479 // Encode for use in an url.
480 $urlencoded = rawurlencode( $sanitized_string );
481 return $urlencoded;
482 }
483
484 /**
485 * Add additional plugin meta links to the SimpleTOC plugin page.
486 *
487 * @param array $links An array of plugin meta links.
488 * @param string $file The plugin file path.
489 * @return array The modified array of plugin meta links.
490 */
491 function simpletoc_plugin_meta( $links, $file ) {
492
493 if ( false !== strpos( $file, 'simpletoc' ) ) {
494 $links = array_merge( $links, array( '<a href="https://wordpress.org/support/plugin/simpletoc">' . esc_html__( 'Support', 'simpletoc' ) . '</a>' ) );
495 $links = array_merge( $links, array( '<a href="https://marc.tv/out/donate">' . esc_html__( 'Donate', 'simpletoc' ) . '</a>' ) );
496 $links = array_merge( $links, array( '<a href="https://wordpress.org/support/plugin/simpletoc/reviews/#new-post">' . esc_html__( 'Write a review', 'simpletoc' ) . '&nbsp;⭐️⭐️⭐️⭐️⭐️</a>' ) );
497 }
498
499 return $links;
500 }
501
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 /**
629 * Adds an ID attribute to all Heading tags in the provided HTML.
630 *
631 * @param string $html The HTML content to modify.
632 * @param SimpleTOC_Headline_Ids $headline_class_instance The instance of the SimpleTOC_Headline_Ids class.
633 * @param array $block The parsed block data.
634 * @return string The modified HTML content with ID attributes added to the Heading tags
635 */
636 function add_anchor_attribute( $html, $headline_class_instance = null, $block = array() ) {
637 if ( ! is_string( $html ) ) {
638 return $html;
639 }
640
641 // remove non-breaking space entites from input HTML.
642 $html_wo_nbs = str_replace( '&nbsp;', ' ', $html );
643
644 // Thank you Nick Diego.
645 if ( ! $html_wo_nbs ) {
646 return $html;
647 }
648
649 $processor = simpletoc_get_html_tag_processor( $html_wo_nbs );
650
651 if ( $processor ) {
652
653 while ( $processor->next_tag() ) {
654 if ( ! in_array( $processor->get_tag(), array( 'H1', 'H2', 'H3', 'H4', 'H5', 'H6' ), true ) ) {
655 continue;
656 }
657
658 // If tag already has an attribute "id" defined, no need for creating a new one.
659 if ( ! empty( $processor->get_attribute( 'id' ) ) ) {
660 continue;
661 }
662
663 $heading_html = simpletoc_get_heading_html_for_anchor( $html, $block );
664 $heading_text = trim( wp_strip_all_tags( $heading_html ) );
665 $anchor = $headline_class_instance->get_headline_anchor( $heading_text );
666 $processor->set_attribute( 'id', $anchor );
667 }
668
669 return $processor->get_updated_html();
670 }
671
672 return $html;
673 }
674
675 /**
676 * Generates a table of contents based on the provided headings and attributes
677 *
678 * @param array $headings An array of headings to include in the table of contents.
679 * @param array $attributes An array of attributes to customize the output.
680 * @return string The generated table of contents as HTML
681 */
682 function generate_toc( $headings, $attributes ) {
683 $list = '';
684 $html = '';
685 $min_depth = 6;
686 $initial_depth = 6;
687 $align_class = isset( $attributes['align'] ) ? 'align' . $attributes['align'] : '';
688 $styles = $attributes['remove_indent'] ? 'style="padding-left:0;list-style:none;"' : '';
689 $list_type = $attributes['use_ol'] ? 'ol' : 'ul';
690 $global_absolut_urls_enabled = get_option( 'simpletoc_absolute_urls_enabled', false );
691 $absolute_url = $attributes['use_absolute_urls'] || $global_absolut_urls_enabled ? get_permalink() : '';
692
693 $toc_headings = get_included_toc_headings( $headings, $attributes );
694 list($min_depth, $initial_depth) = find_min_depth( $toc_headings, $attributes );
695
696 $item_count = count( $toc_headings );
697 $list = render_toc_list_items( $toc_headings, $list_type, $absolute_url, $min_depth );
698
699 $html = add_accordion_start( $html, $attributes, $item_count, $align_class );
700 $html = add_hidden_markup_start( $html, $attributes, $item_count, $align_class );
701 $html = add_smooth( $html, $attributes );
702
703 // Add the table of contents list to the output if the list is not empty.
704 if ( ! empty( $list ) ) {
705 $html_class = 'simpletoc-list';
706 if ( ! empty( $align_class ) ) {
707 $html_class .= " $align_class";
708 }
709
710 $html_style = '';
711 if ( ! empty( $styles ) ) {
712 $html_style = " $styles";
713 }
714
715 $html .= "<$list_type class=\"$html_class\"$html_style>\n$list</$list_type>";
716 }
717
718 $html = add_accordion_end( $html, $attributes );
719 $html = add_hidden_markup_end( $html, $attributes );
720
721 // return an emtpy string if stripped result is empty.
722 if ( empty( trim( wp_strip_all_tags( $html ) ) ) ) {
723 $html = '';
724 }
725
726 return $html;
727 }
728
729 /**
730 * Finds the minimum depth level of headings in the provided array and adjusts it based on the provided attributes
731 *
732 * @param array $headings An array of headings to search through.
733 * @param array $attributes An array of attributes to adjust the minimum depth level.
734 * @return array An array containing the minimum depth level and the initial depth level.
735 */
736 function find_min_depth( $headings, $attributes ) {
737 $min_depth = 6;
738 $initial_depth = 6;
739
740 foreach ( $headings as $line => $headline ) {
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;
744 $initial_depth = $min_depth;
745 }
746 }
747
748 if ( $attributes['min_level'] > $min_depth ) {
749 $min_depth = $attributes['min_level'];
750 $initial_depth = $min_depth;
751 }
752
753 return array( $min_depth, $initial_depth );
754 }
755
756 /**
757 * Determines if a given headline should be excluded based on the provided attributes.
758 *
759 * @param string $headline The headline to check for exclusion.
760 * @param array $attributes An array of attributes to use for exclusion.
761 * @param int $this_depth The depth level of the headline.
762 * @return bool True if the headline should be excluded, false otherwise.
763 */
764 function should_exclude_headline( $headline, $attributes, $this_depth ) {
765 $exclude_headline = simpletoc_heading_has_class( $headline, 'simpletoc-hidden' );
766
767 return ( $this_depth > $attributes['max_level'] || $exclude_headline || $this_depth < $attributes['min_level'] );
768 }
769
770 /**
771 * Filters headings down to TOC-visible entries while preserving anchor generation order.
772 *
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.
776 */
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;
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 );
802 }
803
804 return $toc_headings;
805 }
806
807 /**
808 * Renders nested TOC list item markup from already-filtered headings.
809 *
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.
815 */
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>";
829 }
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";
835 }
836 $list .= "</li>\n<li>";
837 }
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";
848 }
849 $list .= '</li>';
850 }
851
852 return $list;
853 }
854
855 /**
856 * Adds smooth scrolling styles to the output HTML, if enabled by global option or block attribute.
857 *
858 * @param string $html The HTML string to which the styles will be added.
859 * @param array $attributes An array of block attributes.
860 * @return string The modified HTML string with the added smooth scrolling styles.
861 */
862 function add_smooth( $html, $attributes ) {
863 // Add smooth scrolling styles, if enabled by global option or block attribute.
864 $is_smooth_enabled = $attributes['add_smooth'] || true === (bool) get_option( 'simpletoc_smooth_enabled', false );
865 $html .= $is_smooth_enabled ? '<style>html { scroll-behavior: smooth; }</style>' : '';
866
867 return $html;
868 }
869
870 /**
871 * Enqueues the necessary CSS and JS files for the accordion functionality on the frontend.
872 */
873 function enqueue_accordion_frontend() {
874 wp_enqueue_script(
875 'simpletoc-accordion',
876 plugin_dir_url( __FILE__ ) . 'assets/accordion.js',
877 array(),
878 SIMPLETOC_VERSION,
879 true
880 );
881
882 wp_enqueue_style(
883 'simpletoc-accordion',
884 plugin_dir_url( __FILE__ ) . 'assets/accordion.css',
885 array(),
886 SIMPLETOC_VERSION
887 );
888 }
889
890 /**
891 * Adds the opening HTML tag(s) for the hidden markup element and the table of contents title, if applicable.
892 *
893 * @param string $html The HTML string to add the opening tag(s) to.
894 * @param array $attributes The attributes of the table of contents block.
895 * @param int $itemcount The number of items in the table of contents.
896 * @param string $alignclass The alignment class for the table of contents block.
897 */
898 function add_hidden_markup_start( $html, $attributes, $itemcount, $alignclass ) { // phpcs:ignore.
899 $is_hidden_enabled = $attributes['hidden'];
900
901 if ( $is_hidden_enabled ) {
902 $title_text = $attributes['title_text'] ? esc_html( trim( $attributes['title_text'] ) ) : esc_html__( 'Table of Contents', 'simpletoc' );
903 $hidden_start = '<details class="simpletoc">
904 <summary>' . $title_text . '</summary>';
905 $html .= $hidden_start;
906 }
907
908 // If there are no items in the table of contents, return an empty string.
909 if ( $itemcount < 1 ) {
910 return '';
911 }
912
913 return $html;
914 }
915
916 /**
917 * Adds the closing HTML tag(s) for the hidden markup element if the hidden markup is enabled.
918 *
919 * @param string $html The HTML string to add the closing tag(s) to.
920 * @param array $attributes The attributes of the table of contents block.
921 * @return string The modified HTML string with the closing tag(s) added.
922 */
923 function add_hidden_markup_end( $html, $attributes ) {
924 $is_hidden_enabled = $attributes['hidden'];
925
926 if ( $is_hidden_enabled ) {
927 $html .= '</details>';
928 }
929
930 return $html;
931 }
932
933 /**
934 * Adds the opening HTML tag(s) for the accordion element and the table of contents title, if applicable.
935 *
936 * @param string $html The HTML string to add the opening tag(s) to.
937 * @param array $attributes The attributes of the table of contents block.
938 * @param int $itemcount The number of items in the table of contents.
939 * @param string $alignclass The alignment class for the table of contents block.
940 */
941 function add_accordion_start( $html, $attributes, $itemcount, $alignclass ) {
942 // Check if accordion is enabled either through the function arguments or the options.
943 $is_accordion_enabled = $attributes['accordion'] || true === (bool) get_option( 'simpletoc_accordion_enabled', false );
944 $is_hidden_enabled = $attributes['hidden'];
945 $title_text = $attributes['title_text'] ? esc_html( trim( $attributes['title_text'] ) ) : esc_html__( 'Table of Contents', 'simpletoc' );
946
947 // Start and end HTML for accordion, if enabled.
948 $accordion_start = '';
949 if ( $is_accordion_enabled ) {
950 enqueue_accordion_frontend();
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">';
952 }
953
954 // Add the accordion start HTML to the output.
955 $html .= $accordion_start;
956
957 // Add the table of contents title, if not hidden and not in accordion mode.
958 $show_title = ! $attributes['no_title'] && ! $is_accordion_enabled && ! $is_hidden_enabled;
959 if ( $show_title ) {
960 $title_tag = $attributes['title_level'] > 0 ? "h{$attributes['title_level']}" : 'p';
961 $title_tag = wp_strip_all_tags( $title_tag );
962 $html_class = 'simpletoc-title';
963
964 if ( ! empty( $alignclass ) ) {
965 $html_class .= " $alignclass";
966 }
967
968 $html = "<$title_tag class=\"$html_class\">$title_text</$title_tag>\n";
969 }
970
971 // If there are no items in the table of contents, return an empty string.
972 if ( $itemcount < 1 ) {
973 return '';
974 }
975
976 return $html;
977 }
978
979 /**
980 * Adds the closing HTML tag(s) for the accordion element if the accordion is enabled.
981 *
982 * @param string $html The HTML string to add the closing tag(s) to.
983 * @param array $attributes The attributes of the table of contents block.
984 * @return string The modified HTML string with the closing tag(s) added
985 */
986 function add_accordion_end( $html, $attributes ) {
987 // Check if accordion is enabled either through the function arguments or the options.
988 $is_accordion_enabled = $attributes['accordion'] || true === (bool) get_option( 'simpletoc_accordion_enabled', false );
989
990 if ( $is_accordion_enabled ) {
991 $html .= '</div>';
992 }
993
994 return $html;
995 }
996
997 /**
998 * Extracts the ID value from the provided heading HTML string.
999 *
1000 * @param string $headline The heading HTML string to extract the ID value from.
1001 * @return string|false Returns the extracted ID value, or false if no ID value is found.
1002 */
1003 function extract_id( $headline ) {
1004 $processor = simpletoc_get_html_tag_processor( $headline );
1005
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 }
1019 }
1020
1021 return false;
1022 }
1023
1024 /**
1025 * Gets the page number from a headline string.
1026 *
1027 * @param string $headline The headline string.
1028 * @return string The page number (in the format "X/") if it exists and is greater than 1, or an empty string otherwise.
1029 */
1030 function get_page_number_from_headline( $headline ) {
1031 $processor = simpletoc_get_html_tag_processor( $headline );
1032
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 $page_number = (int) $processor->get_attribute( 'data-page' );
1041
1042 if ( $page_number > 1 ) {
1043 return esc_html( $page_number . '/' );
1044 }
1045 }
1046
1047 return '';
1048 }
1049
1050 return '';
1051 }
1052