PluginProbe ʕ •ᴥ•ʔ
Superb Addons: Blocks, Patterns, Pre-built Pages, Sliders, Popups, Free Forms, Animations & More / 4.0.8
Superb Addons: Blocks, Patterns, Pre-built Pages, Sliders, Popups, Free Forms, Animations & More v4.0.8
4.1.0 4.0.9 4.0.8 4.0.7 4.0.6 4.0.5 4.0.4 4.0.3 4.0.2 4.0.1 4.0.0 trunk 1.0.0 2.0.0 2.0.1 2.0.2 2.0.3 3.0 3.0.1 3.0.2 3.0.5 3.0.6 3.0.7 3.0.8 3.0.9 3.1.0 3.1.2 3.1.3 3.2.0 3.2.1 3.2.2 3.2.4 3.2.5 3.2.7 3.2.8 3.2.9 3.3.0 3.3.1 3.3.2 3.4.0 3.4.1 3.4.2 3.4.5 3.4.6 3.5.0 3.5.1 3.5.2 3.5.3 3.5.4 3.5.6 3.5.7 3.5.8 3.5.9 3.6.0 3.6.1 3.6.2 3.7.0 3.7.1
superb-blocks / src / gutenberg / block-api / class-table-of-contents-controller.php
superb-blocks / src / gutenberg / block-api Last commit date
class-author-box-controller.php 1 month ago class-dynamic-block-assets.php 1 month ago class-recent-posts-controller.php 1 month ago class-table-of-contents-controller.php 1 month ago
class-table-of-contents-controller.php
706 lines
1 <?php
2
3 namespace SuperbAddons\Gutenberg\BlocksAPI\Controllers;
4
5 use SuperbAddons\Data\Controllers\LogController;
6 use SuperbAddons\Data\Controllers\OptionController;
7 use Exception;
8
9 defined('ABSPATH') || exit();
10
11 class TableOfContentsController
12 {
13 private static $cached_toc = null;
14 private static $anchor_map = array();
15 private static $anchor_map_index = array();
16 private static $excluded_levels = array();
17 private static $instance_count = 0;
18
19 // Set by getFlattenedPageBlocks(): true when the page is rendered through a
20 // template that places the queried post via a core/post-content block. The
21 // post's own content is then the page content, so the TOC must list only its
22 // headings, not the headings of the surrounding template chrome.
23 // $post_content_blocks holds that inlined post content as the heading source.
24 private static $page_renders_post_content = false;
25 private static $post_content_blocks = array();
26
27 // Blocks whose headings are not part of the linear page content flow:
28 // flattenBlockTree() leaves their innerBlocks untouched and heading
29 // extraction does not descend into them.
30 private static $skip_blocks = array(
31 'superb-addons/popup',
32 'superb-addons/carousel',
33 'superb-addons/accordion-block',
34 'core/details',
35 'core/query',
36 );
37
38 public static function Initialize()
39 {
40 // The template_include pass exists solely to serve the TOC block;
41 // when the block is disabled in settings, skip the hook
42 if (OptionController::IsBlockDisabled('table-of-contents')) {
43 return;
44 }
45
46 add_filter('template_include', array(__CLASS__, 'setupTOC'));
47 }
48
49 public static function setupTOC($template = '')
50 {
51 try {
52 // The TOC block can render from anywhere in the page's block tree
53 // (template body, a template part, the post content, a pattern or a reusable block)
54 $blocks = self::getFlattenedPageBlocks();
55
56 // false => the page renders no TOC block; nothing to set up.
57 $toc_attributes = self::findTocBlockAttributes($blocks);
58 if ($toc_attributes === false) {
59 return $template;
60 }
61
62 self::$cached_toc = null;
63 self::$anchor_map = array();
64 self::$anchor_map_index = array();
65 self::$excluded_levels = array();
66
67 $auto_anchor_links = isset($toc_attributes['autoAnchorLinks']) ? (bool) $toc_attributes['autoAnchorLinks'] : true;
68 $excluded_levels = isset($toc_attributes['excludedHeadingLevels']) && is_array($toc_attributes['excludedHeadingLevels']) ? array_map('intval', $toc_attributes['excludedHeadingLevels']) : array();
69
70 self::$excluded_levels = $excluded_levels;
71
72 // When the template renders the post through a core/post-content
73 // block, scope headings to the post content; otherwise list every
74 // heading in the template (the TOC block still gets found from the
75 // full page tree above).
76 $heading_blocks = self::$page_renders_post_content ? self::$post_content_blocks : $blocks;
77 $headings = array();
78 self::extractHeadingsAndBuildAnchors($heading_blocks, $headings, $auto_anchor_links, $excluded_levels);
79 self::$cached_toc = self::buildTableOfContents($headings);
80
81 // Register anchor injection filter when auto anchor links are enabled
82 if ($auto_anchor_links && !empty(self::$anchor_map)) {
83 add_filter('render_block', array(__CLASS__, 'injectHeadingAnchors'), 10, 2);
84 }
85 } catch (Exception $ex) {
86 LogController::HandleException($ex);
87 }
88
89 return $template;
90 }
91
92 private static function getFlattenedPageBlocks()
93 {
94 global $_wp_current_template_content;
95
96 // Reset the post-content scoping state; flattenBlockTree() repopulates it
97 // if it inlines a core/post-content block while walking the page tree.
98 self::$page_renders_post_content = false;
99 self::$post_content_blocks = array();
100
101 $in_template = is_string($_wp_current_template_content) && $_wp_current_template_content !== '';
102 if ($in_template) {
103 $root_markup = $_wp_current_template_content;
104 } else {
105 $post = get_post();
106 $root_markup = ($post && isset($post->post_content) && is_string($post->post_content)) ? $post->post_content : '';
107 }
108
109 $visited = array();
110 $blocks = self::parseAndFlatten($root_markup, $visited);
111
112 // Post-content scoping only makes sense for a templated page: outside a
113 // template the root markup already is the post content, so there is no
114 // template chrome to exclude.
115 if (!$in_template) {
116 self::$page_renders_post_content = false;
117 self::$post_content_blocks = array();
118 }
119
120 return $blocks;
121 }
122
123 private static function parseAndFlatten($markup, &$visited)
124 {
125 if (!is_string($markup) || $markup === '') {
126 return array();
127 }
128 $blocks = parse_blocks($markup);
129 if (!is_array($blocks)) {
130 return array();
131 }
132 return self::flattenBlockTree($blocks, $visited);
133 }
134
135 private static function flattenBlockTree($blocks, &$visited)
136 {
137 $result = array();
138 if (!is_array($blocks)) {
139 return $result;
140 }
141
142 foreach ($blocks as $block) {
143 // parse_blocks() yields non-block entries (raw HTML) with no blockName.
144 if (!is_array($block) || !isset($block['blockName'])) {
145 continue;
146 }
147
148 $name = $block['blockName'];
149
150 if ($name === 'core/template-part') {
151 $result = array_merge($result, self::expandTemplatePart($block, $visited));
152 } elseif ($name === 'core/post-content') {
153 $expanded = self::expandPostContent($visited);
154 // The rendered page includes the queried post's content here, so
155 // its headings are the page content. Remember that, and keep this
156 // inlined content as the heading source for post-content scoping.
157 // Capture the first occurrence only; a second core/post-content
158 // resolves empty through the visit guard in expandPostContent().
159 if (!self::$page_renders_post_content) {
160 self::$page_renders_post_content = true;
161 self::$post_content_blocks = $expanded;
162 }
163 $result = array_merge($result, $expanded);
164 } elseif ($name === 'core/pattern') {
165 $result = array_merge($result, self::expandPattern($block, $visited));
166 } elseif ($name === 'core/block') {
167 $result = array_merge($result, self::expandReusableBlock($block, $visited));
168 } else {
169 // Recurse into children unless the block is skip-listed.
170 if (!in_array($name, self::$skip_blocks, true) && !empty($block['innerBlocks']) && is_array($block['innerBlocks'])) {
171 $block['innerBlocks'] = self::flattenBlockTree($block['innerBlocks'], $visited);
172 }
173 $result[] = $block;
174 }
175 }
176
177 return $result;
178 }
179
180 private static function expandTemplatePart($block, &$visited)
181 {
182 if (!isset($block['attrs']['slug']) || !is_string($block['attrs']['slug']) || $block['attrs']['slug'] === '') {
183 return array();
184 }
185
186 // Skip site chrome up-front when the placement declares its area.
187 $attr_area = isset($block['attrs']['area']) && is_string($block['attrs']['area']) ? $block['attrs']['area'] : '';
188 if ($attr_area === 'header' || $attr_area === 'footer') {
189 return array();
190 }
191
192 $theme = isset($block['attrs']['theme']) && is_string($block['attrs']['theme']) && $block['attrs']['theme'] !== '' ? $block['attrs']['theme'] : get_stylesheet();
193 $template_part_id = $theme . '//' . $block['attrs']['slug'];
194
195 $visit_key = 'part:' . $template_part_id;
196 if (isset($visited[$visit_key])) {
197 return array();
198 }
199 $visited[$visit_key] = true;
200
201 if (!function_exists('get_block_template')) {
202 return array();
203 }
204 $part = get_block_template($template_part_id, 'wp_template_part');
205 if (!is_object($part) || !isset($part->content) || !is_string($part->content) || $part->content === '') {
206 return array();
207 }
208
209 // Confirm the area against the resolved part before descending.
210 if (isset($part->area) && is_string($part->area) && ($part->area === 'header' || $part->area === 'footer')) {
211 return array();
212 }
213
214 return self::parseAndFlatten($part->content, $visited);
215 }
216
217 private static function expandPostContent(&$visited)
218 {
219 $post = get_post();
220 if (!is_object($post) || !isset($post->ID)) {
221 return array();
222 }
223
224 $visit_key = 'post:' . intval($post->ID);
225 if (isset($visited[$visit_key])) {
226 return array();
227 }
228 $visited[$visit_key] = true;
229
230 if (!isset($post->post_content) || !is_string($post->post_content)) {
231 return array();
232 }
233
234 return self::parseAndFlatten($post->post_content, $visited);
235 }
236
237 private static function expandPattern($block, &$visited)
238 {
239 if (!isset($block['attrs']['slug']) || !is_string($block['attrs']['slug']) || $block['attrs']['slug'] === '') {
240 return array();
241 }
242 $slug = $block['attrs']['slug'];
243
244 $visit_key = 'pattern:' . $slug;
245 if (isset($visited[$visit_key])) {
246 return array();
247 }
248 $visited[$visit_key] = true;
249
250 if (!class_exists('WP_Block_Patterns_Registry')) {
251 return array();
252 }
253 $registry = \WP_Block_Patterns_Registry::get_instance();
254 if (!$registry->is_registered($slug)) {
255 return array();
256 }
257 $pattern = $registry->get_registered($slug);
258 if (!is_array($pattern) || !isset($pattern['content']) || !is_string($pattern['content'])) {
259 return array();
260 }
261
262 return self::parseAndFlatten($pattern['content'], $visited);
263 }
264
265 private static function expandReusableBlock($block, &$visited)
266 {
267 if (!isset($block['attrs']['ref'])) {
268 return array();
269 }
270 $ref = intval($block['attrs']['ref']);
271 if ($ref <= 0) {
272 return array();
273 }
274
275 // Keyed by post ID (same namespace as expandPostContent) so a reusable
276 // block and core/post-content resolving to the same post expand once.
277 $visit_key = 'post:' . $ref;
278 if (isset($visited[$visit_key])) {
279 return array();
280 }
281 $visited[$visit_key] = true;
282
283 $reusable_post = get_post($ref);
284 if (!is_object($reusable_post) || !isset($reusable_post->post_content) || !is_string($reusable_post->post_content)) {
285 return array();
286 }
287
288 return self::parseAndFlatten($reusable_post->post_content, $visited);
289 }
290
291 private static function findTocBlockAttributes($blocks)
292 {
293 if (!is_array($blocks)) {
294 return false;
295 }
296 foreach ($blocks as $block) {
297 if (!is_array($block) || !isset($block['blockName'])) {
298 continue;
299 }
300 if ($block['blockName'] === 'superb-addons/table-of-contents') {
301 return isset($block['attrs']) && is_array($block['attrs']) ? $block['attrs'] : array();
302 }
303 if (!empty($block['innerBlocks']) && is_array($block['innerBlocks'])) {
304 $result = self::findTocBlockAttributes($block['innerBlocks']);
305 if ($result !== false) {
306 return $result;
307 }
308 }
309 }
310 return false;
311 }
312
313 /**
314 * Dynamic render callback for the TOC block.
315 */
316 public static function DynamicRender($attributes, $content)
317 {
318 try {
319 $attributes = is_array($attributes) ? $attributes : array();
320
321 if (self::$cached_toc !== null) {
322 $toc = self::$cached_toc;
323 } else {
324 // Fallback for REST / preview contexts, or when template_include
325 // setup did not run (e.g. a TOC block nested inside a template
326 // part). Headings come from the same flattened page tree; auto
327 // anchor injection is unavailable here because the render_block
328 // filter was not registered.
329 $blocks = self::getFlattenedPageBlocks();
330 $auto_anchor_links = isset($attributes['autoAnchorLinks']) ? (bool) $attributes['autoAnchorLinks'] : true;
331 $excluded_levels = isset($attributes['excludedHeadingLevels']) && is_array($attributes['excludedHeadingLevels']) ? array_map('intval', $attributes['excludedHeadingLevels']) : array();
332 $heading_blocks = self::$page_renders_post_content ? self::$post_content_blocks : $blocks;
333 $headings = array();
334 self::extractHeadingsAndBuildAnchors($heading_blocks, $headings, $auto_anchor_links, $excluded_levels);
335 $toc = self::buildTableOfContents($headings);
336 }
337
338 $smooth_scroll = isset($attributes['smoothScroll']) ? (bool) $attributes['smoothScroll'] : true;
339 if ($smooth_scroll) {
340 wp_enqueue_script(
341 'superbaddons-toc-smooth-scroll',
342 SUPERBADDONS_ASSETS_PATH . '/js/dynamic-blocks/table-of-contents-smooth-scroll.js',
343 array(),
344 SUPERBADDONS_VERSION,
345 true
346 );
347 }
348
349 return self::render($attributes, $toc);
350 } catch (Exception $ex) {
351 LogController::HandleException($ex);
352 return '';
353 }
354 }
355
356 /**
357 * Recursively extract headings from parsed blocks.
358 * Builds anchor map for auto-anchor injection.
359 */
360 private static function extractHeadingsAndBuildAnchors($blocks, &$headings, $auto_anchor_links, $excluded_levels = array())
361 {
362 $seen_slugs = array();
363
364 self::extractHeadingsRecursive($blocks, $headings, $auto_anchor_links, $seen_slugs, $excluded_levels);
365 }
366
367 private static function extractHeadingsRecursive($blocks, &$headings, $auto_anchor_links, &$seen_slugs, $excluded_levels = array())
368 {
369 if (!is_array($blocks)) {
370 return;
371 }
372 if (!is_array($excluded_levels)) {
373 $excluded_levels = array();
374 }
375 foreach ($blocks as $block) {
376 // parse_blocks() can yield non-block array entries (e.g. raw HTML),
377 // which have no blockName. Skip them rather than throwing.
378 if (!is_array($block) || !isset($block['blockName'])) {
379 continue;
380 }
381 if ($block['blockName'] === 'core/heading') {
382 $inner_html = isset($block['innerHTML']) && is_string($block['innerHTML']) ? $block['innerHTML'] : '';
383 $text = wp_strip_all_tags($inner_html);
384 $level = isset($block['attrs']['level']) ? intval($block['attrs']['level']) : 2;
385
386 // Skip excluded heading levels
387 if (in_array($level, $excluded_levels, true)) {
388 continue;
389 }
390 // core/heading stores its manual anchor as the HTML `id` (block.json
391 // declares `anchor` with source: "attribute"), so it is NOT present
392 // in $block['attrs'] after parse_blocks(). Read attrs first as a
393 // belt-and-braces fallback, then extract from innerHTML.
394 $anchor = isset($block['attrs']['anchor']) && is_string($block['attrs']['anchor']) && $block['attrs']['anchor'] !== '' ? $block['attrs']['anchor'] : false;
395 if ($anchor === false && $inner_html !== '') {
396 $manual_id = self::extractHeadingIdFromHtml($inner_html);
397 if ($manual_id !== '') {
398 $anchor = $manual_id;
399 }
400 }
401
402 if ($anchor) {
403 // Manual anchor set
404 $headings[] = array(
405 'title' => $text,
406 'level' => $level,
407 'anchor' => $anchor,
408 );
409 } elseif ($auto_anchor_links && $text !== '') {
410 // Auto-generate anchor
411 $slug = sanitize_title($text);
412 if (isset($seen_slugs[$slug])) {
413 $seen_slugs[$slug]++;
414 $slug = $slug . '-' . $seen_slugs[$slug];
415 } else {
416 $seen_slugs[$slug] = 1;
417 }
418 $headings[] = array(
419 'title' => $text,
420 'level' => $level,
421 'anchor' => $slug,
422 );
423 // Store in anchor map for injection (array to handle duplicate texts)
424 if (!isset(self::$anchor_map[$text])) {
425 self::$anchor_map[$text] = array();
426 }
427 self::$anchor_map[$text][] = $slug;
428 } else {
429 // No anchor, not auto-linked
430 $headings[] = array(
431 'title' => $text,
432 'level' => $level,
433 'anchor' => false,
434 );
435 }
436 }
437
438 // Don't recurse into blocks whose headings aren't part of the page
439 // content flow. Reference blocks (template parts, post content,
440 // patterns, reusable blocks) were already inlined by flattenBlockTree().
441 if (!in_array($block['blockName'], self::$skip_blocks, true) && !empty($block['innerBlocks']) && is_array($block['innerBlocks'])) {
442 self::extractHeadingsRecursive($block['innerBlocks'], $headings, $auto_anchor_links, $seen_slugs, $excluded_levels);
443 }
444 }
445 }
446
447 /**
448 * Build nested table of contents from flat heading list.
449 * PHP port of the JS nesting algorithm from headinghandler.js.
450 */
451 private static function buildTableOfContents($headings)
452 {
453 $toc = array();
454 $top_level_headings = array();
455
456 foreach ($headings as $heading) {
457 $item = array(
458 'title' => $heading['title'],
459 'level' => $heading['level'],
460 'anchor' => $heading['anchor'],
461 'children' => array(),
462 );
463
464 // Reset all lower levels
465 $max_level = empty($top_level_headings) ? 0 : max(array_keys($top_level_headings));
466 for ($i = $item['level'] + 1; $i <= $max_level; $i++) {
467 unset($top_level_headings[$i]);
468 }
469
470 // Set current level
471 $top_level_headings[$item['level']] = &$item;
472
473 // Find parent
474 $parent = false;
475 for ($i = $item['level'] - 1; $i > 0; $i--) {
476 if (isset($top_level_headings[$i]) && $top_level_headings[$i] !== false) {
477 $parent = &$top_level_headings[$i];
478 break;
479 }
480 }
481
482 if ($parent !== false) {
483 $parent['children'][] = &$item;
484 } else {
485 $toc[] = &$item;
486 }
487
488 unset($item);
489 unset($parent);
490 }
491
492 return $toc;
493 }
494
495 /**
496 * Extract the `id` attribute from the first heading tag in a chunk of
497 * heading-block innerHTML. Returns '' if none is present or HTML parsing
498 * fails. core/heading's manual anchor lives here (source: "attribute"),
499 * not in the block's attrs JSON.
500 */
501 private static function extractHeadingIdFromHtml($inner_html)
502 {
503 if (!is_string($inner_html) || $inner_html === '') {
504 return '';
505 }
506 if (!class_exists('WP_HTML_Tag_Processor')) {
507 return '';
508 }
509 $processor = new \WP_HTML_Tag_Processor($inner_html);
510 while ($processor->next_tag()) {
511 $tag = $processor->get_tag();
512 if ($tag !== null && preg_match('/^H[1-6]$/', $tag)) {
513 $id = $processor->get_attribute('id');
514 if (is_string($id) && $id !== '') {
515 return $id;
516 }
517 return '';
518 }
519 }
520 return '';
521 }
522
523 /*
524 * Resolve a color value: prefer WPC slug as CSS custom property, then explicit raw value.
525 */
526 private static function resolveColor($attributes, $attrName)
527 {
528 $wpc = isset($attributes[$attrName . 'WPC']) && is_string($attributes[$attrName . 'WPC']) ? $attributes[$attrName . 'WPC'] : '';
529 $raw = isset($attributes[$attrName]) && is_string($attributes[$attrName]) ? $attributes[$attrName] : '';
530 if ($wpc !== '') {
531 return 'var(--wp--preset--color--' . esc_attr($wpc) . ')';
532 }
533 if ($raw !== '') {
534 return esc_attr($raw);
535 }
536 return '';
537 }
538
539 /*
540 * Render the TOC HTML.
541 */
542 private static function render($attributes, $toc)
543 {
544 $alignment = isset($attributes['toolbarAlignment']) && is_string($attributes['toolbarAlignment']) ? $attributes['toolbarAlignment'] : 'left';
545 if (!in_array($alignment, array('left', 'center', 'right'), true)) {
546 $alignment = 'left';
547 }
548 $label_enabled = isset($attributes['labelTitleEnabled']) ? (bool) $attributes['labelTitleEnabled'] : true;
549 $label_title = isset($attributes['labelTitle']) && is_string($attributes['labelTitle']) ? $attributes['labelTitle'] : __('Table of Contents', 'superb-blocks');
550 $font_size_title = isset($attributes['fontSizeTitle']) ? intval($attributes['fontSizeTitle']) : 32;
551 $font_size_text = isset($attributes['fontSizeText']) ? intval($attributes['fontSizeText']) : 14;
552 $list_style = isset($attributes['listStyle']) && is_string($attributes['listStyle']) ? $attributes['listStyle'] : 'ordered';
553 $use_ordered_list = $list_style === 'ordered';
554
555 // Build inline style with CSS variables for colors
556 $style_parts = array();
557 $color_title = self::resolveColor($attributes, 'colorTitle');
558 $color_text = self::resolveColor($attributes, 'colorText');
559 $color_anchor = self::resolveColor($attributes, 'colorAnchor');
560 if ($color_title) {
561 $style_parts[] = '--superb-toc-title-color:' . $color_title;
562 }
563 if ($color_text) {
564 $style_parts[] = '--superb-toc-text-color:' . $color_text;
565 }
566 if ($color_anchor) {
567 $style_parts[] = '--superb-toc-anchor-color:' . $color_anchor;
568 }
569 $inline_style = !empty($style_parts) ? implode(';', $style_parts) : '';
570
571 $smooth_scroll = isset($attributes['smoothScroll']) ? (bool) $attributes['smoothScroll'] : true;
572
573 $wrapper_extra = array();
574 if (!empty($inline_style)) {
575 $wrapper_extra['style'] = $inline_style;
576 }
577 if ($smooth_scroll) {
578 $wrapper_extra['data-smooth-scroll'] = 'true';
579 }
580
581 $wrapper_attributes = get_block_wrapper_attributes($wrapper_extra);
582
583 self::$instance_count++;
584 $title_id = 'superb-toc-title-' . self::$instance_count;
585
586 ob_start();
587 ?>
588 <div <?php
589 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- get_block_wrapper_attributes() returns pre-escaped HTML attribute markup per WP core API.
590 echo $wrapper_attributes;
591 ?>>
592 <nav class="superbaddons-tableofcontents superbaddons-tableofcontents-alignment-<?php echo esc_attr($alignment); ?>" <?php echo $label_enabled ? ' aria-labelledby="' . esc_attr($title_id) . '"' : ' aria-label="' . esc_attr($label_title) . '"'; ?>>
593 <?php if ($label_enabled) : ?>
594 <span id="<?php echo esc_attr($title_id); ?>" class="superbaddons-tableofcontents-title" style="font-size:<?php echo esc_attr($font_size_title); ?>px;line-height:<?php echo esc_attr($font_size_title + 8); ?>px;"><?php echo wp_kses_post($label_title); ?></span>
595 <?php endif; ?>
596 <div class="superbaddons-tableofcontents-table">
597 <?php
598 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- renderList() returns HTML composed of values passed through tag_escape/esc_attr/esc_html within the method.
599 echo self::renderList($toc, $font_size_text, $use_ordered_list ? 'decimal' : '', $use_ordered_list);
600 ?>
601 </div>
602 </nav>
603 </div>
604 <?php
605 return ob_get_clean();
606 }
607
608 /**
609 * Recursively render nested lists (ordered or unordered).
610 */
611 private static function renderList($items, $font_size_text, $list_style_type, $use_ordered_list = true)
612 {
613 if (empty($items)) {
614 return '';
615 }
616
617 $tag = $use_ordered_list ? 'ol' : 'ul';
618 $list_style_attr_value = $use_ordered_list && $list_style_type !== '' ? $list_style_type : '';
619
620 ob_start();
621 ?>
622 <<?php echo tag_escape($tag); ?><?php if ($list_style_attr_value !== '') : ?> style="list-style-type:<?php echo esc_attr($list_style_attr_value); ?>" <?php endif; ?>>
623 <?php foreach ($items as $item) : ?>
624 <li style="font-size:<?php echo esc_attr($font_size_text); ?>px;line-height:<?php echo esc_attr($font_size_text + 14); ?>px;">
625 <?php if ($item['anchor'] !== false) : ?>
626 <a href="#<?php echo esc_attr($item['anchor']); ?>"><?php echo esc_html($item['title']); ?></a>
627 <?php else : ?>
628 <span><?php echo esc_html($item['title']); ?></span>
629 <?php endif; ?>
630 <?php
631 if (!empty($item['children'])) {
632 // First nesting level uses lower-alpha, deeper uses lower-roman
633 $child_style = ($list_style_type === 'decimal') ? 'lower-alpha' : 'lower-roman';
634 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- renderList() returns HTML composed of values passed through tag_escape/esc_attr/esc_html within the method.
635 echo self::renderList($item['children'], $font_size_text, $use_ordered_list ? $child_style : '', $use_ordered_list);
636 }
637 ?>
638 </li>
639 <?php endforeach; ?>
640 </<?php echo tag_escape($tag); ?>>
641 <?php
642 return ob_get_clean();
643 }
644
645 /**
646 * render_block filter callback — injects id attributes onto core/heading blocks.
647 */
648 public static function injectHeadingAnchors($block_content, $block)
649 {
650 // Other plugins on the same filter can return non-string content for
651 // hidden/empty blocks; bail before handing it to WP_HTML_Tag_Processor.
652 if (!is_string($block_content) || $block_content === '') {
653 return $block_content;
654 }
655 if (!is_array($block) || !isset($block['blockName']) || $block['blockName'] !== 'core/heading') {
656 return $block_content;
657 }
658
659 // Skip excluded heading levels
660 $level = isset($block['attrs']['level']) ? intval($block['attrs']['level']) : 2;
661 if (!empty(self::$excluded_levels) && in_array($level, self::$excluded_levels, true)) {
662 return $block_content;
663 }
664
665 // Skip headings that already have an anchor attribute
666 if (isset($block['attrs']['anchor']) && !empty($block['attrs']['anchor'])) {
667 return $block_content;
668 }
669
670 $inner_html = isset($block['innerHTML']) && is_string($block['innerHTML']) ? $block['innerHTML'] : '';
671 $text = $inner_html !== '' ? wp_strip_all_tags($inner_html) : wp_strip_all_tags($block_content);
672 if (empty($text) || !isset(self::$anchor_map[$text]) || empty(self::$anchor_map[$text])) {
673 return $block_content;
674 }
675
676 // Track which anchor to use for duplicate heading texts
677 if (!isset(self::$anchor_map_index[$text])) {
678 self::$anchor_map_index[$text] = 0;
679 }
680 $index = self::$anchor_map_index[$text];
681 if (!isset(self::$anchor_map[$text][$index])) {
682 return $block_content;
683 }
684
685 // Seek the heading tag itself rather than trusting the first tag to be it:
686 // another plugin's filter may have wrapped the heading in an element, and
687 // injecting the id onto that wrapper would leave the heading without one.
688 $processor = new \WP_HTML_Tag_Processor($block_content);
689 while ($processor->next_tag()) {
690 $tag = $processor->get_tag();
691 if ($tag === null || !preg_match('/^H[1-6]$/', $tag)) {
692 continue;
693 }
694 $existing_id = $processor->get_attribute('id');
695 if (empty($existing_id)) {
696 $processor->set_attribute('id', self::$anchor_map[$text][$index]);
697 self::$anchor_map_index[$text]++;
698 $block_content = $processor->get_updated_html();
699 }
700 break;
701 }
702
703 return $block_content;
704 }
705 }
706