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
95 changes: 95 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,95 @@
<?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 {
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 ) {
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;
}

public function remove_node() {
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 );
$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 + $bm->length;
$this->release_bookmark( '_wp_block_bindings_tag_closer' );

return true;
}
}
55 changes: 15 additions & 40 deletions src/wp-includes/class-wp-block.php
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ class WP_Block {
private const BLOCK_BINDINGS_SUPPORTED_ATTRIBUTES = array(
'core/paragraph' => array( 'content' ),
'core/heading' => array( 'content' ),
'core/image' => array( 'id', 'url', 'title', 'alt' ),
'core/image' => array( 'id', 'url', 'title', 'alt', 'caption' ),
'core/button' => array( 'url', 'text', 'linkTarget', 'rel' ),
'core/post-date' => array( 'datetime' ),
);
Expand Down 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,28 @@ 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();
/*
* If the value returned from the Block Bindings source is empty
* for a block attribute that whose selector is `rich-text` or `html`,
* we remove the HTML node denoted by its selector. For example, this
* means removing an Image block's `<figcaption>` node if there's no
* caption supplied.
*/
if ( empty( $source_value ) ) {
$block_reader->remove_node();
} else {
$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
107 changes: 96 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,51 @@ 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>',
),
'image block' => array(
'caption',
<<<HTML
<!-- wp:image {"id":66,"sizeSlug":"large","linkDestination":"none"} -->
<figure class="wp-block-image size-large"><img src="breakfast.jpg" alt="" class="wp-image-1"/><figcaption class="wp-element-caption">Breakfast at a <em>café</em> in Wrocław.</figcaption></figure>
<!-- /wp:image -->
HTML
,
'<figure class="wp-block-image size-large"><img src="breakfast.jpg" alt="" class="wp-image-1"/><figcaption class="wp-element-caption">test source value</figcaption></figure>'
)
);
}

/**
* 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,27 +118,75 @@ 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.'
);
}

public function test_remove_containing_tag_if_rich_text_attribute_source_value_is_empty() {
$get_value_callback = function () {
return '';
};

register_block_bindings_source(
self::SOURCE_NAME,
array(
'label' => self::SOURCE_LABEL,
'get_value_callback' => $get_value_callback,
)
);

$block_content = <<<HTML
<!-- wp:image {"id":66,"sizeSlug":"large","linkDestination":"none"} -->
<figure class="wp-block-image size-large"><img src="breakfast.jpg" alt="" class="wp-image-1"/><figcaption class="wp-element-caption">Breakfast at a <em>café</em> in Wrocław.</figcaption></figure>
<!-- /wp:image -->
HTML;
$parsed_blocks = parse_blocks( $block_content );

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

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

$this->assertSame(
'',
$block->attributes[ 'caption' ],
"The 'caption' attribute should be updated with the empty string value returned by the source."
);
$this->assertSame(
'<figure class="wp-block-image size-large"><img src="breakfast.jpg" alt="" class="wp-image-1"/></figure>',
trim( $result ),
'The <figcaption> and its rich text content should be removed.'
);
}


/**
* Test if the block_bindings_supported_attributes_{$block_type} filter is applied correctly.
*
Expand Down
Loading