← All changes
|
build/scripts/block-library/table-of-contents.php
+400
-2
23.3.2
→
trunk
View file →
| @@ -5,16 +5,21 @@ | ||
| 5 | 5 | * @package WordPress |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | /** |
| 9 | + * The Heading block's default level when no `level` attribute is saved. | |
| 10 | + */ | |
| 11 | +const BLOCK_CORE_TABLE_OF_CONTENTS_DEFAULT_HEADING_LEVEL = 2; | |
| 12 | + | |
| 13 | +/** | |
| 9 | 14 | * Adds an aria-label to the table of contents block content. |
| 10 | 15 | * |
| 11 | 16 | * @param array $attributes Attributes of the block being rendered. |
| 12 | - * @param string $content Content of the block being rendered. | |
| 17 | + * @param string $content Content of the block being rendered. | |
| 13 | 18 | * |
| 14 | 19 | * @return string The content of the block being rendered. |
| 15 | 20 | */ |
| 16 | -function gutenberg_block_core_table_of_contents_render( $attributes, $content ) { | |
| 21 | +function gutenberg_block_core_table_of_contents_add_aria_label( $attributes, $content ) { | |
| 17 | 22 | if ( ! $content ) { |
| 18 | 23 | return $content; |
| 19 | 24 | } |
| 20 | 25 | |
| @@ -27,8 +32,401 @@ | ||
| 27 | 32 | $p->set_attribute( 'aria-label', $aria_label ); |
| 28 | 33 | } |
| 29 | 34 | |
| 30 | 35 | return $p->get_updated_html(); |
| 36 | +} | |
| 37 | + | |
| 38 | +/** | |
| 39 | + * Gets the link for a heading. | |
| 40 | + * | |
| 41 | + * Headings without an id cannot be linked. Non-paginated posts can use a local | |
| 42 | + * fragment link. Paginated posts need a full permalink so headings on later | |
| 43 | + * pages link to the correct page before applying the fragment. | |
| 44 | + * | |
| 45 | + * @param string $id Heading id. | |
| 46 | + * @param array $context Heading resolution context used while scanning the | |
| 47 | + * post content. | |
| 48 | + * | |
| 49 | + * @return string Heading link. | |
| 50 | + */ | |
| 51 | +function gutenberg_block_core_table_of_contents_get_heading_link( $id, $context = array() ) { | |
| 52 | + if ( '' === $id ) { | |
| 53 | + return ''; | |
| 54 | + } | |
| 55 | + | |
| 56 | + if ( empty( $context['is_paginated'] ) || empty( $context['permalink'] ) ) { | |
| 57 | + return '#' . $id; | |
| 58 | + } | |
| 59 | + | |
| 60 | + $page = isset( $context['current_page'] ) ? max( 1, (int) $context['current_page'] ) : 1; | |
| 61 | + $permalink = remove_query_arg( 'page', $context['permalink'] ); | |
| 62 | + | |
| 63 | + // Page 1 uses the canonical permalink, e.g. `/post/#intro`. Later pages use | |
| 64 | + // the page query arg before the fragment, e.g. `/post/?page=2#details`. | |
| 65 | + if ( 1 < $page ) { | |
| 66 | + $permalink = add_query_arg( 'page', $page, $permalink ); | |
| 67 | + } | |
| 68 | + | |
| 69 | + return $permalink . '#' . $id; | |
| 70 | +} | |
| 71 | + | |
| 72 | +/** | |
| 73 | + * Normalizes raw page break comments so the block processor can see them. | |
| 74 | + * | |
| 75 | + * Classic content can store page breaks as bare `<!--nextpage-->` markers, and | |
| 76 | + * the Page Break block also saves that marker as its inner content. Because | |
| 77 | + * `WP_Block_Processor` only advances through block comments, wrapping those | |
| 78 | + * markers as `core/nextpage` blocks lets the ToC count paginated post pages in | |
| 79 | + * the same pass that it scans headings. | |
| 80 | + * | |
| 81 | + * @param string $content Serialized block content. | |
| 82 | + * | |
| 83 | + * @return string Content with page breaks wrapped as nextpage blocks. | |
| 84 | + */ | |
| 85 | +function gutenberg_block_core_table_of_contents_normalize_nextpage_blocks( $content ) { | |
| 86 | + // Collapse already-wrapped nextpage blocks first so the replacement below | |
| 87 | + // never nests a page break inside another nextpage wrapper. | |
| 88 | + $content = preg_replace( | |
| 89 | + '/<!--\s+wp:(?:core\/)?nextpage\s+-->\s*<!--nextpage-->\s*<!--\s+\/wp:(?:core\/)?nextpage\s+-->/', | |
| 90 | + '<!--nextpage-->', | |
| 91 | + $content | |
| 92 | + ); | |
| 93 | + | |
| 94 | + return str_replace( | |
| 95 | + '<!--nextpage-->', | |
| 96 | + '<!-- wp:nextpage --><!--nextpage--><!-- /wp:nextpage -->', | |
| 97 | + $content | |
| 98 | + ); | |
| 99 | +} | |
| 100 | + | |
| 101 | +/** | |
| 102 | + * Gets the heading data from a heading block. | |
| 103 | + * | |
| 104 | + * @param array $block Parsed heading block. | |
| 105 | + * @param int $max_level Maximum heading level to include. | |
| 106 | + * @param array $context Heading resolution context. | |
| 107 | + * | |
| 108 | + * @return array|null Heading data, or null when the heading should be skipped. | |
| 109 | + */ | |
| 110 | +function gutenberg_block_core_table_of_contents_get_heading_from_block( $block, $max_level, $context = array() ) { | |
| 111 | + if ( ! is_array( $block ) ) { | |
| 112 | + return null; | |
| 113 | + } | |
| 114 | + | |
| 115 | + $level = isset( $block['attrs']['level'] ) | |
| 116 | + ? (int) $block['attrs']['level'] | |
| 117 | + : BLOCK_CORE_TABLE_OF_CONTENTS_DEFAULT_HEADING_LEVEL; | |
| 118 | + | |
| 119 | + if ( $max_level && $level > $max_level ) { | |
| 120 | + return null; | |
| 121 | + } | |
| 122 | + | |
| 123 | + $rendered_heading = render_block( $block ); | |
| 124 | + $processor = new WP_HTML_Tag_Processor( $rendered_heading ); | |
| 125 | + $heading_tags = array( 'H1', 'H2', 'H3', 'H4', 'H5', 'H6' ); | |
| 126 | + $id = ''; | |
| 127 | + | |
| 128 | + while ( $processor->next_tag() ) { | |
| 129 | + if ( in_array( $processor->get_tag(), $heading_tags, true ) ) { | |
| 130 | + $id = $processor->get_attribute( 'id' ); | |
| 131 | + break; | |
| 132 | + } | |
| 133 | + } | |
| 134 | + | |
| 135 | + if ( ! is_string( $id ) ) { | |
| 136 | + $id = ''; | |
| 137 | + } | |
| 138 | + | |
| 139 | + $content = preg_replace( '/<br\s*\/?>/i', ' ', $rendered_heading ); | |
| 140 | + // Decode entities from rendered heading HTML before the ToC escapes them | |
| 141 | + // once. Use html_entity_decode() because wp_specialchars_decode() only | |
| 142 | + // handles special HTML characters, while headings can contain other named | |
| 143 | + // entities. | |
| 144 | + $content = html_entity_decode( | |
| 145 | + trim( wp_strip_all_tags( $content ) ), | |
| 146 | + ENT_QUOTES, | |
| 147 | + get_option( 'blog_charset' ) | |
| 148 | + ); | |
| 149 | + | |
| 150 | + if ( '' === $content ) { | |
| 151 | + return null; | |
| 152 | + } | |
| 153 | + | |
| 154 | + return array( | |
| 155 | + 'content' => $content, | |
| 156 | + 'level' => $level, | |
| 157 | + 'link' => gutenberg_block_core_table_of_contents_get_heading_link( $id, $context ), | |
| 158 | + ); | |
| 159 | +} | |
| 160 | + | |
| 161 | +/** | |
| 162 | + * Normalizes heading resolution context. | |
| 163 | + * | |
| 164 | + * The context is the shared state used while walking the current post content. | |
| 165 | + * It tracks whether the post is paginated, which page is being scanned, which | |
| 166 | + * page the rendered ToC should include, and which permalink should be used for | |
| 167 | + * page-aware heading links. Callers can pass partial context, so this helper | |
| 168 | + * fills defaults and normalizes booleans and page numbers before scanning. | |
| 169 | + * | |
| 170 | + * @param string $content Serialized block content. | |
| 171 | + * @param array $context Heading resolution context. | |
| 172 | + * | |
| 173 | + * @return array Normalized heading resolution context. | |
| 174 | + */ | |
| 175 | +function gutenberg_block_core_table_of_contents_normalize_heading_context( $content, $context = array() ) { | |
| 176 | + $context = wp_parse_args( | |
| 177 | + $context, | |
| 178 | + array( | |
| 179 | + 'current_page' => 1, | |
| 180 | + 'is_paginated' => str_contains( $content, '<!--nextpage-->' ), | |
| 181 | + 'only_include_current_page' => false, | |
| 182 | + 'permalink' => '', | |
| 183 | + 'target_page' => 1, | |
| 184 | + ) | |
| 185 | + ); | |
| 186 | + | |
| 187 | + return array( | |
| 188 | + 'current_page' => max( 1, (int) $context['current_page'] ), | |
| 189 | + 'is_paginated' => ! empty( $context['is_paginated'] ), | |
| 190 | + 'only_include_current_page' => ! empty( $context['only_include_current_page'] ), | |
| 191 | + 'permalink' => $context['permalink'], | |
| 192 | + 'target_page' => max( 1, (int) $context['target_page'] ), | |
| 193 | + ); | |
| 194 | +} | |
| 195 | + | |
| 196 | +/** | |
| 197 | + * Collects heading data from block content. | |
| 198 | + * | |
| 199 | + * @param string $content Block content to scan. | |
| 200 | + * @param int $max_level Maximum heading level to include. | |
| 201 | + * @param array $context Heading resolution context. | |
| 202 | + * | |
| 203 | + * @return array Heading data. | |
| 204 | + */ | |
| 205 | +function gutenberg_block_core_table_of_contents_get_headings_from_content( $content, $max_level = 0, $context = array() ) { | |
| 206 | + if ( ! class_exists( 'WP_Block_Processor' ) || '' === trim( $content ) ) { | |
| 207 | + return array(); | |
| 208 | + } | |
| 209 | + | |
| 210 | + $content = gutenberg_block_core_table_of_contents_normalize_nextpage_blocks( $content ); | |
| 211 | + $context = gutenberg_block_core_table_of_contents_normalize_heading_context( $content, $context ); | |
| 212 | + $headings = array(); | |
| 213 | + $processor = new WP_Block_Processor( $content ); | |
| 214 | + | |
| 215 | + while ( $processor->next_block() ) { | |
| 216 | + $block_type = $processor->get_block_type(); | |
| 217 | + | |
| 218 | + if ( 'core/nextpage' === $block_type ) { | |
| 219 | + ++$context['current_page']; | |
| 220 | + continue; | |
| 221 | + } | |
| 222 | + | |
| 223 | + // `only_include_current_page` is the normalized form of the block's | |
| 224 | + // `onlyIncludeCurrentPage` attribute. When false, headings from every | |
| 225 | + // paginated page are included. | |
| 226 | + $include_current_page = ( | |
| 227 | + empty( $context['only_include_current_page'] ) || | |
| 228 | + $context['current_page'] === $context['target_page'] | |
| 229 | + ); | |
| 230 | + | |
| 231 | + if ( 'core/heading' !== $block_type || ! $include_current_page ) { | |
| 232 | + continue; | |
| 233 | + } | |
| 234 | + | |
| 235 | + $block = $processor->extract_full_block_and_advance(); | |
| 236 | + $heading = gutenberg_block_core_table_of_contents_get_heading_from_block( $block, $max_level, $context ); | |
| 237 | + | |
| 238 | + if ( $heading ) { | |
| 239 | + $headings[] = $heading; | |
| 240 | + } | |
| 241 | + } | |
| 242 | + | |
| 243 | + return $headings; | |
| 244 | +} | |
| 245 | + | |
| 246 | +/** | |
| 247 | + * Gets the current page number for paginated post content. | |
| 248 | + * | |
| 249 | + * @global int $page Current page number of the content. | |
| 250 | + * | |
| 251 | + * @return int Current page number. | |
| 252 | + */ | |
| 253 | +function gutenberg_block_core_table_of_contents_get_current_page_number() { | |
| 254 | + global $page; | |
| 255 | + | |
| 256 | + $current_page = (int) get_query_var( 'page' ); | |
| 257 | + if ( ! $current_page && isset( $page ) ) { | |
| 258 | + $current_page = (int) $page; | |
| 259 | + } | |
| 260 | + | |
| 261 | + return max( 1, $current_page ); | |
| 262 | +} | |
| 263 | + | |
| 264 | +/** | |
| 265 | + * Converts a flat list of headings to a nested list. | |
| 266 | + * | |
| 267 | + * @param array $headings Flat heading data. | |
| 268 | + * | |
| 269 | + * @return array Nested heading data. | |
| 270 | + */ | |
| 271 | +function gutenberg_block_core_table_of_contents_linear_to_nested_heading_list( $headings ) { | |
| 272 | + $nested_headings = array(); | |
| 273 | + | |
| 274 | + foreach ( $headings as $index => $heading ) { | |
| 275 | + if ( | |
| 276 | + '' === $heading['content'] || | |
| 277 | + $heading['level'] !== $headings[0]['level'] | |
| 278 | + ) { | |
| 279 | + continue; | |
| 280 | + } | |
| 281 | + | |
| 282 | + if ( | |
| 283 | + isset( $headings[ $index + 1 ] ) && | |
| 284 | + $headings[ $index + 1 ]['level'] > $heading['level'] | |
| 285 | + ) { | |
| 286 | + // The following headings are children until another heading at | |
| 287 | + // the current level appears. Slice that child run for recursion | |
| 288 | + // so nested nodes are not duplicated as top-level siblings. | |
| 289 | + $end_of_slice = count( $headings ); | |
| 290 | + for ( $i = $index + 1; $i < count( $headings ); $i++ ) { | |
| 291 | + if ( $headings[ $i ]['level'] === $heading['level'] ) { | |
| 292 | + $end_of_slice = $i; | |
| 293 | + break; | |
| 294 | + } | |
| 295 | + } | |
| 296 | + | |
| 297 | + // The child slice starts after the current heading, so each | |
| 298 | + // recursive call receives fewer headings than its caller. | |
| 299 | + $child_headings = array_slice( | |
| 300 | + $headings, | |
| 301 | + $index + 1, | |
| 302 | + $end_of_slice - $index - 1 | |
| 303 | + ); | |
| 304 | + $nested_headings[] = array( | |
| 305 | + 'heading' => $heading, | |
| 306 | + 'children' => gutenberg_block_core_table_of_contents_linear_to_nested_heading_list( $child_headings ), | |
| 307 | + ); | |
| 308 | + } else { | |
| 309 | + $nested_headings[] = array( | |
| 310 | + 'heading' => $heading, | |
| 311 | + 'children' => null, | |
| 312 | + ); | |
| 313 | + } | |
| 314 | + } | |
| 315 | + | |
| 316 | + return $nested_headings; | |
| 317 | +} | |
| 318 | + | |
| 319 | +/** | |
| 320 | + * Builds the table of contents list items. | |
| 321 | + * | |
| 322 | + * @param array $nested_headings Nested heading data. | |
| 323 | + * @param string $list_tag List tag name. | |
| 324 | + * | |
| 325 | + * @return string List item markup. | |
| 326 | + */ | |
| 327 | +function gutenberg_block_core_table_of_contents_build_list_items( $nested_headings, $list_tag ) { | |
| 328 | + $list = ''; | |
| 329 | + | |
| 330 | + foreach ( $nested_headings as $node ) { | |
| 331 | + $heading = $node['heading']; | |
| 332 | + $content = esc_html( $heading['content'] ); | |
| 333 | + | |
| 334 | + if ( '' !== $heading['link'] ) { | |
| 335 | + $entry = sprintf( | |
| 336 | + '<a class="wp-block-table-of-contents__entry" href="%1$s">%2$s</a>', | |
| 337 | + esc_url( $heading['link'] ), | |
| 338 | + $content | |
| 339 | + ); | |
| 340 | + } else { | |
| 341 | + $entry = sprintf( | |
| 342 | + '<span class="wp-block-table-of-contents__entry">%s</span>', | |
| 343 | + $content | |
| 344 | + ); | |
| 345 | + } | |
| 346 | + | |
| 347 | + $list .= '<li>' . $entry; | |
| 348 | + | |
| 349 | + if ( ! empty( $node['children'] ) ) { | |
| 350 | + $list .= sprintf( | |
| 351 | + '<%1$s>%2$s</%1$s>', | |
| 352 | + $list_tag, | |
| 353 | + gutenberg_block_core_table_of_contents_build_list_items( $node['children'], $list_tag ) | |
| 354 | + ); | |
| 355 | + } | |
| 356 | + | |
| 357 | + $list .= '</li>'; | |
| 358 | + } | |
| 359 | + | |
| 360 | + return $list; | |
| 361 | +} | |
| 362 | + | |
| 363 | +/** | |
| 364 | + * Renders the table of contents block from current post headings. | |
| 365 | + * | |
| 366 | + * @param array $attributes Attributes of the block being rendered. | |
| 367 | + * @param string $content Content of the block being rendered. | |
| 368 | + * @param WP_Block|null $block Block instance. | |
| 369 | + * | |
| 370 | + * @return string The content of the block being rendered. | |
| 371 | + */ | |
| 372 | +function gutenberg_block_core_table_of_contents_render( $attributes, $content, $block = null ) { | |
| 373 | + global $wp_current_filter; | |
| 374 | + | |
| 375 | + // Preserve legacy saved markup for old posts that have not been edited and migrated yet. | |
| 376 | + $legacy_content = $content; | |
| 377 | + if ( '' === trim( $legacy_content ) && ! empty( $block->parsed_block['innerHTML'] ) ) { | |
| 378 | + $legacy_content = $block->parsed_block['innerHTML']; | |
| 379 | + } | |
| 380 | + | |
| 381 | + // Once a post is edited and saved, the block migrates to dynamic rendering. | |
| 382 | + if ( '' !== trim( $legacy_content ) ) { | |
| 383 | + return gutenberg_block_core_table_of_contents_add_aria_label( $attributes, $legacy_content ); | |
| 384 | + } | |
| 385 | + | |
| 386 | + // Outside post content rendering, there is no reliable current post to scan. | |
| 387 | + if ( ! is_array( $wp_current_filter ) || ! in_array( 'the_content', $wp_current_filter, true ) ) { | |
| 388 | + return gutenberg_block_core_table_of_contents_add_aria_label( $attributes, $content ); | |
| 389 | + } | |
| 390 | + | |
| 391 | + $post = get_post(); | |
| 392 | + if ( ! $post ) { | |
| 393 | + return ''; | |
| 394 | + } | |
| 395 | + | |
| 396 | + $max_level = isset( $attributes['maxLevel'] ) ? (int) $attributes['maxLevel'] : 0; | |
| 397 | + // Heading context records the current pagination state so collection can | |
| 398 | + // skip headings outside the rendered page and build page-aware links. | |
| 399 | + $context = gutenberg_block_core_table_of_contents_normalize_heading_context( | |
| 400 | + $post->post_content, | |
| 401 | + array( | |
| 402 | + 'only_include_current_page' => ! empty( $attributes['onlyIncludeCurrentPage'] ), | |
| 403 | + 'permalink' => get_permalink( $post ), | |
| 404 | + 'target_page' => gutenberg_block_core_table_of_contents_get_current_page_number(), | |
| 405 | + ) | |
| 406 | + ); | |
| 407 | + $headings = gutenberg_block_core_table_of_contents_get_headings_from_content( $post->post_content, $max_level, $context ); | |
| 408 | + | |
| 409 | + if ( empty( $headings ) ) { | |
| 410 | + return ''; | |
| 411 | + } | |
| 412 | + | |
| 413 | + $ordered = array_key_exists( 'ordered', $attributes ) | |
| 414 | + ? (bool) $attributes['ordered'] | |
| 415 | + : true; | |
| 416 | + $list_tag = $ordered ? 'ol' : 'ul'; | |
| 417 | + $wrapper_attributes = get_block_wrapper_attributes(); | |
| 418 | + $content = sprintf( | |
| 419 | + '<nav %1$s><%2$s>%3$s</%2$s></nav>', | |
| 420 | + $wrapper_attributes, | |
| 421 | + $list_tag, | |
| 422 | + gutenberg_block_core_table_of_contents_build_list_items( | |
| 423 | + gutenberg_block_core_table_of_contents_linear_to_nested_heading_list( $headings ), | |
| 424 | + $list_tag | |
| 425 | + ) | |
| 426 | + ); | |
| 427 | + | |
| 428 | + return gutenberg_block_core_table_of_contents_add_aria_label( $attributes, $content ); | |
| 31 | 429 | } |
| 32 | 430 | |
| 33 | 431 | /** |
| 34 | 432 | * Registers the `core/table-of-contents` block on the server. |