PluginProbe
Gutenberg / trunk
Gutenberg vtrunk
24.1.0 24.0.0 23.9.1 23.9.0 23.8.0 23.7.2 23.7.1 23.7.0 23.6.1 23.6.2 23.6.0 23.5.3 23.5.2 23.5.1 23.5.0 23.4.0 23.3.2 23.3.1 23.3.0 23.2.0 23.2.1 23.2.2 23.1.1 23.1.0 23.0.1 All 404 releases
gutenberg / build / scripts / block-library / table-of-contents.php

table-of-contents.php in Gutenberg trunk, at build/scripts/block-library/table-of-contents.php

443 lines 13.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Server-side rendering of the `core/table-of-contents` block.
4 *
5 * @package WordPress
6 */
7
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 /**
14 * Adds an aria-label to the table of contents block content.
15 *
16 * @param array $attributes Attributes of the block being rendered.
17 * @param string $content Content of the block being rendered.
18 *
19 * @return string The content of the block being rendered.
20 */
21 function gutenberg_block_core_table_of_contents_add_aria_label( $attributes, $content ) {
22 if ( ! $content ) {
23 return $content;
24 }
25
26 // Get the aria-label from block attributes, or fallback to localized default.
27 $aria_label = empty( $attributes['ariaLabel'] ) ? __( 'Table of Contents' ) : wp_strip_all_tags( $attributes['ariaLabel'] );
28
29 $p = new WP_HTML_Tag_Processor( $content );
30
31 if ( $p->next_tag( 'nav' ) ) {
32 $p->set_attribute( 'aria-label', $aria_label );
33 }
34
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 );
429 }
430
431 /**
432 * Registers the `core/table-of-contents` block on the server.
433 */
434 function gutenberg_register_block_core_table_of_contents() {
435 register_block_type_from_metadata(
436 __DIR__ . '/table-of-contents',
437 array(
438 'render_callback' => 'gutenberg_block_core_table_of_contents_render',
439 )
440 );
441 }
442 add_action( 'init', 'gutenberg_register_block_core_table_of_contents', 20 );
443