Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/wp-includes/block-bindings.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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' ),
Expand Down
87 changes: 78 additions & 9 deletions src/wp-includes/class-wp-block.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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' );
Expand Down Expand Up @@ -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;
}
Expand All @@ -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.
Comment on lines +482 to +491

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this always hold? Specifically:

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.

Another way we might do this is to find all the text nodes from the block start to the first inner block and that becomes the replacement range. We could use a Tag Processor on that range to ensure that everything is, in fact, text.

@cbravobernal cbravobernal Jun 11, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For core/list-item, yes. The block renders its innerContent in order: own rich-text first, then the inner-block placeholders. The offsets are recorded in that same render loop, so the first offset is probably where the rich text ends.

For an arbitrary block or manual markup, it's not guaranteed. I'll add a test.

On the Tag Processor idea

On finding the range by walking text nodes: the range isn't always text. It can contain raw block markup that should be replaced, right next to a real inner block that shouldn't, and they look identical once rendered. The offset is the only thing that knows which <ul> came from an actual inner block.

Still, I would need to add a test to be really sure about it.

*/
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,
Expand All @@ -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.
*
Expand Down Expand Up @@ -538,13 +575,29 @@ 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;

foreach ( $this->inner_content as $chunk ) {
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;

Expand Down Expand Up @@ -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;
}
}

Expand Down
Loading
Loading