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