PluginProbe
SimpleTOC – Table of Contents Block / 7.1.0
SimpleTOC – Table of Contents Block v7.1.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.1.0, at plugin.php

944 lines 33.2 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.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
25 /**
26 * Prevents direct execution of the plugin file.
27 * If a WordPress function does not exist, it means that the file has not been run by WordPress.
28 */
29 if ( ! defined( 'ABSPATH' ) || ! function_exists( 'add_filter' ) ) {
30 header( 'Status: 403 Forbidden' );
31 header( 'HTTP/1.1 403 Forbidden' );
32 exit;
33 }
34
35 /**
36 * Registers the SimpleTOC block, adds a filter for plugin row meta, and sets script translations.
37 *
38 * This function registers the SimpleTOC block by specifying the build directory and render callback function.
39 * It also sets the script translations for the block editor script and adds a filter for the plugin row meta.
40 */
41 function register_simpletoc_block() {
42
43 if ( function_exists( 'wp_set_script_translations' ) ) {
44 wp_set_script_translations( 'simpletoc-toc-editor-script', 'simpletoc' );
45 }
46
47 add_filter( 'plugin_row_meta', __NAMESPACE__ . '\simpletoc_plugin_meta', 10, 2 );
48
49 register_block_type(
50 __DIR__ . '/build',
51 array(
52 'render_callback' => __NAMESPACE__ . '\render_callback_simpletoc',
53 )
54 );
55
56 wp_add_inline_script(
57 'simpletoc-toc-editor-script',
58 'window.simpletocEditorSettings = ' . wp_json_encode(
59 array(
60 'settingsUrl' => admin_url( 'options-general.php?page=simpletoc' ),
61 )
62 ) . ';',
63 'before'
64 );
65 }
66
67 add_action( 'init', __NAMESPACE__ . '\register_simpletoc_block' );
68
69 /**
70 * Adds SimpleTOC-specific block editor settings.
71 *
72 * @param array $editor_settings Default editor settings.
73 * @param \WP_Block_Editor_Context $editor_context Editor context.
74 *
75 * @return array
76 */
77 function add_simpletoc_block_editor_settings( $editor_settings, $editor_context ) {
78 $editor_settings['simpletocSettingsUrl'] = admin_url( 'options-general.php?page=simpletoc' );
79
80 return $editor_settings;
81 }
82
83 add_filter( 'block_editor_settings_all', __NAMESPACE__ . '\add_simpletoc_block_editor_settings', 10, 2 );
84
85 /**
86 * Inject potentially missing translations into the block-editor i18n
87 * collection.
88 *
89 * This keeps the plugin backwards compatible, in case the user did not
90 * update translations on their website (yet).
91 *
92 * @param string|false|null $translations JSON-encoded translation data. Default null.
93 * @param string|false $file Path to the translation file to load. False if there isn't one.
94 * @param string $handle Name of the script to register a translation domain to.
95 * @param string $domain The text domain.
96 *
97 * @return string|false|null JSON string
98 */
99 add_filter(
100 'load_script_translations',
101 function ( $translations, $file, $handle, $domain ) {
102 if ( 'simpletoc' === $domain && $translations ) {
103 // List of translations that we inject into the block-editor JS.
104 $dynamic_translations = array(
105 'Table of Contents' => __( 'Table of Contents', 'simpletoc' ),
106 );
107
108 $changed = false;
109 $obj = json_decode( $translations, true );
110
111 // Confirm that the translation JSON is valid.
112 if ( isset( $obj['locale_data'] ) && isset( $obj['locale_data']['messages'] ) ) {
113 $messages = $obj['locale_data']['messages'];
114
115 // Inject dynamic translations, when needed.
116 foreach ( $dynamic_translations as $key => $locale ) {
117 if ( empty( $messages[ $key ] )
118 || ! is_array( $messages[ $key ] )
119 || ! array_key_exists( 0, $messages[ $key ] )
120 || $locale !== $messages[ $key ][0]
121 ) {
122 $messages[ $key ] = array( $locale );
123 $changed = true;
124 }
125 }
126
127 // Only modify the translations string when locales did change.
128 if ( $changed ) {
129 $obj['locale_data']['messages'] = $messages;
130 $translations = wp_json_encode( $obj );
131 }
132 }
133 }
134
135 return $translations;
136 },
137 10,
138 4
139 );
140
141 /**
142 * Sets the default value of translatable attributes.
143 *
144 * Values inside block.json are static strings that are not translated. This
145 * filter inserts relevant translations i
146 *
147 * @param array $settings Array of determined settings for registering a block type.
148 * @param array $metadata Metadata provided for registering a block type.
149 *
150 * @return array Modified settings array.
151 */
152 add_filter(
153 'block_type_metadata_settings',
154 function ( $settings, $metadata ) {
155 if ( 'simpletoc/toc' === $metadata['name'] ) {
156 $settings['attributes']['title_text']['default'] = __( 'Table of Contents', 'simpletoc' );
157 }
158
159 return $settings;
160 },
161 10,
162 2
163 );
164
165 /**
166 * Filter to add plugins to the TOC list for Rank Math plugin
167 *
168 * @param array TOC plugins.
169 */
170 add_filter(
171 'rank_math/researches/toc_plugins',
172 function ( $toc_plugins ) {
173 $toc_plugins['simpletoc/plugin.php'] = 'SimpleTOC';
174 return $toc_plugins;
175 }
176 );
177
178
179
180 /**
181 * Adds IDs to the headings of the provided post content using a recursive block structure.
182 *
183 * @param string $content The content to add IDs to.
184 * @return string The content with IDs added to its headings
185 */
186 function simpletoc_add_ids_to_content( $content ) {
187
188 $blocks = parse_blocks( $content );
189
190 $blocks = add_ids_to_blocks_recursive( $blocks );
191
192 $content = serialize_blocks( $blocks );
193
194 return $content;
195 }
196
197 add_filter( 'the_content', __NAMESPACE__ . '\simpletoc_add_ids_to_content', 1 );
198
199 /**
200 * Recursively adds IDs to the headings of a nested block structure.
201 *
202 * @param array $blocks The blocks to add IDs to.
203 * @return array The blocks with IDs added to their headings
204 */
205 function add_ids_to_blocks_recursive( $blocks ) {
206
207 $supported_blocks = array(
208 'core/heading',
209 'generateblocks/text',
210 'generateblocks/headline',
211 );
212
213 /**
214 * Filter to add supported blocks for IDs.
215 *
216 * @param array $supported_blocks The array of supported blocks.
217 */
218 $supported_blocks = apply_filters( 'simpletoc_supported_blocks_for_ids', $supported_blocks );
219
220 // Need two separate instances so that IDs aren't double coubnted.
221 $inner_html_id_instance = new SimpleTOC_Headline_Ids();
222 $inner_content_id_instance = new SimpleTOC_Headline_Ids();
223
224 foreach ( $blocks as &$block ) {
225 if ( isset( $block['blockName'] ) && in_array( $block['blockName'], $supported_blocks, true ) && isset( $block['innerHTML'] ) && isset( $block['innerContent'] ) && isset( $block['innerContent'][0] ) ) {
226 $block['innerHTML'] = add_anchor_attribute( $block['innerHTML'], $inner_html_id_instance, $block );
227 $block['innerContent'][0] = add_anchor_attribute( $block['innerContent'][0], $inner_content_id_instance, $block );
228 } elseif ( isset( $block['attrs']['ref'] ) ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedElseif
229 // search in reusable blocks (this is not finished because I ran out of ideas.)
230 // $reusable_block_id = $block['attrs']['ref'];
231 // $reusable_block_content = parse_blocks(get_post($reusable_block_id)->post_content);.
232 } elseif ( ! empty( $block['innerBlocks'] ) ) {
233 // search in groups.
234 $block['innerBlocks'] = add_ids_to_blocks_recursive( $block['innerBlocks'] );
235 }
236 }
237
238 return $blocks;
239 }
240
241 /**
242 * Renders a Table of Contents block for a post
243 *
244 * @param array $attributes An array of attributes for the Table of Contents block.
245 * @return string The HTML output for the Table of Contents block
246 */
247 function render_callback_simpletoc( $attributes ) {
248 $is_backend = defined( 'REST_REQUEST' ) && REST_REQUEST && 'edit' === filter_input( INPUT_GET, 'context' );
249 $title_text = $attributes['title_text'] ? esc_html( trim( $attributes['title_text'] ) ) : __( 'Table of Contents', 'simpletoc' );
250 $alignclass = ! empty( $attributes['align'] ) ? 'align' . $attributes['align'] : '';
251 $class_name = ! empty( $attributes['className'] ) ? wp_strip_all_tags( $attributes['className'] ) : '';
252 $title_level = $attributes['title_level'];
253 $global_box_style_enabled = apply_filters( 'simpletoc_box_style_enabled', false ) || true === (bool) get_option( 'simpletoc_box_style_enabled', false );
254 $box_style_enabled = $global_box_style_enabled || ! empty( $attributes['box_style'] );
255 $wrapper_classes = array( 'simpletoc' );
256 $wrapper_style = '';
257
258 if ( $box_style_enabled ) {
259 $wrapper_classes[] = 'has-simpletoc-box-style';
260
261 if ( $global_box_style_enabled ) {
262 $wrapper_classes[] = 'has-background';
263 $wrapper_style = safecss_filter_attr( 'background-color:' . DEFAULT_BOX_COLOR . ';' );
264 } elseif ( ! empty( $attributes['box_color'] ) ) {
265 $wrapper_classes[] = 'has-background';
266 $wrapper_style = safecss_filter_attr( 'background-color:' . $attributes['box_color'] . ';' );
267 } else {
268 $wrapper_classes[] = 'has-background';
269 $wrapper_style = safecss_filter_attr( 'background-color:' . DEFAULT_BOX_COLOR . ';' );
270 }
271 }
272
273 $wrapper_enabled = apply_filters( 'simpletoc_wrapper_enabled', false ) || true === (bool) get_option( 'simpletoc_wrapper_enabled', false ) || true === (bool) get_option( 'simpletoc_accordion_enabled', false );
274 $wrapper_attrs = get_block_wrapper_attributes(
275 array(
276 'class' => implode( ' ', $wrapper_classes ),
277 'style' => $wrapper_style,
278 )
279 );
280 $has_wrapper = ! empty( $class_name ) || $wrapper_enabled || $attributes['accordion'] || $attributes['wrapper'] || $box_style_enabled;
281 $pre_html = $has_wrapper ? '<div role="navigation" aria-label="' . __( 'Table of Contents', 'simpletoc' ) . '" ' . $wrapper_attrs . '>' : '';
282 $post_html = $has_wrapper ? '</div>' : '';
283
284 $post = get_post();
285 $blocks = ! is_null( $post ) && ! is_null( $post->post_content ) ? parse_blocks( $post->post_content ) : '';
286
287 $headings = array_reverse( filter_headings_recursive( $blocks ) );
288 $headings = simpletoc_add_pagenumber( $blocks, $headings );
289 $headings_clean = array_map( 'trim', $headings );
290 $toc_html = generate_toc( $headings_clean, $attributes );
291
292 if ( empty( $blocks ) ) {
293 return get_empty_blocks_message( $is_backend, $attributes, $title_level, $alignclass, $title_text, __( 'No blocks found.', 'simpletoc' ), __( 'Save or update post first.', 'simpletoc' ), $wrapper_attrs, $has_wrapper );
294 }
295
296 if ( empty( $headings_clean ) ) {
297 return get_empty_blocks_message( $is_backend, $attributes, $title_level, $alignclass, $title_text, __( 'No headings found.', 'simpletoc' ), __( 'Save or update post first.', 'simpletoc' ), $wrapper_attrs, $has_wrapper );
298 }
299
300 if ( empty( $toc_html ) ) {
301 return get_empty_blocks_message( $is_backend, $attributes, $title_level, $alignclass, $title_text, __( 'No headings found.', 'simpletoc' ), __( 'Check minimal and maximum level block settings.', 'simpletoc' ), $wrapper_attrs, $has_wrapper );
302 }
303
304 return $pre_html . $toc_html . $post_html;
305 }
306
307 /**
308 * Generates an HTML message for empty blocks cases in the Table of Contents.
309 *
310 * @param bool $is_backend Indicates if the request is from the backend (i.e., the WordPress editor).
311 * @param array $attributes An array of attributes for the Table of Contents block.
312 * @param int $title_level The heading level for the Table of Contents title.
313 * @param string $alignclass The CSS class for alignment of the Table of Contents block.
314 * @param string $title_text The text for the Table of Contents title.
315 * @param string $warning_text1 The first part of the warning message to be displayed.
316 * @param string $warning_text2 The second part of the warning message to be displayed.
317 * @param string $wrapper_attrs Wrapper attributes for the optional block wrapper.
318 * @param bool $has_wrapper Indicates if the wrapper should be rendered.
319 *
320 * @return string The HTML output for the empty blocks message.
321 */
322 function get_empty_blocks_message( $is_backend, $attributes, $title_level, $alignclass, $title_text, $warning_text1, $warning_text2, $wrapper_attrs = '', $has_wrapper = false ) {
323 $html = '';
324
325 if ( $is_backend ) {
326 if ( $has_wrapper ) {
327 $html .= '<div role="navigation" aria-label="' . esc_attr__( 'Table of Contents', 'simpletoc' ) . '" ' . $wrapper_attrs . '>';
328 }
329
330 $html .= sprintf( '<h%d class="%s">%s</h%d>', $title_level, esc_attr( trim( 'simpletoc-title ' . $alignclass ) ), $title_text, $title_level );
331 $html .= sprintf( '<p class="components-notice is-warning %s">%s %s</p>', $alignclass, $warning_text1, $warning_text2 );
332
333 if ( $has_wrapper ) {
334 $html .= '</div>';
335 }
336 }
337
338 return $html;
339 }
340
341 /**
342 * Adds page numbers to headings in the provided blocks array.
343 *
344 * @param array $blocks The array of blocks to process.
345 * @param array $headings The array of headings to add page numbers to.
346 * @return array The modified headings array with page numbers added.
347 */
348 function simpletoc_add_pagenumber( $blocks, $headings ) {
349 $pages = 1;
350
351 if ( ! is_array( $blocks ) ) {
352 return $headings;
353 }
354
355 foreach ( $blocks as $block => $inner_block ) {
356 // count nextpage blocks.
357 if ( isset( $blocks[ $block ]['blockName'] ) && 'core/nextpage' === $blocks[ $block ]['blockName'] ) {
358 ++$pages;
359 }
360
361 if ( isset( $blocks[ $block ]['blockName'] ) && 'core/heading' === $blocks[ $block ]['blockName'] ) {
362 // make sure its a headline.
363 foreach ( $headings as $heading => &$inner_heading ) {
364 if ( $inner_heading === $blocks[ $block ]['innerHTML'] ) {
365 $inner_heading = preg_replace( '/(<h1|<h2|<h3|<h4|<h5|<h6)/i', '$1 data-page="' . $pages . '"', $blocks[ $block ]['innerHTML'] );
366 }
367 }
368 }
369 }
370 return $headings;
371 }
372
373 /**
374 * Return all headings with a recursive walk through all blocks.
375 * This includes groups and reusable block with groups within reusable blocks.
376 *
377 * @param array[] $blocks The blocks to filter headings from.
378 * @return array[]
379 */
380 function filter_headings_recursive( $blocks ) {
381 $arr = array();
382
383 if ( ! is_array( $blocks ) ) {
384 return $arr;
385 }
386
387 // allow developers to ignore specific blocks.
388 $ignored_blocks = apply_filters( 'simpletoc_excluded_blocks', array() );
389
390 foreach ( $blocks as $inner_block ) {
391 if ( is_array( $inner_block ) ) {
392 // if block is ignored, skip.
393 if ( isset( $inner_block['blockName'] ) && in_array( $inner_block['blockName'], $ignored_blocks, true ) ) {
394 continue;
395 }
396
397 if ( isset( $inner_block['attrs']['ref'] ) ) {
398 // search in reusable blocks.
399 $post = get_post( $inner_block['attrs']['ref'] );
400 if ( $post ) {
401 $e_arr = parse_blocks( $post->post_content );
402 $arr = array_merge( filter_headings_recursive( $e_arr ), $arr );
403 }
404 } else {
405 // search in groups.
406 $arr = array_merge( filter_headings_recursive( $inner_block ), $arr );
407 }
408 } else {
409 if ( isset( $blocks['blockName'] ) && ( 'core/heading' === $blocks['blockName'] ) && 'core/heading' !== $inner_block ) {
410 // make sure it's a headline.
411 if ( preg_match( '/(<h1|<h2|<h3|<h4|<h5|<h6)/i', $inner_block ) ) {
412 $arr[] = $inner_block;
413 }
414 }
415
416 $supported_third_party_blocks = array(
417 'generateblocks/headline', /* GenerateBlocks 1.x */
418 'generateblocks/text', /* GenerateBlocks 2.0 */
419 );
420
421 /**
422 * Filter to add supported third party blocks.
423 *
424 * @param array $supported_third_party_blocks The array of supported third party blocks.
425 * @return array The modified array of supported third party blocks.
426 */
427 $supported_third_party_blocks = apply_filters(
428 'simpletoc_supported_third_party_blocks',
429 $supported_third_party_blocks
430 );
431
432 if ( isset( $blocks['blockName'] ) && in_array( $blocks['blockName'], $supported_third_party_blocks, true ) && 'core/heading' !== $inner_block ) {
433 // make sure it's a headline.
434 if ( preg_match( '/(<h1|<h2|<h3|<h4|<h5|<h6)/i', $inner_block ) ) {
435 $inner_block = simpletoc_maybe_replace_generateblocks_dynamic_tags( $inner_block, $blocks );
436 $arr[] = $inner_block;
437 }
438 }
439 }
440 }
441
442 return $arr;
443 }
444
445 /**
446 * Replaces GenerateBlocks dynamic tags in heading HTML before SimpleTOC uses it in the TOC.
447 *
448 * @param string $html The heading HTML.
449 * @param array $block The parsed block data.
450 * @return string The heading HTML with GenerateBlocks dynamic tags resolved when available.
451 */
452 function simpletoc_maybe_replace_generateblocks_dynamic_tags( $html, $block ) {
453 if ( ! class_exists( '\GenerateBlocks_Register_Dynamic_Tag' ) || false === strpos( $html, '{{' ) ) {
454 return $html;
455 }
456
457 return \GenerateBlocks_Register_Dynamic_Tag::replace_tags( $html, $block, null );
458 }
459
460 /**
461 * Gets heading HTML used for anchor generation.
462 *
463 * @param string $html The original heading HTML.
464 * @param array $block The parsed block data.
465 * @return string The heading HTML to use for anchor generation.
466 */
467 function simpletoc_get_heading_html_for_anchor( $html, $block ) {
468 $heading_html = simpletoc_maybe_replace_generateblocks_dynamic_tags( $html, $block );
469
470 if ( '' === trim( wp_strip_all_tags( $heading_html ) ) ) {
471 return $html;
472 }
473
474 return $heading_html;
475 }
476
477 /**
478 * Sanitizes a string to be used as an anchor attribute in HTML by removing punctuation, non-breaking spaces, umlauts, and accents,
479 * and replacing whitespace and other characters with dashes.
480 *
481 * @param string $string_to_sanitize The input string to be sanitized.
482 * @return string The sanitized string encoded for use in a URL.
483 */
484 function simpletoc_sanitize_string( $string_to_sanitize ) {
485 // remove punctuation.
486 $zero_punctuation = preg_replace( '/\p{P}/u', '', $string_to_sanitize );
487 // remove non-breaking spaces.
488 $html_wo_nbs = str_replace( '&nbsp;', ' ', $zero_punctuation );
489 // remove umlauts and accents.
490 $string_without_accents = remove_accents( $html_wo_nbs );
491 // Sanitizes a title, replacing whitespace and a few other characters with dashes.
492 $sanitized_string = sanitize_title_with_dashes( $string_without_accents );
493 // Encode for use in an url.
494 $urlencoded = rawurlencode( $sanitized_string );
495 return $urlencoded;
496 }
497
498 /**
499 * Add additional plugin meta links to the SimpleTOC plugin page.
500 *
501 * @param array $links An array of plugin meta links.
502 * @param string $file The plugin file path.
503 * @return array The modified array of plugin meta links.
504 */
505 function simpletoc_plugin_meta( $links, $file ) {
506
507 if ( false !== strpos( $file, 'simpletoc' ) ) {
508 $links = array_merge( $links, array( '<a href="https://wordpress.org/support/plugin/simpletoc">' . esc_html__( 'Support', 'simpletoc' ) . '</a>' ) );
509 $links = array_merge( $links, array( '<a href="https://marc.tv/out/donate">' . esc_html__( 'Donate', 'simpletoc' ) . '</a>' ) );
510 $links = array_merge( $links, array( '<a href="https://wordpress.org/support/plugin/simpletoc/reviews/#new-post">' . esc_html__( 'Write a review', 'simpletoc' ) . '&nbsp;⭐️⭐️⭐️⭐️⭐️</a>' ) );
511 }
512
513 return $links;
514 }
515
516 /**
517 * Loads the WordPress HTML Tag Processor when available.
518 *
519 * @return bool True when the HTML Tag Processor can be used.
520 */
521 function simpletoc_load_html_tag_processor() {
522 if ( class_exists( '\WP_HTML_Tag_Processor' ) ) {
523 return true;
524 }
525
526 if ( defined( 'ABSPATH' ) && defined( 'WPINC' ) ) {
527 $html_tag_processor_file = ABSPATH . WPINC . '/html-api/class-wp-html-tag-processor.php';
528
529 if ( file_exists( $html_tag_processor_file ) ) {
530 require_once $html_tag_processor_file;
531 }
532 }
533
534 return class_exists( '\WP_HTML_Tag_Processor' );
535 }
536
537 /**
538 * Adds an ID attribute to all Heading tags in the provided HTML.
539 *
540 * @param string $html The HTML content to modify.
541 * @param SimpleTOC_Headline_Ids $headline_class_instance The instance of the SimpleTOC_Headline_Ids class.
542 * @param array $block The parsed block data.
543 * @return string The modified HTML content with ID attributes added to the Heading tags
544 */
545 function add_anchor_attribute( $html, $headline_class_instance = null, $block = array() ) {
546
547 // remove non-breaking space entites from input HTML.
548 $html_wo_nbs = str_replace( '&nbsp;', ' ', $html );
549
550 // Thank you Nick Diego.
551 if ( ! $html_wo_nbs ) {
552 return $html;
553 }
554
555 if ( simpletoc_load_html_tag_processor() ) {
556 $processor = new \WP_HTML_Tag_Processor( $html_wo_nbs );
557
558 while ( $processor->next_tag() ) {
559 if ( ! in_array( $processor->get_tag(), array( 'H1', 'H2', 'H3', 'H4', 'H5', 'H6' ), true ) ) {
560 continue;
561 }
562
563 // If tag already has an attribute "id" defined, no need for creating a new one.
564 if ( ! empty( $processor->get_attribute( 'id' ) ) ) {
565 continue;
566 }
567
568 $heading_html = simpletoc_get_heading_html_for_anchor( $html, $block );
569 $heading_text = trim( wp_strip_all_tags( $heading_html ) );
570 $anchor = $headline_class_instance->get_headline_anchor( $heading_text );
571 $processor->set_attribute( 'id', $anchor );
572 }
573
574 return $processor->get_updated_html();
575 }
576
577 return $html;
578 }
579
580 /**
581 * Generates a table of contents based on the provided headings and attributes
582 *
583 * @param array $headings An array of headings to include in the table of contents.
584 * @param array $attributes An array of attributes to customize the output.
585 * @return string The generated table of contents as HTML
586 */
587 function generate_toc( $headings, $attributes ) {
588 $list = '';
589 $html = '';
590 $min_depth = 6;
591 $initial_depth = 6;
592 $align_class = isset( $attributes['align'] ) ? 'align' . $attributes['align'] : '';
593 $styles = $attributes['remove_indent'] ? 'style="padding-left:0;list-style:none;"' : '';
594 $list_type = $attributes['use_ol'] ? 'ol' : 'ul';
595 $global_absolut_urls_enabled = get_option( 'simpletoc_absolute_urls_enabled', false );
596 $absolute_url = $attributes['use_absolute_urls'] || $global_absolut_urls_enabled ? get_permalink() : '';
597
598 $toc_headings = get_included_toc_headings( $headings, $attributes );
599 list($min_depth, $initial_depth) = find_min_depth( $toc_headings, $attributes );
600
601 $item_count = count( $toc_headings );
602 $list = render_toc_list_items( $toc_headings, $list_type, $absolute_url, $min_depth );
603
604 $html = add_accordion_start( $html, $attributes, $item_count, $align_class );
605 $html = add_hidden_markup_start( $html, $attributes, $item_count, $align_class );
606 $html = add_smooth( $html, $attributes );
607
608 // Add the table of contents list to the output if the list is not empty.
609 if ( ! empty( $list ) ) {
610 $html_class = 'simpletoc-list';
611 if ( ! empty( $align_class ) ) {
612 $html_class .= " $align_class";
613 }
614
615 $html_style = '';
616 if ( ! empty( $styles ) ) {
617 $html_style = " $styles";
618 }
619
620 $html .= "<$list_type class=\"$html_class\"$html_style>\n$list</$list_type>";
621 }
622
623 $html = add_accordion_end( $html, $attributes );
624 $html = add_hidden_markup_end( $html, $attributes );
625
626 // return an emtpy string if stripped result is empty.
627 if ( empty( trim( wp_strip_all_tags( $html ) ) ) ) {
628 $html = '';
629 }
630
631 return $html;
632 }
633
634 /**
635 * Finds the minimum depth level of headings in the provided array and adjusts it based on the provided attributes
636 *
637 * @param array $headings An array of headings to search through.
638 * @param array $attributes An array of attributes to adjust the minimum depth level.
639 * @return array An array containing the minimum depth level and the initial depth level.
640 */
641 function find_min_depth( $headings, $attributes ) {
642 $min_depth = 6;
643 $initial_depth = 6;
644
645 foreach ( $headings as $line => $headline ) {
646 $this_depth = is_array( $headline ) && isset( $headline['depth'] ) ? (int) $headline['depth'] : (int) $headings[ $line ][2];
647 if ( $min_depth > $this_depth ) {
648 $min_depth = $this_depth;
649 $initial_depth = $min_depth;
650 }
651 }
652
653 if ( $attributes['min_level'] > $min_depth ) {
654 $min_depth = $attributes['min_level'];
655 $initial_depth = $min_depth;
656 }
657
658 return array( $min_depth, $initial_depth );
659 }
660
661 /**
662 * Determines if a given headline should be excluded based on the provided attributes.
663 *
664 * @param string $headline The headline to check for exclusion.
665 * @param array $attributes An array of attributes to use for exclusion.
666 * @param int $this_depth The depth level of the headline.
667 * @return bool True if the headline should be excluded, false otherwise.
668 */
669 function should_exclude_headline( $headline, $attributes, $this_depth ) {
670 $exclude_headline = false;
671 preg_match( '/class="([^"]+)"/', $headline, $matches );
672 if ( ! empty( $matches[1] ) && strpos( $matches[1], 'simpletoc-hidden' ) !== false ) {
673 $exclude_headline = true;
674 }
675
676 return ( $this_depth > $attributes['max_level'] || $exclude_headline || $this_depth < $attributes['min_level'] );
677 }
678
679 /**
680 * Filters headings down to TOC-visible entries while preserving anchor generation order.
681 *
682 * @param array $headings An array of headings to include in the table of contents.
683 * @param array $attributes An array of attributes to customize the output.
684 * @return array[] The headings that should be rendered in the table of contents.
685 */
686 function get_included_toc_headings( $headings, $attributes ) {
687 $toc_headings = array();
688 $headline_ids = new SimpleTOC_Headline_Ids();
689
690 foreach ( $headings as $headline ) {
691 $this_depth = (int) $headline[2];
692 $title = trim( wp_strip_all_tags( $headline ) );
693 $custom_id = extract_id( $headline );
694 $link = $custom_id ? $custom_id : $headline_ids->get_headline_anchor( $title );
695
696 if ( should_exclude_headline( $headline, $attributes, $this_depth ) ) {
697 continue;
698 }
699
700 $toc_headings[] = array(
701 'headline' => $headline,
702 'depth' => $this_depth,
703 'title' => $title,
704 'link' => $link,
705 );
706 }
707
708 return $toc_headings;
709 }
710
711 /**
712 * Renders nested TOC list item markup from already-filtered headings.
713 *
714 * @param array[] $toc_headings The headings that should be rendered in the table of contents.
715 * @param string $list_type The type of list to be created, either "ul" (unordered list) or "ol".
716 * @param string $absolute_url The optional absolute URL prefix for links.
717 * @param int $min_depth The minimum heading depth included in the table of contents.
718 * @return string The rendered nested list item markup.
719 */
720 function render_toc_list_items( $toc_headings, $list_type, $absolute_url, $min_depth ) {
721 $list = '';
722 $current_depth = null;
723
724 foreach ( $toc_headings as $toc_heading ) {
725 $this_depth = $toc_heading['depth'];
726
727 if ( null === $current_depth ) {
728 $current_depth = $this_depth;
729 $list .= '<li>';
730 } elseif ( $this_depth > $current_depth ) {
731 for ( $current_depth; $current_depth < $this_depth; $current_depth++ ) {
732 $list .= "\n<" . $list_type . ">\n<li>";
733 }
734 } elseif ( $this_depth === $current_depth ) {
735 $list .= "</li>\n<li>";
736 } else {
737 for ( $current_depth; $current_depth > $this_depth; $current_depth-- ) {
738 $list .= "</li>\n</" . $list_type . ">\n";
739 }
740 $list .= "</li>\n<li>";
741 }
742
743 $page = get_page_number_from_headline( $toc_heading['headline'] );
744 $list .= '<a href="' . $absolute_url . $page . '#' . $toc_heading['link'] . '">' . $toc_heading['title'] . '</a>' . PHP_EOL;
745 }
746
747 if ( null !== $current_depth ) {
748 for ( $current_depth; $current_depth > $min_depth; $current_depth-- ) {
749 $list .= "</li>\n</" . $list_type . ">\n";
750 }
751 $list .= '</li>';
752 }
753
754 return $list;
755 }
756
757 /**
758 * Adds smooth scrolling styles to the output HTML, if enabled by global option or block attribute.
759 *
760 * @param string $html The HTML string to which the styles will be added.
761 * @param array $attributes An array of block attributes.
762 * @return string The modified HTML string with the added smooth scrolling styles.
763 */
764 function add_smooth( $html, $attributes ) {
765 // Add smooth scrolling styles, if enabled by global option or block attribute.
766 $is_smooth_enabled = $attributes['add_smooth'] || true === (bool) get_option( 'simpletoc_smooth_enabled', false );
767 $html .= $is_smooth_enabled ? '<style>html { scroll-behavior: smooth; }</style>' : '';
768
769 return $html;
770 }
771
772 /**
773 * Enqueues the necessary CSS and JS files for the accordion functionality on the frontend.
774 */
775 function enqueue_accordion_frontend() {
776 wp_enqueue_script(
777 'simpletoc-accordion',
778 plugin_dir_url( __FILE__ ) . 'assets/accordion.js',
779 array(),
780 '6.9.0',
781 true
782 );
783
784 wp_enqueue_style(
785 'simpletoc-accordion',
786 plugin_dir_url( __FILE__ ) . 'assets/accordion.css',
787 array(),
788 '6.9.0'
789 );
790 }
791
792 /**
793 * Adds the opening HTML tag(s) for the hidden markup element and the table of contents title, if applicable.
794 *
795 * @param string $html The HTML string to add the opening tag(s) to.
796 * @param array $attributes The attributes of the table of contents block.
797 * @param int $itemcount The number of items in the table of contents.
798 * @param string $alignclass The alignment class for the table of contents block.
799 */
800 function add_hidden_markup_start( $html, $attributes, $itemcount, $alignclass ) { // phpcs:ignore.
801 $is_hidden_enabled = $attributes['hidden'];
802
803 if ( $is_hidden_enabled ) {
804 $title_text = $attributes['title_text'] ? esc_html( trim( $attributes['title_text'] ) ) : esc_html__( 'Table of Contents', 'simpletoc' );
805 $hidden_start = '<details class="simpletoc">
806 <summary style="cursor: pointer;">' . $title_text . '</summary>';
807 $html .= $hidden_start;
808 }
809
810 // If there are no items in the table of contents, return an empty string.
811 if ( $itemcount < 1 ) {
812 return '';
813 }
814
815 return $html;
816 }
817
818 /**
819 * Adds the closing HTML tag(s) for the hidden markup element if the hidden markup is enabled.
820 *
821 * @param string $html The HTML string to add the closing tag(s) to.
822 * @param array $attributes The attributes of the table of contents block.
823 * @return string The modified HTML string with the closing tag(s) added.
824 */
825 function add_hidden_markup_end( $html, $attributes ) {
826 $is_hidden_enabled = $attributes['hidden'];
827
828 if ( $is_hidden_enabled ) {
829 $html .= '</details>';
830 }
831
832 return $html;
833 }
834
835 /**
836 * Adds the opening HTML tag(s) for the accordion element and the table of contents title, if applicable.
837 *
838 * @param string $html The HTML string to add the opening tag(s) to.
839 * @param array $attributes The attributes of the table of contents block.
840 * @param int $itemcount The number of items in the table of contents.
841 * @param string $alignclass The alignment class for the table of contents block.
842 */
843 function add_accordion_start( $html, $attributes, $itemcount, $alignclass ) {
844 // Check if accordion is enabled either through the function arguments or the options.
845 $is_accordion_enabled = $attributes['accordion'] || true === (bool) get_option( 'simpletoc_accordion_enabled', false );
846 $is_hidden_enabled = $attributes['hidden'];
847 $title_text = $attributes['title_text'] ? esc_html( trim( $attributes['title_text'] ) ) : esc_html__( 'Table of Contents', 'simpletoc' );
848
849 // Start and end HTML for accordion, if enabled.
850 $accordion_start = '';
851 if ( $is_accordion_enabled ) {
852 enqueue_accordion_frontend();
853 $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">';
854 }
855
856 // Add the accordion start HTML to the output.
857 $html .= $accordion_start;
858
859 // Add the table of contents title, if not hidden and not in accordion mode.
860 $show_title = ! $attributes['no_title'] && ! $is_accordion_enabled && ! $is_hidden_enabled;
861 if ( $show_title ) {
862 $title_tag = $attributes['title_level'] > 0 ? "h{$attributes['title_level']}" : 'p';
863 $title_tag = wp_strip_all_tags( $title_tag );
864 $html_class = 'simpletoc-title';
865
866 if ( ! empty( $alignclass ) ) {
867 $html_class .= " $alignclass";
868 }
869
870 $html = "<$title_tag class=\"$html_class\">$title_text</$title_tag>\n";
871 }
872
873 // If there are no items in the table of contents, return an empty string.
874 if ( $itemcount < 1 ) {
875 return '';
876 }
877
878 return $html;
879 }
880
881 /**
882 * Adds the closing HTML tag(s) for the accordion element if the accordion is enabled.
883 *
884 * @param string $html The HTML string to add the closing tag(s) to.
885 * @param array $attributes The attributes of the table of contents block.
886 * @return string The modified HTML string with the closing tag(s) added
887 */
888 function add_accordion_end( $html, $attributes ) {
889 // Check if accordion is enabled either through the function arguments or the options.
890 $is_accordion_enabled = $attributes['accordion'] || true === (bool) get_option( 'simpletoc_accordion_enabled', false );
891
892 if ( $is_accordion_enabled ) {
893 $html .= '</div>';
894 }
895
896 return $html;
897 }
898
899 /**
900 * Extracts the ID value from the provided heading HTML string.
901 *
902 * @param string $headline The heading HTML string to extract the ID value from.
903 * @return mixed Returns the extracted ID value, or false if no ID value is found.
904 */
905 function extract_id( $headline ) {
906 $pattern = '/id="([^"]*)"/';
907 preg_match( $pattern, $headline, $matches );
908 $id_value = $matches[1] ?? false;
909
910 if ( false !== $id_value ) {
911 return $id_value;
912 }
913
914 return false;
915 }
916
917 /**
918 * Gets the page number from a headline string.
919 *
920 * @param string $headline The headline string.
921 * @return string The page number (in the format "X/") if it exists and is greater than 1, or an empty string otherwise.
922 */
923 function get_page_number_from_headline( $headline ) {
924 if ( simpletoc_load_html_tag_processor() ) {
925 $processor = new \WP_HTML_Tag_Processor( $headline );
926
927 while ( $processor->next_tag() ) {
928 if ( ! in_array( $processor->get_tag(), array( 'H1', 'H2', 'H3', 'H4', 'H5', 'H6' ), true ) ) {
929 continue;
930 }
931
932 $page_number = (int) $processor->get_attribute( 'data-page' );
933
934 if ( $page_number > 1 ) {
935 return esc_html( $page_number . '/' );
936 }
937 }
938
939 return '';
940 }
941
942 return '';
943 }
944