diff --git a/src/wp-includes/block-bindings.php b/src/wp-includes/block-bindings.php index 1250f92e99848..7809e05cd4185 100644 --- a/src/wp-includes/block-bindings.php +++ b/src/wp-includes/block-bindings.php @@ -138,6 +138,7 @@ function get_block_bindings_source( string $source_name ) { * @since 6.9.0 * @since 7.1.0 Added support for the List Item block. * @since 7.2.0 Added support for the Icon block. + * @since 7.3.0 Added support for the inner blocks of the Group and Column blocks. * * @param string $block_type The block type whose supported attributes are being retrieved. * @return array The list of block attributes that are supported by block bindings. @@ -153,6 +154,8 @@ function get_block_bindings_supported_attributes( $block_type ) { 'core/navigation-link' => array( 'url' ), 'core/navigation-submenu' => array( 'url' ), 'core/icon' => array( 'icon' ), + 'core/group' => array( 'innerBlocks' ), + 'core/column' => array( 'innerBlocks' ), ); $supported_block_attributes = diff --git a/src/wp-includes/block-editor.php b/src/wp-includes/block-editor.php index 24bdd2f6db5db..caf4d7d38db59 100644 --- a/src/wp-includes/block-editor.php +++ b/src/wp-includes/block-editor.php @@ -485,6 +485,8 @@ function wp_get_post_content_block_attributes() { * Returns the contextualized block editor settings for a selected editor context. * * @since 5.8.0 + * @since 7.3.0 Leaves `innerBlocks` out of the attributes that block bindings support, + * as the editor doesn't render its bindings yet. * * @param array $custom_settings Custom settings to use with the given editor type. * @param WP_Block_Editor_Context $block_editor_context The current block editor context. @@ -502,7 +504,7 @@ function get_block_editor_settings( array $custom_settings, $block_editor_contex $editor_settings['__experimentalBlockBindingsSupportedAttributes'] = array(); foreach ( array_keys( WP_Block_Type_Registry::get_instance()->get_all_registered() ) as $block_type ) { - $supported_block_attributes = get_block_bindings_supported_attributes( $block_type ); + $supported_block_attributes = array_values( array_diff( get_block_bindings_supported_attributes( $block_type ), array( 'innerBlocks' ) ) ); if ( ! empty( $supported_block_attributes ) ) { $editor_settings['__experimentalBlockBindingsSupportedAttributes'][ $block_type ] = $supported_block_attributes; } diff --git a/src/wp-includes/class-wp-block.php b/src/wp-includes/class-wp-block.php index 8baf0f99bacde..cd975fd1d777a 100644 --- a/src/wp-includes/class-wp-block.php +++ b/src/wp-includes/class-wp-block.php @@ -65,6 +65,15 @@ class WP_Block { */ protected $registry; + /** + * The blocks whose inner blocks a block bindings source supplied are + * rendering, outermost first, each with the request it made to the source. + * + * @since 7.3.0 + * @var array[] + */ + private static $rendering_inner_blocks = array(); + /** * List of inner blocks (of this same class) * @@ -273,6 +282,8 @@ public function __get( $name ) { * @since 6.5.0 * @since 6.6.0 Handle the `__default` attribute for pattern overrides. * @since 6.7.0 Return any updated bindings metadata in the computed attributes. + * @since 7.3.0 Replace the inner blocks of the block for an `innerBlocks` binding, + * which the `__default` binding doesn't add. * * @return array The computed block attributes for the provided block bindings. */ @@ -310,6 +321,9 @@ private function process_block_bindings() { * resulting array. */ foreach ( $supported_block_attributes as $attribute_name ) { + if ( 'innerBlocks' === $attribute_name && ! isset( $bindings['innerBlocks'] ) ) { + continue; + } // Retain any non-pattern override bindings that might be present. $updated_bindings[ $attribute_name ] = $bindings[ $attribute_name ] ?? array( 'source' => 'core/pattern-overrides' ); } @@ -348,7 +362,13 @@ private function process_block_bindings() { } } - $source_args = ! empty( $block_binding['args'] ) && is_array( $block_binding['args'] ) ? $block_binding['args'] : array(); + $source_args = ! empty( $block_binding['args'] ) && is_array( $block_binding['args'] ) ? $block_binding['args'] : array(); + + if ( 'innerBlocks' === $attribute_name ) { + $this->replace_inner_blocks( $block_binding_source, $source_args ); + continue; + } + $source_value = $block_binding_source->get_value( $source_args, $this, $attribute_name ); // If the value is not null, process the HTML based on the block and the attribute. @@ -360,6 +380,285 @@ private function process_block_bindings() { return $computed_attributes; } + /** + * Replaces the inner blocks of the block with the ones a block bindings + * source supplies, before they render. + * + * Not while the inner blocks the source supplied for the same request + * render: the block then keeps its own inner blocks, and the source isn't + * asked. This way sources whose values bind each other's, directly or + * through content that loads as it renders, like the menu of a Navigation + * block, can't render forever. Bindings that ask a source something else + * still resolve, inside supplied inner blocks too. + * + * @since 7.3.0 + * + * @param WP_Block_Bindings_Source $source The source of the binding. + * @param array $source_args The arguments of the binding. + */ + private function replace_inner_blocks( WP_Block_Bindings_Source $source, array $source_args ) { + $request = $this->get_inner_blocks_request( $source, $source_args ); + foreach ( self::$rendering_inner_blocks as $rendering ) { + if ( $rendering['request'] === $request ) { + return; + } + } + + $value = $source->get_value( $source_args, $this, 'innerBlocks' ); + if ( ! self::is_inner_blocks_value( $value ) ) { + return; + } + + $blocks = $this->parse_bound_inner_blocks( $value, $source, $source_args ); + $inner_content = self::get_inner_content_with_slots( $this->parsed_block['innerContent'] ?? null, count( $blocks ) ); + if ( null === $inner_content ) { + return; + } + + $this->parsed_block['innerBlocks'] = $blocks; + $this->parsed_block['innerContent'] = $inner_content; + $this->parsed_block['innerHTML'] = implode( '', array_filter( $inner_content, 'is_string' ) ); + $this->inner_blocks = new WP_Block_List( array() ); + $this->inner_content = $inner_content; + $this->inner_html = $this->parsed_block['innerHTML']; + $this->refresh_parsed_block_dependents(); + + self::$rendering_inner_blocks[] = array( + 'block' => $this, + 'request' => $request, + ); + } + + /** + * Returns what the block asks its block bindings source for `innerBlocks`: + * the source, the arguments of the binding, the context the source uses, + * and the attributes of the block. + * + * Two blocks with the same request get the same inner blocks from the + * source. + * + * @since 7.3.0 + * + * @param WP_Block_Bindings_Source $source The source of the binding. + * @param array $source_args The arguments of the binding. + * @return array The request. + */ + private function get_inner_blocks_request( WP_Block_Bindings_Source $source, array $source_args ): array { + $context = array(); + foreach ( (array) $source->uses_context as $context_name ) { + if ( array_key_exists( $context_name, $this->context ) ) { + $context[ $context_name ] = $this->context[ $context_name ]; + } + } + return array( $source->name, $source_args, $context, $this->attributes ); + } + + /** + * Stops tracking the block, once it has rendered, if the inner blocks a + * source supplied for it were rendering. + * + * The blocks still rendering inside it, if any, stopped with it: a block + * that doesn't finish rendering, because of an exception, never gets here, + * so they are forgotten along with it. + * + * @since 7.3.0 + */ + private function forget_rendered_inner_blocks() { + foreach ( self::$rendering_inner_blocks as $index => $rendering ) { + if ( $rendering['block'] === $this ) { + self::$rendering_inner_blocks = array_slice( self::$rendering_inner_blocks, 0, $index ); + return; + } + } + } + + /** + * Determines whether the value of a block bindings source supplies inner + * blocks. + * + * Serialized block markup supplies them, and an empty string supplies none. + * Markup supplies them when the first block delimiter the block parser + * finds, after any whitespace, opens a block. Any other value, like the + * plain text of a field, leaves the block with its own inner blocks. + * + * @since 7.3.0 + * + * @param mixed $value The value of the source. + * @return bool Whether the value supplies inner blocks. + */ + private static function is_inner_blocks_value( $value ): bool { + if ( '' === $value ) { + return true; + } + if ( ! is_string( $value ) ) { + return false; + } + + $processor = new WP_Block_Processor( $value ); + while ( $processor->next_token() ) { + if ( $processor->is_html() && ! $processor->is_non_whitespace_html() ) { + continue; + } + return ! $processor->is_html() && $processor->opens_block(); + } + return false; + } + + /** + * Parses the inner blocks a block bindings source supplies. + * + * The markup never went through the filters that sanitize post content on + * save, and the user who supplied it may not be allowed unfiltered HTML, so + * by default it is sanitized as on save, attributes of its blocks included. + * Its embeds are expanded as the embeds of the content were, before its + * blocks render. + * + * @since 7.3.0 + * + * @global WP_Embed $wp_embed WordPress Embed object. + * + * @param string $markup The serialized blocks. + * @param WP_Block_Bindings_Source $source The source of the binding. + * @param array $source_args The arguments of the binding. + * @return array[] The parsed blocks. + */ + private function parse_bound_inner_blocks( string $markup, WP_Block_Bindings_Source $source, array $source_args ): array { + global $wp_embed; + + /** + * Filters whether the inner blocks a block bindings source supplies are + * sanitized before they render. + * + * By default the markup is sanitized as the post content of a user + * without the `unfiltered_html` capability, since the user who supplied + * it is unknown: `wp_kses_post()` removes the HTML that user may not + * publish, in the attributes of the blocks included. A source whose + * markup comes from trusted code, not from users, may keep it as + * supplied. Custom CSS is removed from the blocks either way. + * + * Sanitizing keeps the block delimiters that `serialize_blocks()` + * writes, which escapes the characters `wp_kses_post()` rewrites + * inside comments. + * + * @since 7.3.0 + * + * @param bool $sanitize Whether to sanitize the markup. Default true. + * @param string $source_name The name of the source. + * @param array $source_args The arguments of the binding. + * @param WP_Block $block_instance The block whose inner blocks the source supplies. + */ + if ( apply_filters( 'block_bindings_sanitize_inner_blocks', true, $source->name, $source_args, $this ) ) { + $markup = wp_kses_post( $markup ); + } + if ( $wp_embed instanceof WP_Embed ) { + $markup = $wp_embed->run_shortcode( $markup ); + $markup = $wp_embed->autoembed( $markup ); + } + + return self::prepare_bound_inner_blocks( parse_blocks( $markup ) ); + } + + /** + * Prepares the blocks a block bindings source supplies, and their inner + * blocks, to render. + * + * Removes their custom CSS, which only users who can edit CSS may add: + * whoever supplied the value is unknown. + * + * @since 7.3.0 + * + * @param array[] $blocks The parsed blocks. + * @return array[] The parsed blocks. + */ + private static function prepare_bound_inner_blocks( array $blocks ): array { + foreach ( $blocks as &$block ) { + if ( isset( $block['attrs']['style'] ) ) { + $block['attrs']['style'] = wp_strip_custom_css_from_style_array( $block['attrs']['style'] ); + if ( empty( $block['attrs']['style'] ) ) { + unset( $block['attrs']['style'] ); + } + } + + $block['innerBlocks'] = self::prepare_bound_inner_blocks( $block['innerBlocks'] ); + } + return $blocks; + } + + /** + * Returns the inner content of a block with slots for the given number of + * inner blocks. + * + * The slots replace the block's own inner blocks and the HTML between them. + * A block without inner blocks gets them before the closing tag of the + * element that wraps its content. + * + * @since 7.3.0 + * + * @param array|null $inner_content The inner content of the block. + * @param int $count The number of inner blocks. + * @return array|null The inner content, or null if there's no element to put + * the inner blocks in. + */ + private static function get_inner_content_with_slots( $inner_content, int $count ): ?array { + if ( ! is_array( $inner_content ) ) { + return null; + } + + $slots = array_fill( 0, $count, null ); + $own_slots = array_keys( $inner_content, null, true ); + if ( $own_slots ) { + $before_first_slot = array_slice( $inner_content, 0, $own_slots[0] ); + $after_last_slot = array_slice( $inner_content, end( $own_slots ) + 1 ); + return array_merge( $before_first_slot, $slots, $after_last_slot ); + } + + $wrapper_parts = self::split_at_wrapper_closer( implode( '', $inner_content ) ); + if ( null === $wrapper_parts ) { + return null; + } + return array_merge( array( $wrapper_parts[0] ), $slots, array( $wrapper_parts[1] ) ); + } + + /** + * Splits the HTML of an element that wraps a block's content before the + * element's closing tag. + * + * @since 7.3.0 + * + * @param string $html The HTML of the wrapper element. + * @return string[]|null The HTML before the closing tag and the HTML from it, + * or null if the HTML has no wrapper element to split. + */ + private static function split_at_wrapper_closer( string $html ): ?array { + $processor = WP_HTML_Processor::create_fragment( $html ); + if ( null === $processor ) { + return null; + } + + $before = ''; + $after = ''; + $depth_outside_wrapper = null; + $is_at_wrapper_closer = false; + while ( $processor->next_token() ) { + if ( null === $depth_outside_wrapper && '#tag' === $processor->get_token_type() && ! $processor->is_tag_closer() ) { + $depth_outside_wrapper = $processor->get_current_depth() - 1; + } elseif ( $processor->is_tag_closer() && $processor->get_current_depth() === $depth_outside_wrapper ) { + $is_at_wrapper_closer = true; + } + + if ( $is_at_wrapper_closer ) { + $after .= $processor->serialize_token(); + } else { + $before .= $processor->serialize_token(); + } + } + + if ( ! $is_at_wrapper_closer || null !== $processor->get_last_error() || $processor->paused_at_incomplete_token() ) { + return null; + } + return array( $before, $after ); + } + /** * Depending on the block attribute name, replace its value in the HTML based on the value provided. * @@ -704,6 +1003,8 @@ public function render( $options = array() ) { } } + $this->forget_rendered_inner_blocks(); + /** * Filters the content of a single block. * diff --git a/tests/phpunit/tests/block-bindings/innerBlocks.php b/tests/phpunit/tests/block-bindings/innerBlocks.php new file mode 100644 index 0000000000000..bbb4c5adaf52b --- /dev/null +++ b/tests/phpunit/tests/block-bindings/innerBlocks.php @@ -0,0 +1,603 @@ +value = null; + $this->inner_blocks_calls = 0; + register_block_bindings_source( + self::SOURCE, + array( + 'label' => 'Test', + 'get_value_callback' => function ( $source_args, $block_instance, $attribute_name ) { + if ( 'innerBlocks' !== $attribute_name ) { + return 'Bound text'; + } + ++$this->inner_blocks_calls; + return $this->value instanceof Closure ? call_user_func( $this->value, $source_args, $block_instance ) : $this->value; + }, + ) + ); + } + + public function tear_down() { + unregister_block_bindings_source( self::SOURCE ); + parent::tear_down(); + } + + /** + * Renders a Group block with the given bindings and, by default, a + * `Default` paragraph as its own inner blocks. + * + * @param array|null $bindings The bindings of the Group block. + * @param string $inner_html The markup inside the Group's wrapper. + * @return string The rendered block. + */ + private function render_group( $bindings = null, $inner_html = '

Default

' ) { + $bindings = $bindings ?? array( 'innerBlocks' => array( 'source' => self::SOURCE ) ); + $markup = ''; + $markup .= '
' . $inner_html . '
'; + $markup .= ''; + $block = new WP_Block( parse_blocks( $markup )[0] ); + return $block->render(); + } + + /** + * Returns the markup of a Group block bound to the test source, with a + * paragraph of its own. + * + * @param array $args The arguments of the binding. + * @param string $own The text of the Group's own paragraph. + * @return string The markup. + */ + private function bound_group_markup( $args = array(), $own = 'Nested own' ) { + $binding = array( 'source' => self::SOURCE ); + if ( $args ) { + $binding['args'] = $args; + } + return '

' . $own . '

'; + } + + /** + * Returns the attributes the supplied blocks render with. + * + * @return array[] The attributes of each rendered block, in render order. + */ + private function render_group_and_get_supplied_attributes() { + $attributes = array(); + $capture = static function ( $parsed_block, $source_block, $parent_block ) use ( &$attributes ) { + if ( $parent_block ) { + $attributes[] = $parsed_block['attrs']; + } + return $parsed_block; + }; + add_filter( 'render_block_data', $capture, 10, 3 ); + $this->render_group(); + remove_filter( 'render_block_data', $capture ); + return $attributes; + } + + /** + * @ticket 66415 + */ + public function test_value_replaces_inner_blocks() { + $this->value = '

One

Two

'; + $output = $this->render_group(); + $this->assertStringContainsString( '>One

assertStringContainsString( '>Two

', $output ); + $this->assertStringNotContainsString( 'Default', $output ); + } + + /** + * @ticket 66415 + */ + public function test_value_may_start_with_whitespace() { + $this->value = "\n\t

One

"; + $this->assertStringContainsString( '>One

', $this->render_group() ); + } + + /** + * @ticket 66415 + * + * @dataProvider data_whitespace_after_the_delimiter_opener + * + * @param string $whitespace The whitespace between `

One

"; + $output = $this->render_group(); + $this->assertStringContainsString( '>One

', $output ); + $this->assertStringNotContainsString( 'Default', $output ); + } + + public function data_whitespace_after_the_delimiter_opener() { + return array( + 'space' => array( ' ' ), + 'tab' => array( "\t" ), + 'newline' => array( "\n" ), + ); + } + + /** + * @ticket 66415 + */ + public function test_whitespace_value_keeps_own_inner_blocks() { + $this->value = " \n\t"; + $this->assertStringContainsString( '>Default

', $this->render_group() ); + } + + /** + * @ticket 66415 + */ + public function test_value_starting_with_a_closer_keeps_own_inner_blocks() { + $this->value = '

One

'; + $this->assertStringContainsString( '>Default

', $this->render_group() ); + } + + /** + * @ticket 66415 + */ + public function test_empty_value_removes_inner_blocks() { + $this->value = ''; + $output = $this->render_group(); + $this->assertStringNotContainsString( 'assertStringContainsString( 'wp-block-group', $output ); + } + + /** + * @ticket 66415 + */ + public function test_empty_value_removes_inner_blocks_without_wrapper_markup() { + $this->value = ''; + $output = render_block( parse_blocks( '

Default

' )[0] ); + $this->assertSame( '', $output ); + } + + public function test_without_value_keeps_own_inner_blocks() { + $this->assertStringContainsString( '>Default

', $this->render_group() ); + } + + /** + * @ticket 66415 + */ + public function test_plain_text_value_keeps_own_inner_blocks() { + $this->value = 'Plain text'; + $output = $this->render_group(); + $this->assertStringContainsString( '>Default

', $output ); + $this->assertStringNotContainsString( 'Plain text', $output ); + } + + /** + * @ticket 66415 + */ + public function test_value_does_not_become_an_attribute() { + $this->value = '

One

'; + $block = new WP_Block( parse_blocks( '
' )[0] ); + $block->render(); + $this->assertArrayNotHasKey( 'innerBlocks', $block->attributes ); + } + + /** + * @ticket 66415 + */ + public function test_default_pattern_overrides_binding_does_not_bind_inner_blocks() { + $requested = array(); + $record = static function ( $value, $source_name, $source_args, $block_instance, $attribute_name ) use ( &$requested ) { + $requested[] = $attribute_name; + return $value; + }; + add_filter( 'block_bindings_source_value', $record, 10, 5 ); + $output = $this->render_group( array( '__default' => array( 'source' => 'core/pattern-overrides' ) ) ); + remove_filter( 'block_bindings_source_value', $record ); + + $this->assertNotContains( 'innerBlocks', $requested ); + $this->assertSame( $this->render_group( array() ), $output ); + } + + /** + * @ticket 66415 + */ + public function test_value_is_sanitized() { + $this->value = '

Safe

'; + $output = $this->render_group(); + $this->assertStringNotContainsString( '