PluginProbe
SimpleTOC – Table of Contents Block / 7.4.1
SimpleTOC – Table of Contents Block v7.4.1
7.4.1 7.4.0 7.3.1 7.2.0 7.3.0 7.1.1 7.1.0 7.0.10 7.0.9 7.0.8 7.0.7 7.0.6 7.0.4 7.0.5 5.0.21.1 5.0.22 5.0.23 5.0.24 5.0.25 5.0.25.1 5.0.27 5.0.28 5.0.29 5.0.3 5.0.30 All 162 releases
simpletoc / plugin.php

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

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