diff --git a/src/wp-includes/block-bindings.php b/src/wp-includes/block-bindings.php index d0def1c86ea9e..a996475989d5c 100644 --- a/src/wp-includes/block-bindings.php +++ b/src/wp-includes/block-bindings.php @@ -19,7 +19,9 @@ * * @since 6.5.0 * - * @param string $source_name The name of the source. + * @param string $source_name The name of the source. It must be a string containing a namespace prefix, i.e. + * `my-plugin/my-custom-source`. It must only contain lowercase alphanumeric + * characters, the forward slash `/` and dashes. * @param array $source_properties { * The array of arguments that are used to register a source. * diff --git a/src/wp-includes/block-bindings/sources/pattern.php b/src/wp-includes/block-bindings/sources/pattern.php new file mode 100644 index 0000000000000..863f73da871e0 --- /dev/null +++ b/src/wp-includes/block-bindings/sources/pattern.php @@ -0,0 +1,34 @@ +attributes, array( 'metadata', 'id' ), false ) ) { + return null; + } + $block_id = $block_instance->attributes['metadata']['id']; + return _wp_array_get( $block_instance->context, array( 'pattern/overrides', $block_id, $attribute_name ), null ); +} + + +/** + * Registers the "pattern" source for the Block Bindings API. + * + * @access private + * @since 6.5.0 + */ +function _register_block_bindings_pattern_overrides_source() { + register_block_bindings_source( + 'core/pattern-overrides', + array( + 'label' => _x( 'Pattern Overrides', 'block bindings source' ), + 'get_value_callback' => 'pattern_source_callback', + ) + ); +} + +add_action( 'init', '_register_block_bindings_pattern_overrides_source' ); diff --git a/src/wp-includes/block-bindings/sources/post-meta.php b/src/wp-includes/block-bindings/sources/post-meta.php new file mode 100644 index 0000000000000..0aa55ba1800bc --- /dev/null +++ b/src/wp-includes/block-bindings/sources/post-meta.php @@ -0,0 +1,47 @@ +context['postId'] is not available in the Image block. + $post_id = get_the_ID(); + } + + // If a post isn't public, we need to prevent + // unauthorized users from accessing the post meta. + $post = get_post( $post_id ); + if ( ( ! is_post_publicly_viewable( $post ) && ! current_user_can( 'read_post', $post_id ) ) || post_password_required( $post ) ) { + return null; + } + + return get_post_meta( $post_id, $source_attrs['key'], true ); +} + +/** + * Registers the "post_meta" source for the Block Bindings API. + * + * @access private + * @since 6.5.0 + */ +function _register_block_bindings_post_meta_source() { + register_block_bindings_source( + 'core/post-meta', + array( + 'label' => _x( 'Post Meta', 'block bindings source' ), + 'get_value_callback' => 'post_meta_source_callback', + ) + ); +} + +add_action( 'init', '_register_block_bindings_post_meta_source' ); diff --git a/src/wp-includes/class-wp-block-bindings-registry.php b/src/wp-includes/class-wp-block-bindings-registry.php index 8b363875529d0..81969c2d5671d 100644 --- a/src/wp-includes/class-wp-block-bindings-registry.php +++ b/src/wp-includes/class-wp-block-bindings-registry.php @@ -42,7 +42,9 @@ final class WP_Block_Bindings_Registry { * * @since 6.5.0 * - * @param string $source_name The name of the source. + * @param string $source_name The name of the source. It must be a string containing a namespace prefix, i.e. + * `my-plugin/my-custom-source`. It must only contain lowercase alphanumeric + * characters, the forward slash `/` and dashes. * @param array $source_properties { * The array of arguments that are used to register a source. * diff --git a/src/wp-includes/class-wp-block.php b/src/wp-includes/class-wp-block.php index f7bf912f42bf9..f639594b1129a 100644 --- a/src/wp-includes/class-wp-block.php +++ b/src/wp-includes/class-wp-block.php @@ -191,6 +191,204 @@ public function __get( $name ) { return null; } + /** + * Processes the block bindings in block's attributes. + * + * A block might contain bindings in its attributes. Bindings are mappings + * between an attribute of the block and a source. A "source" is a function + * registered with `register_block_bindings_source()` that defines how to + * retrieve a value from outside the block, e.g. from post meta. + * + * This function will process those bindings and replace the HTML with the value of the binding. + * The value is retrieved from the source of the binding. + * + * ### Example + * + * The "bindings" property for an Image block might look like this: + * + * ```json + * { + * "metadata": { + * "bindings": { + * "title": { + * "source": "post_meta", + * "args": { "key": "text_custom_field" } + * }, + * "url": { + * "source": "post_meta", + * "args": { "key": "url_custom_field" } + * } + * } + * } + * } + * ``` + * + * The above example will replace the `title` and `url` attributes of the Image + * block with the values of the `text_custom_field` and `url_custom_field` post meta. + * + * @access private + * @since 6.5.0 + * + * @param string $block_content Block content. + * @param array $block The full block, including name and attributes. + */ + private function process_block_bindings( $block_content ) { + $block = $this->parsed_block; + + // Allowed blocks that support block bindings. + // TODO: Look for a mechanism to opt-in for this. Maybe adding a property to block attributes? + $allowed_blocks = array( + 'core/paragraph' => array( 'content' ), + 'core/heading' => array( 'content' ), + 'core/image' => array( 'url', 'title', 'alt' ), + 'core/button' => array( 'url', 'text' ), + ); + + // If the block doesn't have the bindings property, isn't one of the allowed + // block types, or the bindings property is not an array, return the block content. + if ( ! isset( $block['attrs']['metadata']['bindings'] ) || + ! is_array( $block['attrs']['metadata']['bindings'] ) || + ! isset( $allowed_blocks[ $this->name ] ) + ) { + return $block_content; + } + + $block_bindings_sources = get_all_registered_block_bindings_sources(); + $modified_block_content = $block_content; + foreach ( $block['attrs']['metadata']['bindings'] as $binding_attribute => $binding_source ) { + // If the attribute is not in the list, process next attribute. + if ( ! in_array( $binding_attribute, $allowed_blocks[ $this->name ], true ) ) { + continue; + } + // If no source is provided, or that source is not registered, process next attribute. + if ( ! isset( $binding_source['source'] ) || ! is_string( $binding_source['source'] ) || ! isset( $block_bindings_sources[ $binding_source['source'] ] ) ) { + continue; + } + + $source_callback = $block_bindings_sources[ $binding_source['source'] ]['get_value_callback']; + // Get the value based on the source. + if ( ! isset( $binding_source['args'] ) ) { + $source_args = array(); + } else { + $source_args = $binding_source['args']; + } + $source_value = call_user_func_array( $source_callback, array( $source_args, $this, $binding_attribute ) ); + // If the value is null, process next attribute. + if ( is_null( $source_value ) ) { + continue; + } + + // Process the HTML based on the block and the attribute. + $modified_block_content = $this->replace_html( $modified_block_content, $this->name, $binding_attribute, $source_value ); + } + return $modified_block_content; + } + + /** + * Depending on the block attributes, replace the HTML based on the value returned by the source. + * + * @since 6.5.0 + * + * @param string $block_content Block content. + * @param string $block_name The name of the block to process. + * @param string $block_attr The attribute of the block we want to process. + * @param string $source_value The value used to replace the HTML. + */ + private function replace_html( string $block_content, string $block_name, string $block_attr, string $source_value ) { + $block_type = $this->block_type; + if ( null === $block_type || ! isset( $block_type->attributes[ $block_attr ] ) ) { + return $block_content; + } + + // Depending on the attribute source, the processing will be different. + switch ( $block_type->attributes[ $block_attr ]['source'] ) { + case 'html': + case 'rich-text': + $block_reader = new WP_HTML_Tag_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. + $selectors = explode( ',', $block_type->attributes[ $block_attr ]['selector'] ); + // Add a bookmark to the first tag to be able to iterate over the selectors. + $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' === $block_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 ), $selector ) === 0 || $block_reader->next_tag( + array( + 'tag_name' => $selector, + ) + ) ) { + $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 ) . ""; + $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' === $block_name || 'core/heading' === $block_name ) { + return $amended_content->get_updated_html(); + } + if ( 'core/button' === $block_name ) { + $button_markup = "<$button_wrapper>{$amended_content->get_updated_html()}"; + $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(); + } + } else { + $block_reader->seek( 'iterate-selectors' ); + } + } + $block_reader->release_bookmark( 'iterate-selectors' ); + return $block_content; + + case 'attribute': + $amended_content = new WP_HTML_Tag_Processor( $block_content ); + if ( ! $amended_content->next_tag( + array( + // TODO: build the query from CSS selector. + 'tag_name' => $block_type->attributes[ $block_attr ]['selector'], + ) + ) ) { + return $block_content; + } + $amended_content->set_attribute( $block_type->attributes[ $block_attr ]['attribute'], esc_attr( $source_value ) ); + return $amended_content->get_updated_html(); + break; + + default: + return $block_content; + break; + } + return; + } + + /** * Generates the render output for the block. * @@ -286,6 +484,10 @@ public function render( $options = array() ) { } } + // Process the block bindings for this block, if any are registered. This + // will replace the block content with the value from a registered binding source. + $block_content = $this->process_block_bindings( $block_content ); + /** * Filters the content of a single block. * diff --git a/src/wp-settings.php b/src/wp-settings.php index 624b1d804acf0..1e177661bf294 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -376,6 +376,8 @@ require ABSPATH . WPINC . '/fonts.php'; require ABSPATH . WPINC . '/class-wp-script-modules.php'; require ABSPATH . WPINC . '/script-modules.php'; +require ABSPATH . WPINC . '/block-bindings/sources/post-meta.php'; +require ABSPATH . WPINC . '/block-bindings/sources/pattern.php'; require ABSPATH . WPINC . '/interactivity-api.php'; $GLOBALS['wp_embed'] = new WP_Embed(); diff --git a/tests/phpunit/includes/functions.php b/tests/phpunit/includes/functions.php index 81d4339db1bf8..0fdff9c71ae46 100644 --- a/tests/phpunit/includes/functions.php +++ b/tests/phpunit/includes/functions.php @@ -339,10 +339,15 @@ function _wp_rest_server_class_filter() { * @since 5.0.0 */ function _unhook_block_registration() { + // Block types. require __DIR__ . '/unregister-blocks-hooks.php'; remove_action( 'init', 'register_core_block_types_from_metadata' ); remove_action( 'init', 'register_block_core_legacy_widget' ); remove_action( 'init', 'register_block_core_widget_group' ); remove_action( 'init', 'register_core_block_types_from_metadata' ); + + // Block binding sources. + remove_action( 'init', '_register_block_bindings_pattern_overrides_source' ); + remove_action( 'init', '_register_block_bindings_post_meta_source' ); } tests_add_filter( 'init', '_unhook_block_registration', 1000 ); diff --git a/tests/phpunit/tests/block-bindings/block-bindings.php b/tests/phpunit/tests/block-bindings/block-bindings.php new file mode 100644 index 0000000000000..d8e63fa6bcf8e --- /dev/null +++ b/tests/phpunit/tests/block-bindings/block-bindings.php @@ -0,0 +1,106 @@ + 'Test source', + ); + + /** + * Set up before each test. + * + * @since 6.5.0 + */ + public function set_up() { + foreach ( get_all_registered_block_bindings_sources() as $source_name => $source_properties ) { + if ( str_starts_with( $source_name, 'test/' ) ) { + unregister_block_bindings_source( $source_name ); + } + } + + parent::set_up(); + } + + /** + * Test if the block content is updated with the value returned by the source. + * + * @ticket 60282 + * + * @covers register_block_bindings_source + */ + public function test_update_block_with_value_from_source() { + $get_value_callback = function () { + return 'test source value'; + }; + + register_block_bindings_source( + self::SOURCE_NAME, + array( + 'label' => self::SOURCE_LABEL, + 'get_value_callback' => $get_value_callback, + ) + ); + + $block_content = <<

