Skip to content
Closed
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
73 changes: 73 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,73 @@
<?php

/**
* WP_Block_Bindings_Processor class.
*
* 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 {

public function build() {
return $this->get_updated_html();
}

/**
* 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() ) {
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'];
$start = $bm->start + $bm->length;
$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'];
$end = $bm->start;
$this->release_bookmark( '_wp_block_bindings_tag_closer' );

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

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
92 changes: 92 additions & 0 deletions tests/phpunit/tests/block-bindings/wpBlockBindingsProcessor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<?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' );
$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->build()
);
}
}