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 |