This should not appear

+HTML; + + $parsed_blocks = parse_blocks( $block_content ); + $block = new WP_Block( $parsed_blocks[0] ); + + $expected = '

test source value

'; + $result = $block->render(); + + // Check if the block content was updated correctly. + $this->assertEquals( $expected, $result, 'The block content should be updated with the value returned by the source.' ); + } + + /** + * Test passing arguments to the source. + * + * @ticket 60282 + * + * @covers register_block_bindings_source + */ + public function test_passing_arguments_to_source() { + $get_value_callback = function ( $source_args, $block_instance, $attribute_name ) { + $value = $source_args['key']; + return "The attribute name is '$attribute_name' and its binding has argument 'key' with value '$value'."; + }; + + register_block_bindings_source( + self::SOURCE_NAME, + array( + 'label' => self::SOURCE_LABEL, + 'get_value_callback' => $get_value_callback, + ) + ); + + $key = 'test'; + + $block_content = <<

This should not appear

+HTML; + + $parsed_blocks = parse_blocks( $block_content ); + $block = new WP_Block( $parsed_blocks[0] ); + + $expected = "

The attribute name is 'content' and its binding has argument 'key' with value 'test'.

"; + $result = $block->render(); + + // Check if the block content was updated correctly. + $this->assertEquals( $expected, $result, 'The block content should be updated with the value returned by the source.' ); + } +} diff --git a/tests/phpunit/tests/block-bindings/register.php b/tests/phpunit/tests/block-bindings/register.php index 04fdba82ce968..a87ae1dc56f3a 100644 --- a/tests/phpunit/tests/block-bindings/register.php +++ b/tests/phpunit/tests/block-bindings/register.php @@ -16,6 +16,19 @@ class Tests_Block_Bindings_Register extends WP_UnitTestCase { 'label' => 'Test source', ); + /** + * Set up before each test. + * + * @since 6.5.0 + */ + public function set_up() { + foreach ( get_all_registered_block_bindings_sources() as $source_name => $source_properties ) { + unregister_block_bindings_source( $source_name ); + } + + parent::set_up(); + } + /** * Tear down after each test. * @@ -23,9 +36,7 @@ class Tests_Block_Bindings_Register extends WP_UnitTestCase { */ public function tear_down() { foreach ( get_all_registered_block_bindings_sources() as $source_name => $source_properties ) { - if ( str_starts_with( $source_name, 'test/' ) ) { - unregister_block_bindings_source( $source_name ); - } + unregister_block_bindings_source( $source_name ); } parent::tear_down();