Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
7f1f6a0
Introduce data provider to allow extending test coverage
ockham Aug 13, 2025
054756d
Add test coverage for Button block's text attribute
ockham Aug 13, 2025
b328dee
Block Bindings: Simplify replace_html() method
ockham Aug 13, 2025
21e80e2
Add WP_Block_Bindings_Processor class
ockham Aug 18, 2025
1c70bc3
Use WP_Block_Bindings_Processor for block bindings
ockham Aug 18, 2025
8f76759
Add kses back :/
ockham Aug 18, 2025
b7b7ca5
WPCS
ockham Aug 18, 2025
bb7c906
Remove obsolete var
ockham Aug 18, 2025
103a5c4
Indentation
ockham Aug 18, 2025
3f2e32b
Return true upon success
ockham Aug 18, 2025
85b6354
Add basic PHPDoc
ockham Aug 18, 2025
b67890e
Add more PHPDoc
ockham Aug 19, 2025
6bc4fd5
Add basic test coverage
ockham Aug 19, 2025
d943cd8
Allow setting attributes
ockham Aug 19, 2025
527c5d8
Increase test coverage
ockham Aug 19, 2025
86e836d
Increase test coverage
ockham Aug 20, 2025
dad7380
Add more commentary and warnings
ockham Aug 20, 2025
88af5ff
Do not explose block bindings processor class
sirreal Aug 21, 2025
a56f978
Remove block bindings processor class
sirreal Aug 21, 2025
7c3fc45
wpcs
sirreal Aug 21, 2025
66fef38
Make the hidden class instance static
sirreal Aug 21, 2025
24a0b0d
Use Reflection to make tests work again
ockham Aug 25, 2025
2e7df73
Block Bindings Processor: Tweak test to break with current implementa…
ockham Aug 25, 2025
e2f0a38
Block Bindings Processor: Base implementation on WP_HTML_Text_Replace…
ockham Aug 25, 2025
b045050
Remove now-obsolete build() alias method
ockham Aug 25, 2025
e739c6c
Rename test file
ockham Aug 25, 2025
6a4a3a3
Correct @since PHPDoc
ockham Aug 25, 2025
4dbb0e5
More descriptive variable names
ockham Aug 26, 2025
a18e435
Remove static var
ockham Aug 26, 2025
3410f40
Make sure we're not stopped on atomic/void/self-closing element
ockham Aug 26, 2025
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
89 changes: 49 additions & 40 deletions src/wp-includes/class-wp-block.php
Original file line number Diff line number Diff line change
Expand Up @@ -416,7 +416,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 = self::get_block_bindings_processor( $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 @@ -425,53 +425,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->get_updated_html();
} else {
$block_reader->seek( 'iterate-selectors' );
}
Expand All @@ -497,6 +461,51 @@ private function replace_html( string $block_content, string $attribute_name, $s
}
}

private static function get_block_bindings_processor( string $block_content ) {
$internal_processor_class = new class('', WP_HTML_Processor::CONSTRUCTOR_UNLOCK_CODE) extends WP_HTML_Processor {
/**
* 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 ) {
if ( $this->is_tag_closer() || ! $this->expects_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 `_`.
$tag_opener = $this->bookmarks['__wp_block_bindings_tag_opener'];
$start = $tag_opener->start + $tag_opener->length;
$this->release_bookmark( '_wp_block_bindings_tag_opener' );
Copy link
Member

@dmsnell dmsnell Sep 3, 2025

Choose a reason for hiding this comment

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

this is certainly welcome to do, to release the bookmarks, but performance-wise it’s not setting a better example than leaving them dangling.

given that this function contains all of the scanning, it’s even fine to trap the bookmark bounds and re-use one, such as set_bookmark( 'here' ). I’ve considered exposing something like ->current_token() but haven’t yet.

part of this is the fact that we expect isolation of bookmarks here and we will dismiss the processor when we’re done.


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

Choose a reason for hiding this comment

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

reasonable follow-up idea: verify that the processor completed and didn’t pause at an incomplete token or reach unsupported markup.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Finally addressing these notes over at #10029.

This look about good? 8d7590e
(I was at first thinking to check if state was STATE_MATCHED_TAG, but then I thought, the higher-level check should give me that anyway.)

Copy link
Member

Choose a reason for hiding this comment

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

looks good; I left a comment on the new PR.


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

$this->lexical_updates[] = new WP_HTML_Text_Replacement(
$start,
$end - $start,
$rich_text
);
Copy link
Member

Choose a reason for hiding this comment

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

applying lexical updates that span token boundaries is not something we have particularly tested well. it’s probably working well enough and hopefully the tests here will catch them, but this is sort of uncharted territory within the HTML Processor. we should make sure that we don’t seek anywhere here to verify that we don’t cross indices.

this single issue is one of the main reasons I proposed the single linear pass of the serialization builder, because it structurally prevents the ability of jumping around or referring to indices after changing positions.

I’m not particularly confident I would know how this interacts with virtual tokens, for example, whose boundaries are coincident with bookmarks (vs. realized tokens which all have non-overlapping spans)


return true;
}
};

return $internal_processor_class::create_fragment( $block_content );
}

/**
* Generates the render output for the block.
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<?php
/**
* Tests for WP_Block::get_block_bindings_processor.
*
* @package WordPress
* @subpackage Blocks
* @since 6.9.0
*
* @group blocks
* @group block-bindings
*/
class Tests_Blocks_GetBlockBindingsProcessor extends WP_UnitTestCase {

private static $get_block_bindings_processor_method;

public static function wpSetupBeforeClass() {
self::$get_block_bindings_processor_method = new ReflectionMethod( 'WP_Block', 'get_block_bindings_processor' );
self::$get_block_bindings_processor_method->setAccessible( true );
}

/**
* @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 = self::$get_block_bindings_processor_method->invoke(
null,
$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->get_updated_html()
);
}

/**
* @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 = self::$get_block_bindings_processor_method->invoke(
null,
$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->get_updated_html()
);
}

/**
* @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 = self::$get_block_bindings_processor_method->invoke(
null,
$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' );
$processor->add_class( 'extra-img-class' );

$this->assertEquals(
$figure_opener .
'<img src="breakfast.jpg" alt="" class="wp-image-1 extra-img-class"/>' .
'<figcaption class="wp-element-caption"><strong>New</strong> image caption</figcaption>' .
$figure_closer,
$processor->get_updated_html()
);
}
}
Loading