From a2786a3785043affa80ae40a4320329d6a3b3561 Mon Sep 17 00:00:00 2001 From: youknowriad Date: Thu, 1 Feb 2024 12:54:15 +0000 Subject: [PATCH] Editor: Add the Block Bindings API. This introduces the Block Bindings API for WordPress. The API allows developers to connects block attributes to different sources. In this PR, two such sources are included: "post meta" and "pattern". Attributes connected to sources can have their HTML replaced by values coming from the source in a way defined by the binding. Props czapla, lgladdy, gziolo, sc0ttkclark, swissspidy, artemiosans, kevin940726, fabiankaegy, santosguillamot, talldanwp, wildworks. Fixes #60282. Built from https://develop.svn.wordpress.org/trunk@57514 git-svn-id: http://core.svn.wordpress.org/trunk@57015 1a063a9b-81f0-0310-95a4-ce76da25c4cd --- wp-includes/block-bindings.php | 4 +- .../block-bindings/sources/pattern.php | 34 +++ .../block-bindings/sources/post-meta.php | 47 ++++ .../class-wp-block-bindings-registry.php | 4 +- wp-includes/class-wp-block.php | 202 ++++++++++++++++++ wp-includes/version.php | 2 +- wp-settings.php | 2 + 7 files changed, 292 insertions(+), 3 deletions(-) create mode 100644 wp-includes/block-bindings/sources/pattern.php create mode 100644 wp-includes/block-bindings/sources/post-meta.php diff --git a/wp-includes/block-bindings.php b/wp-includes/block-bindings.php index e49fa4afb2..108efc3d84 100644 --- a/wp-includes/block-bindings.php +++ b/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/wp-includes/block-bindings/sources/pattern.php b/wp-includes/block-bindings/sources/pattern.php new file mode 100644 index 0000000000..863f73da87 --- /dev/null +++ b/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/wp-includes/block-bindings/sources/post-meta.php b/wp-includes/block-bindings/sources/post-meta.php new file mode 100644 index 0000000000..0aa55ba180 --- /dev/null +++ b/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/wp-includes/class-wp-block-bindings-registry.php b/wp-includes/class-wp-block-bindings-registry.php index d34733327a..f824694c49 100644 --- a/wp-includes/class-wp-block-bindings-registry.php +++ b/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/wp-includes/class-wp-block.php b/wp-includes/class-wp-block.php index f7bf912f42..f639594b11 100644 --- a/wp-includes/class-wp-block.php +++ b/wp-includes/class-wp-block.php @@ -191,6 +191,204 @@ class WP_Block { 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 @@ class WP_Block { } } + // 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/wp-includes/version.php b/wp-includes/version.php index 2753e3bee4..6a22dda5bd 100644 --- a/wp-includes/version.php +++ b/wp-includes/version.php @@ -16,7 +16,7 @@ * * @global string $wp_version */ -$wp_version = '6.5-alpha-57513'; +$wp_version = '6.5-alpha-57514'; /** * Holds the WordPress DB revision, increments when changes are made to the WordPress DB schema. diff --git a/wp-settings.php b/wp-settings.php index 87769333c5..633ce353a1 100644 --- a/wp-settings.php +++ b/wp-settings.php @@ -376,6 +376,8 @@ require ABSPATH . WPINC . '/fonts/class-wp-font-face.php'; 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'; wp_script_modules()->add_hooks();