diff --git a/src/wp-includes/block-bindings.php b/src/wp-includes/block-bindings.php index 268bb6afa66bb..5a20c023149d7 100644 --- a/src/wp-includes/block-bindings.php +++ b/src/wp-includes/block-bindings.php @@ -134,6 +134,7 @@ function get_block_bindings_source( string $source_name ) { * Retrieves the list of block attributes supported by block bindings. * * @since 6.9.0 + * @since 7.1.0 Added support for the List Item block. * * @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. @@ -142,6 +143,7 @@ function get_block_bindings_supported_attributes( $block_type ) { $block_bindings_supported_attributes = array( 'core/paragraph' => array( 'content' ), 'core/heading' => array( 'content' ), + 'core/list-item' => array( 'content' ), 'core/image' => array( 'id', 'url', 'title', 'alt', 'caption' ), 'core/button' => array( 'url', 'text', 'linkTarget', 'rel' ), 'core/post-date' => array( 'datetime' ), diff --git a/src/wp-includes/class-wp-block.php b/src/wp-includes/class-wp-block.php index f59b770d93b5b..8baf0f99bacde 100644 --- a/src/wp-includes/class-wp-block.php +++ b/src/wp-includes/class-wp-block.php @@ -364,13 +364,16 @@ private function process_block_bindings() { * Depending on the block attribute name, replace its value in the HTML based on the value provided. * * @since 6.5.0 + * @since 7.1.0 Added the optional `$inner_block_offsets` parameter. * - * @param string $block_content Block content. - * @param string $attribute_name The attribute name to replace. - * @param mixed $source_value The value used to replace in the HTML. + * @param string $block_content Block content. + * @param string $attribute_name The attribute name to replace. + * @param mixed $source_value The value used to replace in the HTML. + * @param int[] $inner_block_offsets Optional. Byte offsets where each inner block's + * rendered output begins. Default empty array. * @return string The modified block content. */ - private function replace_html( string $block_content, string $attribute_name, $source_value ) { + private function replace_html( string $block_content, string $attribute_name, $source_value, array $inner_block_offsets = array() ) { $block_type = $this->block_type; if ( ! isset( $block_type->attributes[ $attribute_name ]['source'] ) ) { return $block_content; @@ -396,9 +399,16 @@ private function replace_html( string $block_content, string $attribute_name, $s 'tag_name' => $selector, ) ) ) { - // TODO: Use `WP_HTML_Processor::set_inner_html` method once it's available. + /* + * TODO: Use `WP_HTML_Processor::set_inner_html()` once it's available. + * Any replacement must preserve already-rendered inner block + * markup verbatim (it may come from dynamic blocks), so it + * cannot re-serialize the element's contents. Until an API with + * that guarantee exists, the replacement is spliced by byte + * offset, leaving inner block output untouched. + */ $block_reader->release_bookmark( 'iterate-selectors' ); - $block_reader->replace_rich_text( wp_kses_post( $source_value ) ); + $block_reader->replace_rich_text( wp_kses_post( $source_value ), $inner_block_offsets ); return $block_reader->get_updated_html(); } else { $block_reader->seek( 'iterate-selectors' ); @@ -433,10 +443,17 @@ private static function get_block_bindings_processor( string $block_content ) { * When stopped on a tag opener, replace the content enclosed by it and its * matching closer with the provided rich text. * - * @param string $rich_text The rich text to replace the original content with. + * If byte offsets of inner blocks' rendered output are provided, the + * replacement stops at the first inner block found inside the element, + * preserving any markup produced by nested inner blocks (e.g. a List + * block nested inside a List Item). + * + * @param string $rich_text The rich text to replace the original content with. + * @param int[] $inner_block_offsets Optional. Byte offsets in the source HTML where + * inner blocks' rendered output begins. Default empty array. * @return bool True on success. */ - public function replace_rich_text( $rich_text ) { + public function replace_rich_text( $rich_text, $inner_block_offsets = array() ) { if ( $this->is_tag_closer() || ! $this->expects_closer() ) { return false; } @@ -461,6 +478,25 @@ public function replace_rich_text( $rich_text ) { $tag_closer = $this->bookmarks['__wp_block_bindings']; $end = $tag_closer->start; + /* + * Stop at the first inner block that renders inside this element so + * its markup is preserved. The block's own rich text always precedes + * its inner blocks, so replacing up to the first inner block offset + * replaces only that rich text. Offsets are recorded during render in + * the same byte coordinates as this fragment, and are in ascending + * order, so the first match is the earliest inner block. + * + * The lower bound is inclusive of `$start`: when an inner block + * begins immediately, with no leading rich text, the (empty) rich + * text is still replaced instead of the inner block markup. + */ + foreach ( $inner_block_offsets as $inner_block_offset ) { + if ( $inner_block_offset >= $start && $inner_block_offset < $end ) { + $end = $inner_block_offset; + break; + } + } + $this->lexical_updates[] = new WP_HTML_Text_Replacement( $start, $end - $start, @@ -479,6 +515,7 @@ public function replace_rich_text( $rich_text ) { * * @since 5.5.0 * @since 6.5.0 Added block bindings processing. + * @since 7.1.0 Preserve inner blocks when binding a rich text attribute. * * @global WP_Post $post Global post object. * @@ -538,6 +575,19 @@ public function render( $options = array() ) { $is_dynamic = $options['dynamic'] && $this->name && null !== $this->block_type && $this->block_type->is_dynamic(); $block_content = ''; + /* + * Byte offsets in $block_content where each inner block's rendered output + * begins. Block bindings rich-text replacement uses these to stop at the + * first inner block inside a selector, so it replaces only the block's own + * rich text and never the markup produced by nested inner blocks. + * + * They are only collected when the block has bound attributes to resolve; + * otherwise they are never read, so recording them would add work to every + * block render for no benefit. + */ + $inner_block_offsets = array(); + $collect_offsets = ! empty( $computed_attributes ); + if ( ! $options['dynamic'] || empty( $this->block_type->skip_inner_blocks ) ) { $index = 0; @@ -545,6 +595,9 @@ public function render( $options = array() ) { if ( is_string( $chunk ) ) { $block_content .= $chunk; } else { + if ( $collect_offsets ) { + $inner_block_offsets[] = strlen( $block_content ); + } $inner_block = $this->inner_blocks[ $index ]; $parent_block = $this; @@ -583,7 +636,23 @@ public function render( $options = array() ) { if ( ! empty( $computed_attributes ) && ! empty( $block_content ) ) { foreach ( $computed_attributes as $attribute_name => $source_value ) { - $block_content = $this->replace_html( $block_content, $attribute_name, $source_value ); + $updated_block_content = $this->replace_html( $block_content, $attribute_name, $source_value, $inner_block_offsets ); + + /* + * The offsets describe $block_content as it was assembled. A + * replacement that modifies the markup shifts byte positions, so + * once the content changes the remaining attributes fall back to + * offset-free replacement rather than clamp at a stale position. + * Attributes that leave the markup untouched keep the offsets + * valid: the computed `metadata` attribute produced by a pattern + * overrides `__default` binding has no HTML source, so it must + * not invalidate the offsets for the rich text that follows it. + */ + if ( $updated_block_content !== $block_content ) { + $inner_block_offsets = array(); + } + + $block_content = $updated_block_content; } } diff --git a/tests/phpunit/tests/block-bindings/render.php b/tests/phpunit/tests/block-bindings/render.php index 77b0975105dc5..3ce1993e4c351 100644 --- a/tests/phpunit/tests/block-bindings/render.php +++ b/tests/phpunit/tests/block-bindings/render.php @@ -12,9 +12,7 @@ class WP_Block_Bindings_Render extends WP_UnitTestCase { const SOURCE_NAME = 'test/source'; - const SOURCE_LABEL = array( - 'label' => 'Test source', - ); + const SOURCE_LABEL = 'Test source'; /** * Sets up shared fixtures. @@ -122,6 +120,16 @@ public function data_update_block_with_value_from_source() { , '

test source value

', ), + 'list item block' => array( + 'content', + << +
  • This should not appear
  • + +HTML + , + '
  • test source value
  • ', + ), ); } @@ -423,4 +431,484 @@ public function test_filter_block_bindings_source_value() { 'The block content should show the filtered value.' ); } + + /** + * Provides fuzz-style nested list fixtures for rich text binding tests. + * + * The fixtures vary whether fallback rich text exists before the first inner + * block, whether that fallback contains raw markup or multibyte text, whether + * nested lists are ordered, and whether siblings surround the bound item. + * + * @return array[] + */ + public function data_rich_text_binding_preserves_nested_inner_blocks() { + $child_list = self::build_list_block( + array( + self::build_list_item_block( 'Nested child' ), + ) + ); + + $deep_child_list = self::build_list_block( + array( + self::build_list_item_block( + 'Nested parent' . self::build_list_block( + array( + self::build_list_item_block( 'Nested grandchild' ), + ) + ) + ), + ) + ); + + $ordered_child_list = self::build_list_block( + array( + self::build_list_item_block( 'Ordered child' ), + self::build_list_item_block( 'Second ordered child' ), + ), + array( + 'ordered' => true, + 'start' => 3, + ) + ); + + return array( + 'nested list after fallback text' => array( + 'block_content' => self::build_list_block( + array( + self::build_list_item_block( 'Default content' . $child_list, true ), + ) + ), + 'bound_value' => 'Bound list item', + 'expected_rendered_block' => << +
  • Bound list item + +
  • + +HTML + , + 'removed_strings' => array( 'Default content' ), + 'preserved_strings' => array( 'Nested child' ), + ), + 'raw markup before nested list' => array( + 'block_content' => self::build_list_block( + array( + self::build_list_item_block( 'Default content' . $child_list, true ), + ) + ), + 'bound_value' => 'Bound list item', + 'expected_rendered_block' => << +
  • Bound list item + +
  • + +HTML + , + 'removed_strings' => array( 'Default content', 'Raw markup to replace' ), + 'preserved_strings' => array( 'Nested child' ), + ), + 'inner block starts at rich text boundary' => array( + 'block_content' => self::build_list_block( + array( + self::build_list_item_block( $child_list, true ), + ) + ), + 'bound_value' => 'Bound list item', + 'expected_rendered_block' => << +
  • Bound list item + +
  • + +HTML + , + 'removed_strings' => array(), + 'preserved_strings' => array( 'Nested child' ), + ), + 'multibyte fallback before nested list' => array( + 'block_content' => self::build_list_block( + array( + self::build_list_item_block( 'Café fallback before nested list' . $child_list, true ), + ) + ), + 'bound_value' => 'Bound línea', + 'expected_rendered_block' => << +
  • Bound línea + +
  • + +HTML + , + 'removed_strings' => array( 'Café fallback', 'nested' ), + 'preserved_strings' => array( 'Nested child', 'Bound línea' ), + ), + 'deep nested list with sibling item' => array( + 'block_content' => self::build_list_block( + array( + self::build_list_item_block( 'Default parent' . $deep_child_list, true ), + self::build_list_item_block( 'Sibling stays' ), + ) + ), + 'bound_value' => 'Bound parent', + 'expected_rendered_block' => << +
  • Bound parent + +
  • + +
  • Sibling stays
  • + +HTML + , + 'removed_strings' => array( 'Default parent' ), + 'preserved_strings' => array( 'Nested parent', 'Nested grandchild', 'Sibling stays' ), + ), + 'ordered nested list with attributes' => array( + 'block_content' => self::build_list_block( + array( + self::build_list_item_block( 'Default ordered parent' . $ordered_child_list, true ), + ) + ), + 'bound_value' => 'Bound ordered parent', + 'expected_rendered_block' => << +
  • Bound ordered parent +
      +
    1. Ordered child
    2. + +
    3. Second ordered child
    4. +
    +
  • + +HTML + , + 'removed_strings' => array( 'Default ordered parent' ), + 'preserved_strings' => array( 'Ordered child', 'Second ordered child', 'start="3"' ), + ), + ); + } + + /** + * Tests that binding a List Item block's rich text preserves nested List + * inner blocks rendered inside the same `
  • ` element. + * + * @ticket 65406 + * + * @covers WP_Block::render + * + * @dataProvider data_rich_text_binding_preserves_nested_inner_blocks + */ + public function test_rich_text_binding_preserves_nested_inner_blocks( $block_content, $bound_value, $expected_rendered_block, $removed_strings, $preserved_strings ) { + register_block_bindings_source( + self::SOURCE_NAME, + array( + 'label' => self::SOURCE_LABEL, + 'get_value_callback' => static function () use ( $bound_value ) { + return $bound_value; + }, + ) + ); + + $parsed_blocks = parse_blocks( $block_content ); + $block = new WP_Block( $parsed_blocks[0] ); + $result = $block->render(); + + foreach ( $removed_strings as $removed_string ) { + $this->assertStringNotContainsString( + $removed_string, + $result, + "Fallback content '{$removed_string}' should be replaced by the source value." + ); + } + + foreach ( $preserved_strings as $preserved_string ) { + $this->assertStringContainsString( + $preserved_string, + $result, + "Nested inner block content '{$preserved_string}' should be preserved." + ); + } + + $this->assertEqualHTML( + $expected_rendered_block, + trim( $result ), + '', + 'The bound list item rich text should be replaced without dropping nested inner blocks.' + ); + $this->assertSame( + $bound_value, + $block->inner_blocks[0]->attributes['content'], + 'The bound list item content attribute should be updated with the source value.' + ); + } + + /** + * Tests that inner-block preservation is block-agnostic. + * + * The replacement logic has no block-specific handling: it relies only on + * where inner blocks render. This registers an arbitrary block whose bound + * rich text and an inner block share the same element, and confirms the inner + * block is preserved exactly as it is for `core/list-item`. + * + * @ticket 65406 + * + * @covers WP_Block::render + */ + public function test_rich_text_binding_preserves_inner_blocks_for_any_block() { + register_block_type( + 'test/rich-text-with-inner-blocks', + array( + 'attributes' => array( + 'content' => array( + 'type' => 'rich-text', + 'source' => 'rich-text', + 'selector' => 'div', + ), + ), + ) + ); + + $supported_attributes_filter = static function ( $supported_attributes, $block_type ) { + if ( 'test/rich-text-with-inner-blocks' === $block_type ) { + $supported_attributes[] = 'content'; + } + return $supported_attributes; + }; + + add_filter( + 'block_bindings_supported_attributes', + $supported_attributes_filter, + 10, + 2 + ); + + register_block_bindings_source( + self::SOURCE_NAME, + array( + 'label' => self::SOURCE_LABEL, + 'get_value_callback' => static function () { + return 'Bound value'; + }, + ) + ); + + $block_content = << +
    +

    Inner paragraph stays

    +
    + +HTML; + + $parsed_blocks = parse_blocks( $block_content ); + $block = new WP_Block( $parsed_blocks[0] ); + $result = $block->render(); + + remove_filter( 'block_bindings_supported_attributes', $supported_attributes_filter, 10 ); + unregister_block_type( 'test/rich-text-with-inner-blocks' ); + + $expected_rendered_block = <<Bound value +

    Inner paragraph stays

    + +HTML; + + $this->assertEqualHTML( + $expected_rendered_block, + trim( $result ), + '', + 'The inner block should be preserved for any block, not just core/list-item.' + ); + } + + /** + * Tests that a pattern overrides `__default` binding preserves nested List + * inner blocks. + * + * Pattern overrides expand the `__default` binding into computed attributes + * that include the rewritten `metadata` attribute alongside `content`. The + * `metadata` attribute has no HTML source, so its no-op replacement must not + * invalidate the inner-block offsets used to preserve the nested list when + * `content` is replaced afterwards. + * + * @ticket 65406 + * + * @covers WP_Block::render + */ + public function test_pattern_overrides_binding_preserves_nested_inner_blocks() { + $block_content = << + + +HTML; + + $parsed_blocks = parse_blocks( $block_content ); + $block = new WP_Block( + $parsed_blocks[0], + array( + 'pattern/overrides' => array( + 'Editable List Item' => array( 'content' => 'Pattern override' ), + ), + ) + ); + $result = $block->render(); + + $expected_rendered_block = << +
  • Pattern override + +
  • + +HTML; + + $this->assertEqualHTML( + $expected_rendered_block, + trim( $result ), + '', + 'The pattern override should replace the list item rich text without dropping the nested list.' + ); + $this->assertSame( + 'Pattern override', + $block->inner_blocks[0]->attributes['content'], + 'The list item content attribute should be updated with the pattern override value.' + ); + } + + /** + * Tests that binding degrades safely when rich text does not precede the + * inner block. + * + * The replacement assumes a block's own rich text comes before its inner + * blocks, which holds for a normally authored List Item. When markup is + * authored with the nested list first, the replacement stops at that inner + * block: the bound value is written ahead of it and the trailing rich text + * is left in place. The result is an incomplete replacement, never broken + * structure, and the nested inner block is still preserved. + * + * @ticket 65406 + * + * @covers WP_Block::render + */ + public function test_rich_text_binding_with_inner_block_before_text() { + $block_content = << + + +HTML; + + register_block_bindings_source( + self::SOURCE_NAME, + array( + 'label' => self::SOURCE_LABEL, + 'get_value_callback' => static function () { + return 'Bound value'; + }, + ) + ); + + $parsed_blocks = parse_blocks( $block_content ); + $block = new WP_Block( $parsed_blocks[0] ); + $result = $block->render(); + + $expected_rendered_block = << +
  • Bound value + +trailing text
  • + +HTML; + + $this->assertEqualHTML( + $expected_rendered_block, + trim( $result ), + '', + 'The bound value should be written before the inner block while preserving the nested list and the trailing rich text.' + ); + $this->assertStringContainsString( + 'Nested child', + $result, + 'The nested list inner block should be preserved.' + ); + $this->assertStringContainsString( + 'trailing text', + $result, + 'Rich text after the inner block should be left untouched.' + ); + } + + /** + * Builds List block markup. + * + * @param string[] $items Serialized List Item blocks. + * @param array $attributes Optional List block attributes. + * @return string Serialized List block markup. + */ + private static function build_list_block( $items, $attributes = array() ) { + $is_ordered = ! empty( $attributes['ordered'] ); + $tag_name = $is_ordered ? 'ol' : 'ul'; + $block_attributes = $attributes ? ' ' . wp_json_encode( $attributes ) : ''; + $html_attributes = ' class="wp-block-list"'; + + if ( isset( $attributes['start'] ) ) { + $html_attributes .= ' start="' . (int) $attributes['start'] . '"'; + } + + return sprintf( + "\n<%s%s>%s\n", + $block_attributes, + $tag_name, + $html_attributes, + implode( '', $items ), + $tag_name + ); + } + + /** + * Builds List Item block markup. + * + * @param string $content List item inner HTML. + * @param bool $is_bound Optional. Whether to bind the content attribute. + * @return string Serialized List Item block markup. + */ + private static function build_list_item_block( $content, $is_bound = false ) { + $block_attributes = $is_bound ? ' {"metadata":{"bindings":{"content":{"source":"test/source"}}}}' : ''; + + return sprintf( + "\n
  • %s
  • \n", + $block_attributes, + $content + ); + } } diff --git a/tests/phpunit/tests/block-bindings/wp-block-get-block-bindings-processor.php b/tests/phpunit/tests/block-bindings/wp-block-get-block-bindings-processor.php index afdad9bd28512..f7f65b56738a9 100644 --- a/tests/phpunit/tests/block-bindings/wp-block-get-block-bindings-processor.php +++ b/tests/phpunit/tests/block-bindings/wp-block-get-block-bindings-processor.php @@ -40,6 +40,33 @@ public function test_replace_rich_text() { ); } + /** + * @ticket 65406 + */ + public function test_replace_rich_text_stops_at_inner_block_offset() { + $item_opener = '
  • '; + $rich_text = 'This should not appear'; + $nested_list = ''; + $item_closer = '
  • '; + + $processor = self::$get_block_bindings_processor_method->invoke( + null, + $item_opener . $rich_text . $nested_list . $item_closer + ); + $processor->next_tag( array( 'tag_name' => 'li' ) ); + + $this->assertTrue( + $processor->replace_rich_text( + 'New list item content', + array( strlen( $item_opener . $rich_text ) ) + ) + ); + $this->assertEquals( + $item_opener . 'New list item content' . $nested_list . $item_closer, + $processor->get_updated_html() + ); + } + /** * @ticket 63840 */