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

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