Skip to content

Block Bindings: Allow more generic setting of block attributes #9469

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 17 commits into
base: trunk
Choose a base branch
from
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
70 changes: 70 additions & 0 deletions src/wp-includes/class-wp-block-bindings-processor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php

/**
* WP_Block_Bindings_Processor class.
Copy link
Member

Choose a reason for hiding this comment

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

This has @access private, but it may be worth noting early and clearly in this description that the class is only for use by WordPress core and usage by extenders is not supported and likely to break.

Is there any way to avoid adding the file and class? The HTML API should publicly provide the necessary functionality someday.

Copy link
Member

Choose a reason for hiding this comment

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

This has @access private, but it may be worth noting early and clearly in this description that the class is only for use by WordPress core and usage by extenders is not supported and likely to break.

I'm not familiar with such a narrative in WordPress core. Everything is rather done in the spirit that it can be used by 3rd party devs, so public API needs to be continued indefinitely. At least it should not fatally affect the existing code if major changes are necessary.

It's perfectly fine to use a specialized class for Block Bindings even if it one day becomes only a facade. It's similar to how Interactivity API directives processing is structured:

final class WP_Interactivity_API_Directives_Processor extends WP_HTML_Tag_Processor {

Copy link
Contributor

Choose a reason for hiding this comment

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

The only caveat of having a final class is that we won't be able to extend it from Gutenberg in future developments. Meaning we will have to always combine a WordPress 'develop' version with Gutenberg in case we need to work both with Core functions and editor features.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This has @access private, but it may be worth noting early and clearly in this description that the class is only for use by WordPress core and usage by extenders is not supported and likely to break.

Yeah, happy to warn more verbosely against that 👍

Is there any way to avoid adding the file and class? The HTML API should publicly provide the necessary functionality someday.

It's a problem of balancing the desire for the "right" implementation (that will take longer) with the need for a "good enough" one (that we can use sooner). Block Bindings currently suffer from being not very scalable, which is to a large degree due to the way that its attribute replacing logic is hard-coded on a per-block basis. We're pretty adamant that we need to replace this with a more generic way in order to see wider adoption.

Copy link
Member

Choose a reason for hiding this comment

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

Is it possible implement this with an anonymous class in the function that processes block bindings?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Is it possible implement this with an anonymous class in the function that processes block bindings?

Probably 🤔 I can give that a try.

*
* This class can be used to perform the sort of structural
* changes to an HTML document that are required by
* Block Bindings. Namely, proper nesting structure of HTML is
* maintained, but HTML updates could still leak out of the
* containing parent node. For example, this allows inserting
* an A element inside an open A element, which would close
* the containing A element.

* Modifications may be requested for a document _once_ after
* matching a token. Due to the way the modifications are
* applied, it's not possible to replace the rich text content
* for a node more than once. Furthermore, if a `replace_rich_text()`
* operation is followed by a `seek()` to a position before the
* updated rich text content, any modification at that earlier
* position will lead to broken output.
*
* @access private
*
* @package WordPress
* @subpackage Block Bindings
* @since 6.9.0
*/
class WP_Block_Bindings_Processor extends WP_HTML_Processor {
private $output = '';
private $end_of_flushed = 0;

public function build() {
return $this->output . substr( $this->get_updated_html(), $this->end_of_flushed );
}

/**
* Replace the rich text content between a tag opener and matching closer.
*
* 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.
* @return bool True on success.
*/
public function replace_rich_text( $rich_text ) {
Copy link
Member

@sirreal sirreal Aug 20, 2025

Choose a reason for hiding this comment

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

I think this should rely on WP_HTML_Text_Replacement and lexical_updates, is there a reason it does not?

The algorithm should be something like:

  • Ensure the processor is stopped on an open tag that is not atomic (like SCRIPT), void (like BR), nor foreign content with a self-closing flag (like G in (<svg><g /></svg>).
  • It may be necessary to flush updates with get_updated_html, I'm not sure off the top of my head.
  • Find the open tag, use a bookmark to get its closing position.
  • Find the matching close tag, use a bookmark to find the byte length of the replacement.
  • Push a lexical update replacement like this:
$this->lexical_updates[] = new WP_HTML_Text_Replacement(
	$start,
	$length,
	$replacement_html
);

I'd like if the function mentioned that the replacement is not checked for proper HTML nesting, it's unsafe by nature. The method provides raw HTML replacement between tags. There's no guarantee that the HTML will nest as expected after the replacement is applied.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think this should rely on WP_HTML_Text_Replacement and lexical_updates, is there a reason it does not?

The algorithm should be something like: [...]

I think that would amount to a basic version of set_inner_html(), no? The idea for WP_Block_Bindings_Processor was that it would give weaker guarantees -- e.g. you can't currently replace_rich_text(), then seek() to an earlier position, and make another change. This simpler and less safe implementation should however be sufficient for the problem domain -- i.e. block bindings -- where we can make a few assumptions on what kind of operations are needed.

The current approach is based on a recommendation and an earlier experiment by @dmsnell, which is similar in spirit (with a string buffer that's totally separate from the one that's kept by WP_HTML_Processor).

That said, I can spin up another PR to try out the lexical_updates-based approach.


I'd like if the function mentioned that the replacement is not checked for proper HTML nesting, it's unsafe by nature. The method provides raw HTML replacement between tags. There's no guarantee that the HTML will nest as expected after the replacement is applied.

Good point. Something like that is mentioned in the PHPDoc of the class, but it's a good idea to also include it in the method's.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That said, I can spin up another PR to try out the lexical_updates-based approach.

ockham#6

Copy link
Member

Choose a reason for hiding this comment

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

I think that would amount to a basic version of set_inner_html(), no?

Yes, but an unsafe version of set_inner_html(). Similar to what's implemented here.

The idea for WP_Block_Bindings_Processor was that it would give weaker guarantees -- e.g. you can't currently replace_rich_text(), then seek() to an earlier position, and make another change. This simpler and less safe implementation should however be sufficient for the problem domain -- i.e. block bindings -- where we can make a few assumptions on what kind of operations are needed.

The current approach is based on a recommendation and an earlier experiment by @dmsnell, which is similar in spirit (with a string buffer that's totally separate from the one that's kept by WP_HTML_Processor).

It's unclear to me why this would be safer. The unsafe operation is intending to put HTML inside a tag that potentially "leaks" to change external structure. Once we do that here, it feels like the unsafe part has happened. Taking the result of this and putting it back into another processor easily circumvents the prevention on seeks and such.

if ( $this->is_tag_closer() ) {
return false;
}

$depth = $this->get_current_depth();

$this->set_bookmark( '_wp_block_bindings_tag_opener' );
// The bookmark names are prefixed with `_` so the key below has an extra `_`.
$bm = $this->bookmarks['__wp_block_bindings_tag_opener'];
$this->output .= substr( $this->get_updated_html(), $this->end_of_flushed, $bm->start + $bm->length );
$this->output .= $rich_text;
$this->release_bookmark( '_wp_block_bindings_tag_opener' );

// Find matching tag closer.
while ( $this->next_token() && $this->get_current_depth() >= $depth ) {
}

$this->set_bookmark( '_wp_block_bindings_tag_closer' );
$bm = $this->bookmarks['__wp_block_bindings_tag_closer'];
$this->end_of_flushed = $bm->start;
$this->release_bookmark( '_wp_block_bindings_tag_closer' );

return true;
}
}
44 changes: 4 additions & 40 deletions src/wp-includes/class-wp-block.php
Original file line number Diff line number Diff line change
Expand Up @@ -417,7 +417,7 @@ private function replace_html( string $block_content, string $attribute_name, $s
switch ( $block_type->attributes[ $attribute_name ]['source'] ) {
case 'html':
case 'rich-text':
$block_reader = new WP_HTML_Tag_Processor( $block_content );
$block_reader = WP_Block_Bindings_Processor::create_fragment( $block_content );

// TODO: Support for CSS selectors whenever they are ready in the HTML API.
// In the meantime, support comma-separated selectors by exploding them into an array.
Expand All @@ -426,53 +426,17 @@ private function replace_html( string $block_content, string $attribute_name, $s
$block_reader->next_tag();
$block_reader->set_bookmark( 'iterate-selectors' );

// TODO: This shouldn't be needed when the `set_inner_html` function is ready.
// Store the parent tag and its attributes to be able to restore them later in the button.
// The button block has a wrapper while the paragraph and heading blocks don't.
if ( 'core/button' === $this->name ) {
$button_wrapper = $block_reader->get_tag();
$button_wrapper_attribute_names = $block_reader->get_attribute_names_with_prefix( '' );
$button_wrapper_attrs = array();
foreach ( $button_wrapper_attribute_names as $name ) {
$button_wrapper_attrs[ $name ] = $block_reader->get_attribute( $name );
}
}

foreach ( $selectors as $selector ) {
// If the parent tag, or any of its children, matches the selector, replace the HTML.
if ( strcasecmp( $block_reader->get_tag(), $selector ) === 0 || $block_reader->next_tag(
array(
'tag_name' => $selector,
)
) ) {
// TODO: Use `WP_HTML_Processor::set_inner_html` method once it's available.
$block_reader->release_bookmark( 'iterate-selectors' );

// TODO: Use `set_inner_html` method whenever it's ready in the HTML API.
// Until then, it is hardcoded for the paragraph, heading, and button blocks.
// Store the tag and its attributes to be able to restore them later.
$selector_attribute_names = $block_reader->get_attribute_names_with_prefix( '' );
$selector_attrs = array();
foreach ( $selector_attribute_names as $name ) {
$selector_attrs[ $name ] = $block_reader->get_attribute( $name );
}
$selector_markup = "<$selector>" . wp_kses_post( $source_value ) . "</$selector>";
$amended_content = new WP_HTML_Tag_Processor( $selector_markup );
$amended_content->next_tag();
foreach ( $selector_attrs as $attribute_key => $attribute_value ) {
$amended_content->set_attribute( $attribute_key, $attribute_value );
}
if ( 'core/paragraph' === $this->name || 'core/heading' === $this->name ) {
return $amended_content->get_updated_html();
}
if ( 'core/button' === $this->name ) {
$button_markup = "<$button_wrapper>{$amended_content->get_updated_html()}</$button_wrapper>";
$amended_button = new WP_HTML_Tag_Processor( $button_markup );
$amended_button->next_tag();
foreach ( $button_wrapper_attrs as $attribute_key => $attribute_value ) {
$amended_button->set_attribute( $attribute_key, $attribute_value );
}
return $amended_button->get_updated_html();
}
$block_reader->replace_rich_text( wp_kses_post( $source_value ) );
return $block_reader->build();
} else {
$block_reader->seek( 'iterate-selectors' );
}
Expand Down
1 change: 1 addition & 0 deletions src/wp-settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,7 @@
require ABSPATH . WPINC . '/sitemaps/providers/class-wp-sitemaps-posts.php';
require ABSPATH . WPINC . '/sitemaps/providers/class-wp-sitemaps-taxonomies.php';
require ABSPATH . WPINC . '/sitemaps/providers/class-wp-sitemaps-users.php';
require ABSPATH . WPINC . '/class-wp-block-bindings-processor.php';
require ABSPATH . WPINC . '/class-wp-block-bindings-source.php';
require ABSPATH . WPINC . '/class-wp-block-bindings-registry.php';
require ABSPATH . WPINC . '/class-wp-block-editor-context.php';
Expand Down
53 changes: 42 additions & 11 deletions tests/phpunit/tests/block-bindings/render.php
Original file line number Diff line number Diff line change
Expand Up @@ -61,14 +61,41 @@ public static function wpTearDownAfterClass() {
unregister_block_type( 'test/block' );
}

public function data_update_block_with_value_from_source() {
return array(
'paragraph block' => array(
'content',
<<<HTML
<!-- wp:paragraph -->
<p>This should not appear</p>
<!-- /wp:paragraph -->
HTML
,
'<p>test source value</p>',
),
'button block' => array(
'text',
<<<HTML
<!-- wp:button -->
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button">This should not appear</a></div>
<!-- /wp:button -->
HTML
,
'<div class="wp-block-button"><a class="wp-block-button__link wp-element-button">test source value</a></div>',
),
);
}

/**
* Test if the block content is updated with the value returned by the source.
*
* @ticket 60282
*
* @covers ::register_block_bindings_source
*
* @dataProvider data_update_block_with_value_from_source
*/
public function test_update_block_with_value_from_source() {
public function test_update_block_with_value_from_source( $bound_attribute, $block_content, $expected_result ) {
$get_value_callback = function () {
return 'test source value';
};
Expand All @@ -81,22 +108,26 @@ public function test_update_block_with_value_from_source() {
)
);

$block_content = <<<HTML
<!-- wp:paragraph {"metadata":{"bindings":{"content":{"source":"test/source"}}}} -->
<p>This should not appear</p>
<!-- /wp:paragraph -->
HTML;
$parsed_blocks = parse_blocks( $block_content );
$block = new WP_Block( $parsed_blocks[0] );
$result = $block->render();

$parsed_blocks[0]['attrs']['metadata'] = array(
'bindings' => array(
$bound_attribute => array(
'source' => self::SOURCE_NAME,
),
),
);

$block = new WP_Block( $parsed_blocks[0] );
$result = $block->render();

$this->assertSame(
'test source value',
$block->attributes['content'],
"The 'content' attribute should be updated with the value returned by the source."
$block->attributes[ $bound_attribute ],
"The '{$bound_attribute}' attribute should be updated with the value returned by the source."
);
$this->assertSame(
'<p>test source value</p>',
$expected_result,
trim( $result ),
'The block content should be updated with the value returned by the source.'
);
Expand Down
91 changes: 91 additions & 0 deletions tests/phpunit/tests/block-bindings/wpBlockBindingsProcessor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<?php
/**
* Tests for WP_Block_Bindings_Processor.
*
* @package WordPress
* @subpackage Blocks
* @since 6.5.0
*
* @group blocks
* @group block-bindings
*/
class Tests_Blocks_wpBlockBindingsProcessor extends WP_UnitTestCase {
/**
* @ticket 63840
*/
public function test_replace_rich_text() {
$button_wrapper_opener = '<div class="wp-block-button"><a class="wp-block-button__link wp-element-button">';
$button_wrapper_closer = '</a></div>';
$processor = WP_Block_Bindings_Processor::create_fragment(
$button_wrapper_opener . 'This should not appear' . $button_wrapper_closer
);
$processor->next_tag( array( 'tag_name' => 'a' ) );

$this->assertTrue( $processor->replace_rich_text( 'The hardest button to button' ) );
$this->assertEquals(
$button_wrapper_opener . 'The hardest button to button' . $button_wrapper_closer,
$processor->build()
);
}

/**
* @ticket 63840
*/
public function test_set_attribute_and_replace_rich_text() {
$figure_opener = '<figure class="wp-block-image">';
$img = '<img src="breakfast.jpg" alt="" class="wp-image-1"/>';
$figure_closer = '</figure>';
$processor = WP_Block_Bindings_Processor::create_fragment(
$figure_opener .
$img .
'<figcaption class="wp-element-caption">Breakfast at a <em>café</em> in Berlin</figcaption>' .
$figure_closer
);

$processor->next_tag( array( 'tag_name' => 'figure' ) );
$processor->add_class( 'size-large' );

$processor->next_tag( array( 'tag_name' => 'figcaption' ) );

$this->assertTrue( $processor->replace_rich_text( '<strong>New</strong> image caption' ) );
$this->assertEquals(
'<figure class="wp-block-image size-large">' .
$img .
'<figcaption class="wp-element-caption"><strong>New</strong> image caption</figcaption>' .
$figure_closer,
$processor->build()
);
}

/**
* @ticket 63840
*/
public function test_replace_rich_text_and_seek() {
$figure_opener = '<figure class="wp-block-image">';
$img = '<img src="breakfast.jpg" alt="" class="wp-image-1"/>';
$figure_closer = '</figure>';
$processor = WP_Block_Bindings_Processor::create_fragment(
$figure_opener .
$img .
'<figcaption class="wp-element-caption">Breakfast at a <em>café</em> in Berlin</figcaption>' .
$figure_closer
);

$processor->next_tag( array( 'tag_name' => 'img' ) );
$processor->set_bookmark( 'image' );

$processor->next_tag( array( 'tag_name' => 'figcaption' ) );

$this->assertTrue( $processor->replace_rich_text( '<strong>New</strong> image caption' ) );

$processor->seek( 'image' );

$this->assertEquals(
$figure_opener .
$img .
'<figcaption class="wp-element-caption"><strong>New</strong> image caption</figcaption>' .
$figure_closer,
$processor->build()
);
}
}
Loading