PluginProbe
FrontBlocks Site Tools / trunk
FrontBlocks Site Tools vtrunk
1.5.3 1.5.2 1.5.1 1.4.0 1.5.0 trunk 0.2.0 0.2.1 0.2.2 0.2.3 0.2.4 0.2.5 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.1.0 1.2.0 1.2.1 1.3.0 1.3.1 1.3.2 1.3.3 1.3.4 All 28 releases
frontblocks / includes / Frontend / TableOfContents.php

TableOfContents.php in FrontBlocks Site Tools trunk, at includes/Frontend/TableOfContents.php

464 lines 14.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Table of Contents block module for FrontBlocks.
4 *
5 * @package FrontBlocks
6 * @author Closemarketing
7 * @copyright 2026 Closemarketing
8 * @version 1.0
9 */
10
11 namespace FrontBlocks\Frontend;
12
13 use WP_Block_Type_Registry;
14
15 defined( 'ABSPATH' ) || exit;
16
17 /**
18 * TableOfContents class.
19 *
20 * Registers a dynamic "Table of Contents" block. Because the block usually
21 * appears before the headings it needs to link to, it can't discover them
22 * from within its own render_callback (sibling blocks haven't rendered
23 * yet). Instead, render_callback() outputs a small placeholder carrying the
24 * block's config, and a later the_content filter — running after do_blocks()
25 * has rendered the whole post — scans the final HTML for headings, assigns
26 * stable anchors, and replaces every placeholder with the real navigation
27 * markup.
28 *
29 * @since 1.0.0
30 */
31 class TableOfContents {
32
33 const BLOCK_NAME = 'frontblocks/table-of-contents';
34
35 /**
36 * Minimum heading level considered (h1 is reserved for the post title).
37 */
38 const MIN_ALLOWED_LEVEL = 2;
39
40 /**
41 * Maximum heading level considered.
42 */
43 const MAX_ALLOWED_LEVEL = 6;
44
45 /**
46 * Constructor.
47 */
48 public function __construct() {
49 $this->init_hooks();
50 }
51
52 /**
53 * Initialize hooks.
54 *
55 * @return void
56 */
57 private function init_hooks() {
58 add_action( 'init', array( $this, 'register_block' ) );
59 add_filter( 'the_content', array( $this, 'inject_toc_into_content' ), 30 );
60 add_action( 'template_redirect', array( $this, 'maybe_start_output_buffer' ) );
61 add_action( 'enqueue_block_assets', array( $this, 'enqueue_block_style' ) );
62 add_action( 'enqueue_block_editor_assets', array( $this, 'enqueue_block_editor_assets' ) );
63 add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_frontend_assets' ) );
64 }
65
66 /**
67 * Enqueue the block's stylesheet on both the frontend and in the editor,
68 * via `enqueue_block_assets` rather than `enqueue_block_editor_assets`.
69 * Since WordPress 5.9 the block canvas renders inside an iframe, and only
70 * styles registered through `enqueue_block_assets` are mirrored into that
71 * iframe — a style enqueued solely via `enqueue_block_editor_assets` only
72 * reaches the top-level admin document, never the iframed preview. That
73 * previously left the per-level indentation rules (`.frbl-toc__item--
74 * level-*`) missing from the editor preview even though the same classes
75 * and the same stylesheet made it to the published page.
76 *
77 * @return void
78 */
79 public function enqueue_block_style() {
80 wp_enqueue_style(
81 'frontblocks-toc-style',
82 FRBL_PLUGIN_URL . 'assets/table-of-contents/frontblocks-toc.css',
83 array(),
84 FRBL_VERSION
85 );
86 }
87
88 /**
89 * Start a whole-page output buffer so a Table of Contents block placed
90 * directly in a block-theme Template (outside the Post Content block) is
91 * still filled in. `the_content` alone isn't enough there: only
92 * core/post-content runs that filter, so a TOC block rendered as a
93 * sibling of Post Content in the Template never receives it, and can't
94 * see the headings that live inside Post Content either since those are
95 * filtered in a separate, isolated pass. Buffering the entire page lets
96 * the same placeholder/heading-discovery logic run once over the final,
97 * fully assembled HTML. inject_toc_into_content() is idempotent and
98 * exits immediately when no placeholder is present, so this is a no-op
99 * for every request that doesn't use the block outside Post Content.
100 *
101 * @return void
102 */
103 public function maybe_start_output_buffer() {
104 if ( is_admin() || is_feed() || is_robots() || is_trackback() || wp_doing_ajax() || wp_doing_cron() ) {
105 return;
106 }
107
108 if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
109 return;
110 }
111
112 if ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) {
113 return;
114 }
115
116 ob_start( array( $this, 'inject_toc_into_content' ) );
117 }
118
119 /**
120 * Register the Table of Contents block.
121 *
122 * @return void
123 */
124 public function register_block() {
125 if ( ! function_exists( 'register_block_type' ) ) {
126 return;
127 }
128
129 $args = array(
130 'editor_script' => 'frontblocks-toc-option',
131 'render_callback' => array( $this, 'render_placeholder' ),
132 'attributes' => array(
133 'title' => array(
134 'type' => 'string',
135 'default' => __( 'Table of Contents', 'frontblocks' ),
136 ),
137 'listStyle' => array(
138 'type' => 'string',
139 'default' => 'unordered',
140 ),
141 'accentColor' => array(
142 'type' => 'string',
143 'default' => '',
144 ),
145 'collapsible' => array(
146 'type' => 'boolean',
147 'default' => false,
148 ),
149 'collapsedByDefault' => array(
150 'type' => 'boolean',
151 'default' => false,
152 ),
153 'sticky' => array(
154 'type' => 'boolean',
155 'default' => false,
156 ),
157 'minLevel' => array(
158 'type' => 'number',
159 'default' => 2,
160 ),
161 'maxLevel' => array(
162 'type' => 'number',
163 'default' => 4,
164 ),
165 ),
166 );
167
168 if ( ! WP_Block_Type_Registry::get_instance()->is_registered( self::BLOCK_NAME ) ) {
169 register_block_type( self::BLOCK_NAME, $args );
170 }
171 }
172
173 /**
174 * Render a placeholder carrying this instance's config. The real
175 * navigation markup is filled in later by inject_toc_into_content(),
176 * once every heading in the post has been rendered.
177 *
178 * @param array $attributes Block attributes.
179 * @return string
180 */
181 public function render_placeholder( $attributes ) {
182 $config = array(
183 'title' => isset( $attributes['title'] ) ? (string) $attributes['title'] : __( 'Table of Contents', 'frontblocks' ),
184 'listStyle' => $this->sanitize_list_style( $attributes['listStyle'] ?? 'unordered' ),
185 'accentColor' => isset( $attributes['accentColor'] ) ? sanitize_text_field( $attributes['accentColor'] ) : '',
186 'collapsible' => ! empty( $attributes['collapsible'] ),
187 'collapsedByDefault' => ! empty( $attributes['collapsedByDefault'] ),
188 'sticky' => ! empty( $attributes['sticky'] ),
189 'minLevel' => $this->clamp_level( $attributes['minLevel'] ?? 2 ),
190 'maxLevel' => $this->clamp_level( $attributes['maxLevel'] ?? 4 ),
191 );
192
193 if ( $config['minLevel'] > $config['maxLevel'] ) {
194 list( $config['minLevel'], $config['maxLevel'] ) = array( $config['maxLevel'], $config['minLevel'] );
195 }
196
197 $id = wp_unique_id( 'frbl-toc-' );
198
199 return sprintf(
200 '<div class="frbl-toc-placeholder" data-frbl-toc-id="%1$s" data-frbl-toc-config="%2$s"></div>',
201 esc_attr( $id ),
202 esc_attr( wp_json_encode( $config ) )
203 );
204 }
205
206 /**
207 * Replace every Table of Contents placeholder in the final rendered
208 * content with real navigation markup, after assigning stable, unique
209 * anchors to every heading that doesn't already have one.
210 *
211 * Runs at priority 30 on `the_content` — after do_blocks() (priority 9)
212 * has rendered every block, including headings that come after this
213 * one in the post.
214 *
215 * @param string $content Fully rendered post content.
216 * @return string
217 */
218 public function inject_toc_into_content( $content ) {
219 if ( false === strpos( $content, 'frbl-toc-placeholder' ) ) {
220 return $content;
221 }
222
223 $headings = array();
224 $used_ids = array();
225
226 $content = $this->assign_heading_ids( $content, $headings, $used_ids );
227
228 return preg_replace_callback(
229 '/<div class="frbl-toc-placeholder" data-frbl-toc-id="([^"]*)" data-frbl-toc-config="([^"]*)"><\/div>/',
230 function ( $matches ) use ( $headings ) {
231 $config = json_decode( html_entity_decode( $matches[2], ENT_QUOTES ), true );
232 if ( ! is_array( $config ) ) {
233 return '';
234 }
235
236 return $this->build_toc_markup( $config, $headings );
237 },
238 $content
239 );
240 }
241
242 /**
243 * Find every heading in the content, assign a stable unique id (and a
244 * tabindex, so keyboard navigation can focus it) to any heading that
245 * doesn't already have one, and collect them for the TOC to link to.
246 * Existing author-supplied ids are always preserved untouched.
247 *
248 * @param string $content Rendered content.
249 * @param array $headings Populated by reference with { level, id, text }.
250 * @param array $used_ids Populated by reference with every id already in use.
251 * @return string Content with heading ids/tabindex added where missing.
252 */
253 private function assign_heading_ids( $content, array &$headings, array &$used_ids ) {
254 if ( preg_match_all( '/\bid=["\']([^"\']+)["\']/i', $content, $id_matches ) ) {
255 foreach ( $id_matches[1] as $existing_id ) {
256 $used_ids[ $existing_id ] = true;
257 }
258 }
259
260 return preg_replace_callback(
261 '/<h([1-6])((?:\s[^>]*)?)>(.*?)<\/h\1>/is',
262 function ( $matches ) use ( &$headings, &$used_ids ) {
263 $level = (int) $matches[1];
264 $attributes = $matches[2];
265 $inner_html = $matches[3];
266 $text = trim( wp_strip_all_tags( $inner_html ) );
267
268 if ( '' === $text ) {
269 return $matches[0];
270 }
271
272 if ( preg_match( '/\bid=["\']([^"\']+)["\']/i', $attributes, $id_match ) ) {
273 $id = $id_match[1];
274 $used_ids[ $id ] = true;
275 } else {
276 $id = $this->generate_unique_id( $text, $used_ids );
277 $used_ids[ $id ] = true;
278 $attributes .= ' id="' . esc_attr( $id ) . '"';
279 }
280
281 if ( ! preg_match( '/\btabindex=/i', $attributes ) ) {
282 // data-frbl-toc-heading scopes the focus-visible style in
283 // frontblocks-toc.css to only these headings, so adding
284 // tabindex="-1" here doesn't affect focus styling on
285 // unrelated tabindex="-1" elements elsewhere on the site.
286 $attributes .= ' tabindex="-1" data-frbl-toc-heading="1"';
287 }
288
289 $headings[] = array(
290 'level' => $level,
291 'id' => $id,
292 'text' => $text,
293 );
294
295 return '<h' . $level . $attributes . '>' . $inner_html . '</h' . $level . '>';
296 },
297 $content
298 );
299 }
300
301 /**
302 * Generate a slug for a heading's text, disambiguating it from any id
303 * already in use (including author-supplied ones) with a numeric suffix.
304 *
305 * @param string $text Heading text.
306 * @param array $used_ids Ids already in use, keyed by id.
307 * @return string
308 */
309 private function generate_unique_id( $text, array $used_ids ) {
310 $base = sanitize_title( $text );
311 if ( '' === $base ) {
312 $base = 'section';
313 }
314
315 $id = $base;
316 $suffix = 2;
317 while ( isset( $used_ids[ $id ] ) ) {
318 $id = $base . '-' . $suffix;
319 ++$suffix;
320 }
321
322 return $id;
323 }
324
325 /**
326 * Build the final navigation markup for one Table of Contents instance.
327 *
328 * @param array $config Decoded block config.
329 * @param array $headings All headings found in the content.
330 * @return string
331 */
332 private function build_toc_markup( array $config, array $headings ) {
333 $min_level = $this->clamp_level( $config['minLevel'] ?? 2 );
334 $max_level = $this->clamp_level( $config['maxLevel'] ?? 4 );
335
336 $entries = array_values(
337 array_filter(
338 $headings,
339 function ( $heading ) use ( $min_level, $max_level ) {
340 return $heading['level'] >= $min_level && $heading['level'] <= $max_level;
341 }
342 )
343 );
344
345 if ( empty( $entries ) ) {
346 return '';
347 }
348
349 $title = isset( $config['title'] ) && '' !== $config['title'] ? $config['title'] : __( 'Table of Contents', 'frontblocks' );
350 $list_style = $this->sanitize_list_style( $config['listStyle'] ?? 'unordered' );
351 $list_tag = 'ordered' === $list_style ? 'ol' : 'ul';
352 $sticky = ! empty( $config['sticky'] );
353 $collapsible = ! empty( $config['collapsible'] );
354
355 $list_items = '';
356 foreach ( $entries as $entry ) {
357 $list_items .= sprintf(
358 '<li class="frbl-toc__item frbl-toc__item--level-%1$d"><a class="frbl-toc__link" href="#%2$s">%3$s</a></li>',
359 $entry['level'],
360 esc_attr( $entry['id'] ),
361 esc_html( $entry['text'] )
362 );
363 }
364
365 $list_html = sprintf(
366 '<%1$s class="frbl-toc__list frbl-toc__list--%2$s">%3$s</%1$s>',
367 $list_tag,
368 esc_attr( $list_style ),
369 $list_items
370 );
371
372 $wrapper_style = '';
373 if ( ! empty( $config['accentColor'] ) ) {
374 $wrapper_style = ' style="--frbl-toc-accent: ' . esc_attr( $config['accentColor'] ) . ';"';
375 }
376
377 $title_html = esc_html( $title );
378
379 if ( $collapsible ) {
380 $open = empty( $config['collapsedByDefault'] ) ? ' open' : '';
381
382 return sprintf(
383 '<details class="frbl-toc frbl-toc--collapsible%1$s"%2$s%3$s><summary class="frbl-toc__title">%4$s</summary><nav class="frbl-toc__nav" aria-label="%5$s">%6$s</nav></details>',
384 $sticky ? ' frbl-toc--sticky' : '',
385 $open,
386 $wrapper_style,
387 $title_html,
388 esc_attr( $title ),
389 $list_html
390 );
391 }
392
393 return sprintf(
394 '<nav class="frbl-toc%1$s" aria-label="%2$s"%3$s><p class="frbl-toc__title">%4$s</p>%5$s</nav>',
395 $sticky ? ' frbl-toc--sticky' : '',
396 esc_attr( $title ),
397 $wrapper_style,
398 $title_html,
399 $list_html
400 );
401 }
402
403 /**
404 * Sanitize the listStyle attribute to one of the supported values.
405 *
406 * @param string $value Raw value.
407 * @return string
408 */
409 private function sanitize_list_style( $value ) {
410 return in_array( $value, array( 'unordered', 'ordered', 'plain' ), true ) ? $value : 'unordered';
411 }
412
413 /**
414 * Clamp a heading level to the supported h2-h6 range.
415 *
416 * @param mixed $value Raw value.
417 * @return int
418 */
419 private function clamp_level( $value ) {
420 return max( self::MIN_ALLOWED_LEVEL, min( self::MAX_ALLOWED_LEVEL, (int) $value ) );
421 }
422
423 /**
424 * Enqueue block editor assets.
425 *
426 * @return void
427 */
428 public function enqueue_block_editor_assets() {
429 wp_enqueue_script(
430 'frontblocks-toc-option',
431 FRBL_PLUGIN_URL . 'assets/table-of-contents/frontblocks-toc-option.js',
432 array( 'wp-blocks', 'wp-element', 'wp-components', 'wp-block-editor', 'wp-data', 'wp-i18n' ),
433 FRBL_VERSION,
434 true
435 );
436
437 wp_set_script_translations( 'frontblocks-toc-option', 'frontblocks' );
438 }
439
440 /**
441 * Enqueue frontend assets.
442 *
443 * Not gated behind has_block(): that only inspects the current post's
444 * content, so it misses the block when it's placed directly in a
445 * block-theme Template (outside the Post Content block) instead — the
446 * same placement the whole-page output buffer in
447 * maybe_start_output_buffer() exists to support. Both files are a few KB
448 * combined, so loading them unconditionally on the frontend is a simpler
449 * and more reliable trade-off than trying to detect every possible
450 * placement up front.
451 *
452 * @return void
453 */
454 public function enqueue_frontend_assets() {
455 wp_enqueue_script(
456 'frontblocks-toc-frontend',
457 FRBL_PLUGIN_URL . 'assets/table-of-contents/frontblocks-toc-frontend.js',
458 array(),
459 FRBL_VERSION,
460 true
461 );
462 }
463 }
464