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

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

1,057 lines 35.8 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.1.1
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.1.1';
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 $class_name = ! empty( $attributes['className'] ) ? wp_strip_all_tags( $attributes['className'] ) : '';
249 $title_level = $attributes['title_level'];
250 $global_box_style_enabled = apply_filters( 'simpletoc_box_style_enabled', false ) || true === (bool) get_option( 'simpletoc_box_style_enabled', false );
251 $box_style_enabled = $global_box_style_enabled || ! empty( $attributes['box_style'] );
252 $wrapper_classes = array( 'simpletoc' );
253 $wrapper_style = '';
254
255 if ( $box_style_enabled ) {
256 $wrapper_classes[] = 'has-simpletoc-box-style';
257
258 if ( $global_box_style_enabled ) {
259 $wrapper_classes[] = 'has-background';
260 $wrapper_style = safecss_filter_attr( 'background-color:' . DEFAULT_BOX_COLOR . ';' );
261 } elseif ( ! empty( $attributes['box_color'] ) ) {
262 $wrapper_classes[] = 'has-background';
263 $wrapper_style = safecss_filter_attr( 'background-color:' . $attributes['box_color'] . ';' );
264 } else {
265 $wrapper_classes[] = 'has-background';
266 $wrapper_style = safecss_filter_attr( 'background-color:' . DEFAULT_BOX_COLOR . ';' );
267 }
268 }
269
270 $wrapper_enabled = apply_filters( 'simpletoc_wrapper_enabled', false ) || true === (bool) get_option( 'simpletoc_wrapper_enabled', false ) || true === (bool) get_option( 'simpletoc_accordion_enabled', false );
271 $wrapper_attrs = get_block_wrapper_attributes(
272 array(
273 'class' => implode( ' ', $wrapper_classes ),
274 'style' => $wrapper_style,
275 )
276 );
277 $has_wrapper = ! empty( $class_name ) || $wrapper_enabled || $attributes['accordion'] || $attributes['wrapper'] || $box_style_enabled;
278 $pre_html = $has_wrapper ? '<div role="navigation" aria-label="' . esc_attr__( 'Table of Contents', 'simpletoc' ) . '" ' . $wrapper_attrs . '>' : '';
279 $post_html = $has_wrapper ? '</div>' : '';
280
281 $post = get_post();
282 $blocks = ! is_null( $post ) && ! is_null( $post->post_content ) ? parse_blocks( $post->post_content ) : '';
283
284 $headings = array_reverse( filter_headings_recursive( $blocks ) );
285 $headings = simpletoc_add_pagenumber( $blocks, $headings );
286 $headings_clean = array_map( 'trim', $headings );
287 $toc_html = generate_toc( $headings_clean, $attributes );
288
289 if ( empty( $blocks ) ) {
290 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 );
291 }
292
293 if ( empty( $headings_clean ) ) {
294 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 );
295 }
296
297 if ( empty( $toc_html ) ) {
298 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 );
299 }
300
301 return $pre_html . $toc_html . $post_html;
302 }
303
304 /**
305 * Generates an HTML message for empty blocks cases in the Table of Contents.
306 *
307 * @param bool $is_backend Indicates if the request is from the backend (i.e., the WordPress editor).
308 * @param array $attributes An array of attributes for the Table of Contents block.
309 * @param int $title_level The heading level for the Table of Contents title.
310 * @param string $alignclass The CSS class for alignment of the Table of Contents block.
311 * @param string $title_text The text for the Table of Contents title.
312 * @param string $warning_text1 The first part of the warning message to be displayed.
313 * @param string $warning_text2 The second part of the warning message to be displayed.
314 * @param string $wrapper_attrs Wrapper attributes for the optional block wrapper.
315 * @param bool $has_wrapper Indicates if the wrapper should be rendered.
316 *
317 * @return string The HTML output for the empty blocks message.
318 */
319 function get_empty_blocks_message( $is_backend, $attributes, $title_level, $alignclass, $title_text, $warning_text1, $warning_text2, $wrapper_attrs = '', $has_wrapper = false ) {
320 $html = '';
321
322 if ( $is_backend ) {
323 if ( $has_wrapper ) {
324 $html .= '<div role="navigation" aria-label="' . esc_attr__( 'Table of Contents', 'simpletoc' ) . '" ' . $wrapper_attrs . '>';
325 }
326
327 $html .= sprintf( '<h%d class="%s">%s</h%d>', $title_level, esc_attr( trim( 'simpletoc-title ' . $alignclass ) ), $title_text, $title_level );
328 $html .= sprintf( '<p class="components-notice is-warning %s">%s %s</p>', esc_attr( $alignclass ), esc_html( $warning_text1 ), esc_html( $warning_text2 ) );
329
330 if ( $has_wrapper ) {
331 $html .= '</div>';
332 }
333 }
334
335 return $html;
336 }
337
338 /**
339 * Adds page numbers to headings in the provided blocks array.
340 *
341 * @param array $blocks The array of blocks to process.
342 * @param array $headings The array of headings to add page numbers to.
343 * @return array The modified headings array with page numbers added.
344 */
345 function simpletoc_add_pagenumber( $blocks, $headings ) {
346 $pages = 1;
347
348 if ( ! is_array( $blocks ) ) {
349 return $headings;
350 }
351
352 foreach ( $blocks as $block => $inner_block ) {
353 // count nextpage blocks.
354 if ( isset( $blocks[ $block ]['blockName'] ) && 'core/nextpage' === $blocks[ $block ]['blockName'] ) {
355 ++$pages;
356 }
357
358 if ( isset( $blocks[ $block ]['blockName'] ) && 'core/heading' === $blocks[ $block ]['blockName'] ) {
359 // make sure its a headline.
360 foreach ( $headings as $heading => &$inner_heading ) {
361 if ( $inner_heading === $blocks[ $block ]['innerHTML'] ) {
362 $inner_heading = simpletoc_add_page_number_to_headline( $blocks[ $block ]['innerHTML'], $pages );
363 }
364 }
365 }
366 }
367 return $headings;
368 }
369
370 /**
371 * Return all headings with a recursive walk through all blocks.
372 * This includes groups and reusable block with groups within reusable blocks.
373 *
374 * @param array[] $blocks The blocks to filter headings from.
375 * @return array[]
376 */
377 function filter_headings_recursive( $blocks ) {
378 $arr = array();
379
380 if ( ! is_array( $blocks ) ) {
381 return $arr;
382 }
383
384 // allow developers to ignore specific blocks.
385 $ignored_blocks = apply_filters( 'simpletoc_excluded_blocks', array() );
386
387 foreach ( $blocks as $inner_block ) {
388 if ( is_array( $inner_block ) ) {
389 // if block is ignored, skip.
390 if ( isset( $inner_block['blockName'] ) && in_array( $inner_block['blockName'], $ignored_blocks, true ) ) {
391 continue;
392 }
393
394 if ( isset( $inner_block['attrs']['ref'] ) ) {
395 // search in reusable blocks.
396 $post = get_post( $inner_block['attrs']['ref'] );
397 if ( $post ) {
398 $e_arr = parse_blocks( $post->post_content );
399 $arr = array_merge( filter_headings_recursive( $e_arr ), $arr );
400 }
401 } else {
402 // search in groups.
403 $arr = array_merge( filter_headings_recursive( $inner_block ), $arr );
404 }
405 } else {
406 if ( isset( $blocks['blockName'] ) && ( 'core/heading' === $blocks['blockName'] ) && 'core/heading' !== $inner_block && simpletoc_is_heading_html( $inner_block ) ) {
407 $arr[] = $inner_block;
408 }
409
410 $supported_third_party_blocks = array(
411 'generateblocks/headline', /* GenerateBlocks 1.x */
412 'generateblocks/text', /* GenerateBlocks 2.0 */
413 );
414
415 /**
416 * Filter to add supported third party blocks.
417 *
418 * @param array $supported_third_party_blocks The array of supported third party blocks.
419 * @return array The modified array of supported third party blocks.
420 */
421 $supported_third_party_blocks = apply_filters(
422 'simpletoc_supported_third_party_blocks',
423 $supported_third_party_blocks
424 );
425
426 if ( isset( $blocks['blockName'] ) && in_array( $blocks['blockName'], $supported_third_party_blocks, true ) && 'core/heading' !== $inner_block && simpletoc_is_heading_html( $inner_block ) ) {
427 $inner_block = simpletoc_maybe_replace_generateblocks_dynamic_tags( $inner_block, $blocks );
428 $arr[] = $inner_block;
429 }
430 }
431 }
432
433 return $arr;
434 }
435
436 /**
437 * Replaces GenerateBlocks dynamic tags in heading HTML before SimpleTOC uses it in the TOC.
438 *
439 * @param string $html The heading HTML.
440 * @param array $block The parsed block data.
441 * @return string The heading HTML with GenerateBlocks dynamic tags resolved when available.
442 */
443 function simpletoc_maybe_replace_generateblocks_dynamic_tags( $html, $block ) {
444 if ( ! class_exists( '\GenerateBlocks_Register_Dynamic_Tag' ) || false === strpos( $html, '{{' ) ) {
445 return $html;
446 }
447
448 return \GenerateBlocks_Register_Dynamic_Tag::replace_tags( $html, $block, null );
449 }
450
451 /**
452 * Gets heading HTML used for anchor generation.
453 *
454 * @param string $html The original heading HTML.
455 * @param array $block The parsed block data.
456 * @return string The heading HTML to use for anchor generation.
457 */
458 function simpletoc_get_heading_html_for_anchor( $html, $block ) {
459 $heading_html = simpletoc_maybe_replace_generateblocks_dynamic_tags( $html, $block );
460
461 if ( '' === trim( wp_strip_all_tags( $heading_html ) ) ) {
462 return $html;
463 }
464
465 return $heading_html;
466 }
467
468 /**
469 * Sanitizes a string to be used as an anchor attribute in HTML by removing punctuation, non-breaking spaces, umlauts, and accents,
470 * and replacing whitespace and other characters with dashes.
471 *
472 * @param string $string_to_sanitize The input string to be sanitized.
473 * @return string The sanitized string encoded for use in a URL.
474 */
475 function simpletoc_sanitize_string( $string_to_sanitize ) {
476 // remove punctuation.
477 $zero_punctuation = preg_replace( '/\p{P}/u', '', $string_to_sanitize );
478 // remove non-breaking spaces.
479 $html_wo_nbs = str_replace( '&nbsp;', ' ', $zero_punctuation );
480 // remove umlauts and accents.
481 $string_without_accents = remove_accents( $html_wo_nbs );
482 // Sanitizes a title, replacing whitespace and a few other characters with dashes.
483 $sanitized_string = sanitize_title_with_dashes( $string_without_accents );
484 // Encode for use in an url.
485 $urlencoded = rawurlencode( $sanitized_string );
486 return $urlencoded;
487 }
488
489 /**
490 * Add additional plugin meta links to the SimpleTOC plugin page.
491 *
492 * @param array $links An array of plugin meta links.
493 * @param string $file The plugin file path.
494 * @return array The modified array of plugin meta links.
495 */
496 function simpletoc_plugin_meta( $links, $file ) {
497
498 if ( false !== strpos( $file, 'simpletoc' ) ) {
499 $links = array_merge( $links, array( '<a href="https://wordpress.org/support/plugin/simpletoc">' . esc_html__( 'Support', 'simpletoc' ) . '</a>' ) );
500 $links = array_merge( $links, array( '<a href="https://marc.tv/out/donate">' . esc_html__( 'Donate', 'simpletoc' ) . '</a>' ) );
501 $links = array_merge( $links, array( '<a href="https://wordpress.org/support/plugin/simpletoc/reviews/#new-post">' . esc_html__( 'Write a review', 'simpletoc' ) . '&nbsp;⭐️⭐️⭐️⭐️⭐️</a>' ) );
502 }
503
504 return $links;
505 }
506
507 /**
508 * Loads the WordPress HTML Tag Processor when available.
509 *
510 * @return bool True when the HTML Tag Processor can be used.
511 */
512 function simpletoc_load_html_tag_processor() {
513 if ( class_exists( '\WP_HTML_Tag_Processor' ) ) {
514 return true;
515 }
516
517 if ( defined( 'ABSPATH' ) && defined( 'WPINC' ) ) {
518 $html_tag_processor_file = ABSPATH . WPINC . '/html-api/class-wp-html-tag-processor.php';
519
520 if ( file_exists( $html_tag_processor_file ) ) {
521 require_once $html_tag_processor_file;
522 }
523 }
524
525 return class_exists( '\WP_HTML_Tag_Processor' );
526 }
527
528 /**
529 * Creates an HTML Tag Processor for a valid HTML fragment.
530 *
531 * Parsed block content can contain non-string placeholders for nested blocks.
532 * The WordPress HTML API accepts strings only.
533 *
534 * @param mixed $html The HTML fragment to inspect.
535 * @return \WP_HTML_Tag_Processor|null The processor, or null when unavailable or invalid.
536 */
537 function simpletoc_get_html_tag_processor( $html ) {
538 if ( ! is_string( $html ) || ! simpletoc_load_html_tag_processor() ) {
539 return null;
540 }
541
542 return new \WP_HTML_Tag_Processor( $html );
543 }
544
545 /**
546 * Returns true when the provided HTML contains a heading tag.
547 *
548 * @param string $html The HTML to inspect.
549 * @return bool True when the HTML contains a heading tag.
550 */
551 function simpletoc_is_heading_html( $html ) {
552 return false !== simpletoc_get_heading_depth( $html );
553 }
554
555 /**
556 * Gets the first heading depth from an HTML fragment.
557 *
558 * @param string $html The HTML to inspect.
559 * @return int|false The heading depth, or false when no heading was found.
560 */
561 function simpletoc_get_heading_depth( $html ) {
562 $processor = simpletoc_get_html_tag_processor( $html );
563
564 if ( $processor ) {
565
566 while ( $processor->next_tag() ) {
567 $tag_name = $processor->get_tag();
568
569 if ( in_array( $tag_name, array( 'H1', 'H2', 'H3', 'H4', 'H5', 'H6' ), true ) ) {
570 return (int) substr( $tag_name, 1 );
571 }
572 }
573 }
574
575 return false;
576 }
577
578 /**
579 * Adds a data-page attribute to the first heading in an HTML fragment.
580 *
581 * @param string $html The heading HTML.
582 * @param int $page_number The page number to set.
583 * @return string The updated HTML.
584 */
585 function simpletoc_add_page_number_to_headline( $html, $page_number ) {
586 $processor = simpletoc_get_html_tag_processor( $html );
587
588 if ( $processor ) {
589
590 while ( $processor->next_tag() ) {
591 if ( ! in_array( $processor->get_tag(), array( 'H1', 'H2', 'H3', 'H4', 'H5', 'H6' ), true ) ) {
592 continue;
593 }
594
595 $processor->set_attribute( 'data-page', (string) $page_number );
596 return $processor->get_updated_html();
597 }
598 }
599
600 return $html;
601 }
602
603 /**
604 * Checks whether the first heading in an HTML fragment has the provided class.
605 *
606 * @param string $html The heading HTML.
607 * @param string $class_name The class name to find.
608 * @return bool True when the class exists.
609 */
610 function simpletoc_heading_has_class( $html, $class_name ) {
611 $processor = simpletoc_get_html_tag_processor( $html );
612
613 if ( $processor ) {
614
615 while ( $processor->next_tag() ) {
616 if ( ! in_array( $processor->get_tag(), array( 'H1', 'H2', 'H3', 'H4', 'H5', 'H6' ), true ) ) {
617 continue;
618 }
619
620 $class_attribute = $processor->get_attribute( 'class' );
621
622 if ( ! is_string( $class_attribute ) ) {
623 return false;
624 }
625
626 return in_array( $class_name, preg_split( '/\s+/', trim( $class_attribute ) ), true );
627 }
628 }
629
630 return false;
631 }
632
633 /**
634 * Adds an ID attribute to all Heading tags in the provided HTML.
635 *
636 * @param string $html The HTML content to modify.
637 * @param SimpleTOC_Headline_Ids $headline_class_instance The instance of the SimpleTOC_Headline_Ids class.
638 * @param array $block The parsed block data.
639 * @return string The modified HTML content with ID attributes added to the Heading tags
640 */
641 function add_anchor_attribute( $html, $headline_class_instance = null, $block = array() ) {
642 if ( ! is_string( $html ) ) {
643 return $html;
644 }
645
646 // remove non-breaking space entites from input HTML.
647 $html_wo_nbs = str_replace( '&nbsp;', ' ', $html );
648
649 // Thank you Nick Diego.
650 if ( ! $html_wo_nbs ) {
651 return $html;
652 }
653
654 $processor = simpletoc_get_html_tag_processor( $html_wo_nbs );
655
656 if ( $processor ) {
657
658 while ( $processor->next_tag() ) {
659 if ( ! in_array( $processor->get_tag(), array( 'H1', 'H2', 'H3', 'H4', 'H5', 'H6' ), true ) ) {
660 continue;
661 }
662
663 // If tag already has an attribute "id" defined, no need for creating a new one.
664 if ( ! empty( $processor->get_attribute( 'id' ) ) ) {
665 continue;
666 }
667
668 $heading_html = simpletoc_get_heading_html_for_anchor( $html, $block );
669 $heading_text = trim( wp_strip_all_tags( $heading_html ) );
670 $anchor = $headline_class_instance->get_headline_anchor( $heading_text );
671 $processor->set_attribute( 'id', $anchor );
672 }
673
674 return $processor->get_updated_html();
675 }
676
677 return $html;
678 }
679
680 /**
681 * Generates a table of contents based on the provided headings and attributes
682 *
683 * @param array $headings An array of headings to include in the table of contents.
684 * @param array $attributes An array of attributes to customize the output.
685 * @return string The generated table of contents as HTML
686 */
687 function generate_toc( $headings, $attributes ) {
688 $list = '';
689 $html = '';
690 $min_depth = 6;
691 $initial_depth = 6;
692 $align_class = isset( $attributes['align'] ) ? 'align' . $attributes['align'] : '';
693 $styles = $attributes['remove_indent'] ? 'style="padding-left:0;list-style:none;"' : '';
694 $list_type = $attributes['use_ol'] ? 'ol' : 'ul';
695 $global_absolut_urls_enabled = get_option( 'simpletoc_absolute_urls_enabled', false );
696 $absolute_url = $attributes['use_absolute_urls'] || $global_absolut_urls_enabled ? get_permalink() : '';
697
698 $toc_headings = get_included_toc_headings( $headings, $attributes );
699 list($min_depth, $initial_depth) = find_min_depth( $toc_headings, $attributes );
700
701 $item_count = count( $toc_headings );
702 $list = render_toc_list_items( $toc_headings, $list_type, $absolute_url, $min_depth );
703
704 $html = add_accordion_start( $html, $attributes, $item_count, $align_class );
705 $html = add_hidden_markup_start( $html, $attributes, $item_count, $align_class );
706 $html = add_smooth( $html, $attributes );
707
708 // Add the table of contents list to the output if the list is not empty.
709 if ( ! empty( $list ) ) {
710 $html_class = 'simpletoc-list';
711 if ( ! empty( $align_class ) ) {
712 $html_class .= " $align_class";
713 }
714
715 $html_style = '';
716 if ( ! empty( $styles ) ) {
717 $html_style = " $styles";
718 }
719
720 $html .= "<$list_type class=\"$html_class\"$html_style>\n$list</$list_type>";
721 }
722
723 $html = add_accordion_end( $html, $attributes );
724 $html = add_hidden_markup_end( $html, $attributes );
725
726 // return an emtpy string if stripped result is empty.
727 if ( empty( trim( wp_strip_all_tags( $html ) ) ) ) {
728 $html = '';
729 }
730
731 return $html;
732 }
733
734 /**
735 * Finds the minimum depth level of headings in the provided array and adjusts it based on the provided attributes
736 *
737 * @param array $headings An array of headings to search through.
738 * @param array $attributes An array of attributes to adjust the minimum depth level.
739 * @return array An array containing the minimum depth level and the initial depth level.
740 */
741 function find_min_depth( $headings, $attributes ) {
742 $min_depth = 6;
743 $initial_depth = 6;
744
745 foreach ( $headings as $line => $headline ) {
746 $this_depth = is_array( $headline ) && isset( $headline['depth'] ) ? (int) $headline['depth'] : (int) $headings[ $line ][2];
747 if ( $min_depth > $this_depth ) {
748 $min_depth = $this_depth;
749 $initial_depth = $min_depth;
750 }
751 }
752
753 if ( $attributes['min_level'] > $min_depth ) {
754 $min_depth = $attributes['min_level'];
755 $initial_depth = $min_depth;
756 }
757
758 return array( $min_depth, $initial_depth );
759 }
760
761 /**
762 * Determines if a given headline should be excluded based on the provided attributes.
763 *
764 * @param string $headline The headline to check for exclusion.
765 * @param array $attributes An array of attributes to use for exclusion.
766 * @param int $this_depth The depth level of the headline.
767 * @return bool True if the headline should be excluded, false otherwise.
768 */
769 function should_exclude_headline( $headline, $attributes, $this_depth ) {
770 $exclude_headline = simpletoc_heading_has_class( $headline, 'simpletoc-hidden' );
771
772 return ( $this_depth > $attributes['max_level'] || $exclude_headline || $this_depth < $attributes['min_level'] );
773 }
774
775 /**
776 * Filters headings down to TOC-visible entries while preserving anchor generation order.
777 *
778 * @param array $headings An array of headings to include in the table of contents.
779 * @param array $attributes An array of attributes to customize the output.
780 * @return array[] The headings that should be rendered in the table of contents.
781 */
782 function get_included_toc_headings( $headings, $attributes ) {
783 $toc_headings = array();
784 $headline_ids = new SimpleTOC_Headline_Ids();
785
786 foreach ( $headings as $headline ) {
787 $this_depth = simpletoc_get_heading_depth( $headline );
788
789 if ( false === $this_depth ) {
790 continue;
791 }
792
793 $title = trim( wp_strip_all_tags( $headline ) );
794 $custom_id = extract_id( $headline );
795 $link = $custom_id ? $custom_id : $headline_ids->get_headline_anchor( $title );
796
797 if ( should_exclude_headline( $headline, $attributes, $this_depth ) ) {
798 continue;
799 }
800
801 $toc_headings[] = array(
802 'headline' => $headline,
803 'depth' => $this_depth,
804 'title' => $title,
805 'link' => $link,
806 );
807 }
808
809 return $toc_headings;
810 }
811
812 /**
813 * Renders nested TOC list item markup from already-filtered headings.
814 *
815 * @param array[] $toc_headings The headings that should be rendered in the table of contents.
816 * @param string $list_type The type of list to be created, either "ul" (unordered list) or "ol".
817 * @param string $absolute_url The optional absolute URL prefix for links.
818 * @param int $min_depth The minimum heading depth included in the table of contents.
819 * @return string The rendered nested list item markup.
820 */
821 function render_toc_list_items( $toc_headings, $list_type, $absolute_url, $min_depth ) {
822 $list = '';
823 $current_depth = null;
824
825 foreach ( $toc_headings as $toc_heading ) {
826 $this_depth = $toc_heading['depth'];
827
828 if ( null === $current_depth ) {
829 $current_depth = $this_depth;
830 $list .= '<li>';
831 } elseif ( $this_depth > $current_depth ) {
832 for ( $current_depth; $current_depth < $this_depth; $current_depth++ ) {
833 $list .= "\n<" . $list_type . ">\n<li>";
834 }
835 } elseif ( $this_depth === $current_depth ) {
836 $list .= "</li>\n<li>";
837 } else {
838 for ( $current_depth; $current_depth > $this_depth; $current_depth-- ) {
839 $list .= "</li>\n</" . $list_type . ">\n";
840 }
841 $list .= "</li>\n<li>";
842 }
843
844 $page = get_page_number_from_headline( $toc_heading['headline'] );
845 $url = $absolute_url . $page . '#' . $toc_heading['link'];
846 $href = $absolute_url ? esc_url( $url ) : esc_attr( $url );
847 $list .= '<a href="' . $href . '">' . esc_html( $toc_heading['title'] ) . '</a>' . PHP_EOL;
848 }
849
850 if ( null !== $current_depth ) {
851 for ( $current_depth; $current_depth > $min_depth; $current_depth-- ) {
852 $list .= "</li>\n</" . $list_type . ">\n";
853 }
854 $list .= '</li>';
855 }
856
857 return $list;
858 }
859
860 /**
861 * Adds smooth scrolling styles to the output HTML, if enabled by global option or block attribute.
862 *
863 * @param string $html The HTML string to which the styles will be added.
864 * @param array $attributes An array of block attributes.
865 * @return string The modified HTML string with the added smooth scrolling styles.
866 */
867 function add_smooth( $html, $attributes ) {
868 // Add smooth scrolling styles, if enabled by global option or block attribute.
869 $is_smooth_enabled = $attributes['add_smooth'] || true === (bool) get_option( 'simpletoc_smooth_enabled', false );
870 $html .= $is_smooth_enabled ? '<style>html { scroll-behavior: smooth; }</style>' : '';
871
872 return $html;
873 }
874
875 /**
876 * Enqueues the necessary CSS and JS files for the accordion functionality on the frontend.
877 */
878 function enqueue_accordion_frontend() {
879 wp_enqueue_script(
880 'simpletoc-accordion',
881 plugin_dir_url( __FILE__ ) . 'assets/accordion.js',
882 array(),
883 SIMPLETOC_VERSION,
884 true
885 );
886
887 wp_enqueue_style(
888 'simpletoc-accordion',
889 plugin_dir_url( __FILE__ ) . 'assets/accordion.css',
890 array(),
891 SIMPLETOC_VERSION
892 );
893 }
894
895 /**
896 * Adds the opening HTML tag(s) for the hidden markup element and the table of contents title, if applicable.
897 *
898 * @param string $html The HTML string to add the opening tag(s) to.
899 * @param array $attributes The attributes of the table of contents block.
900 * @param int $itemcount The number of items in the table of contents.
901 * @param string $alignclass The alignment class for the table of contents block.
902 */
903 function add_hidden_markup_start( $html, $attributes, $itemcount, $alignclass ) { // phpcs:ignore.
904 $is_hidden_enabled = $attributes['hidden'];
905
906 if ( $is_hidden_enabled ) {
907 $title_text = $attributes['title_text'] ? esc_html( trim( $attributes['title_text'] ) ) : esc_html__( 'Table of Contents', 'simpletoc' );
908 $hidden_start = '<details class="simpletoc">
909 <summary>' . $title_text . '</summary>';
910 $html .= $hidden_start;
911 }
912
913 // If there are no items in the table of contents, return an empty string.
914 if ( $itemcount < 1 ) {
915 return '';
916 }
917
918 return $html;
919 }
920
921 /**
922 * Adds the closing HTML tag(s) for the hidden markup element if the hidden markup is enabled.
923 *
924 * @param string $html The HTML string to add the closing tag(s) to.
925 * @param array $attributes The attributes of the table of contents block.
926 * @return string The modified HTML string with the closing tag(s) added.
927 */
928 function add_hidden_markup_end( $html, $attributes ) {
929 $is_hidden_enabled = $attributes['hidden'];
930
931 if ( $is_hidden_enabled ) {
932 $html .= '</details>';
933 }
934
935 return $html;
936 }
937
938 /**
939 * Adds the opening HTML tag(s) for the accordion element and the table of contents title, if applicable.
940 *
941 * @param string $html The HTML string to add the opening tag(s) to.
942 * @param array $attributes The attributes of the table of contents block.
943 * @param int $itemcount The number of items in the table of contents.
944 * @param string $alignclass The alignment class for the table of contents block.
945 */
946 function add_accordion_start( $html, $attributes, $itemcount, $alignclass ) {
947 // Check if accordion is enabled either through the function arguments or the options.
948 $is_accordion_enabled = $attributes['accordion'] || true === (bool) get_option( 'simpletoc_accordion_enabled', false );
949 $is_hidden_enabled = $attributes['hidden'];
950 $title_text = $attributes['title_text'] ? esc_html( trim( $attributes['title_text'] ) ) : esc_html__( 'Table of Contents', 'simpletoc' );
951
952 // Start and end HTML for accordion, if enabled.
953 $accordion_start = '';
954 if ( $is_accordion_enabled ) {
955 enqueue_accordion_frontend();
956 $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">';
957 }
958
959 // Add the accordion start HTML to the output.
960 $html .= $accordion_start;
961
962 // Add the table of contents title, if not hidden and not in accordion mode.
963 $show_title = ! $attributes['no_title'] && ! $is_accordion_enabled && ! $is_hidden_enabled;
964 if ( $show_title ) {
965 $title_tag = $attributes['title_level'] > 0 ? "h{$attributes['title_level']}" : 'p';
966 $title_tag = wp_strip_all_tags( $title_tag );
967 $html_class = 'simpletoc-title';
968
969 if ( ! empty( $alignclass ) ) {
970 $html_class .= " $alignclass";
971 }
972
973 $html = "<$title_tag class=\"$html_class\">$title_text</$title_tag>\n";
974 }
975
976 // If there are no items in the table of contents, return an empty string.
977 if ( $itemcount < 1 ) {
978 return '';
979 }
980
981 return $html;
982 }
983
984 /**
985 * Adds the closing HTML tag(s) for the accordion element if the accordion is enabled.
986 *
987 * @param string $html The HTML string to add the closing tag(s) to.
988 * @param array $attributes The attributes of the table of contents block.
989 * @return string The modified HTML string with the closing tag(s) added
990 */
991 function add_accordion_end( $html, $attributes ) {
992 // Check if accordion is enabled either through the function arguments or the options.
993 $is_accordion_enabled = $attributes['accordion'] || true === (bool) get_option( 'simpletoc_accordion_enabled', false );
994
995 if ( $is_accordion_enabled ) {
996 $html .= '</div>';
997 }
998
999 return $html;
1000 }
1001
1002 /**
1003 * Extracts the ID value from the provided heading HTML string.
1004 *
1005 * @param string $headline The heading HTML string to extract the ID value from.
1006 * @return string|false Returns the extracted ID value, or false if no ID value is found.
1007 */
1008 function extract_id( $headline ) {
1009 $processor = simpletoc_get_html_tag_processor( $headline );
1010
1011 if ( $processor ) {
1012
1013 while ( $processor->next_tag() ) {
1014 if ( ! in_array( $processor->get_tag(), array( 'H1', 'H2', 'H3', 'H4', 'H5', 'H6' ), true ) ) {
1015 continue;
1016 }
1017
1018 $id_value = $processor->get_attribute( 'id' );
1019
1020 if ( is_string( $id_value ) ) {
1021 return $id_value;
1022 }
1023 }
1024 }
1025
1026 return false;
1027 }
1028
1029 /**
1030 * Gets the page number from a headline string.
1031 *
1032 * @param string $headline The headline string.
1033 * @return string The page number (in the format "X/") if it exists and is greater than 1, or an empty string otherwise.
1034 */
1035 function get_page_number_from_headline( $headline ) {
1036 $processor = simpletoc_get_html_tag_processor( $headline );
1037
1038 if ( $processor ) {
1039
1040 while ( $processor->next_tag() ) {
1041 if ( ! in_array( $processor->get_tag(), array( 'H1', 'H2', 'H3', 'H4', 'H5', 'H6' ), true ) ) {
1042 continue;
1043 }
1044
1045 $page_number = (int) $processor->get_attribute( 'data-page' );
1046
1047 if ( $page_number > 1 ) {
1048 return esc_html( $page_number . '/' );
1049 }
1050 }
1051
1052 return '';
1053 }
1054
1055 return '';
1056 }
1057