Make WordPress Core

Changeset 57514


Ignore:
Timestamp:
02/01/2024 12:52:54 PM (6 months ago)
Author:
youknowriad
Message:

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.

Location:
trunk
Files:
5 added
6 edited

Legend:

Unmodified
Added
Removed
  • trunk/src/wp-includes/block-bindings.php

    r57500 r57514  
    2020 * @since 6.5.0
    2121 *
    22  * @param string   $source_name       The name of the source.
     22 * @param string   $source_name       The name of the source. It must be a string containing a namespace prefix, i.e.
     23 *                                    `my-plugin/my-custom-source`. It must only contain lowercase alphanumeric
     24 *                                    characters, the forward slash `/` and dashes.
    2325 * @param array    $source_properties {
    2426 *     The array of arguments that are used to register a source.
  • trunk/src/wp-includes/class-wp-block-bindings-registry.php

    r57500 r57514  
    4343     * @since 6.5.0
    4444     *
    45      * @param string   $source_name       The name of the source.
     45     * @param string   $source_name       The name of the source. It must be a string containing a namespace prefix, i.e.
     46     *                                    `my-plugin/my-custom-source`. It must only contain lowercase alphanumeric
     47     *                                    characters, the forward slash `/` and dashes.
    4648     * @param array    $source_properties {
    4749     *     The array of arguments that are used to register a source.
  • trunk/src/wp-includes/class-wp-block.php

    r57493 r57514  
    193193
    194194    /**
     195     * Processes the block bindings in block's attributes.
     196     *
     197     * A block might contain bindings in its attributes. Bindings are mappings
     198     * between an attribute of the block and a source. A "source" is a function
     199     * registered with `register_block_bindings_source()` that defines how to
     200     * retrieve a value from outside the block, e.g. from post meta.
     201     *
     202     * This function will process those bindings and replace the HTML with the value of the binding.
     203     * The value is retrieved from the source of the binding.
     204     *
     205     * ### Example
     206     *
     207     * The "bindings" property for an Image block might look like this:
     208     *
     209     * ```json
     210     * {
     211     *   "metadata": {
     212     *     "bindings": {
     213     *       "title": {
     214     *         "source": "post_meta",
     215     *         "args": { "key": "text_custom_field" }
     216     *       },
     217     *       "url": {
     218     *         "source": "post_meta",
     219     *         "args": { "key": "url_custom_field" }
     220     *       }
     221     *     }
     222     *   }
     223     * }
     224     * ```
     225     *
     226     * The above example will replace the `title` and `url` attributes of the Image
     227     * block with the values of the `text_custom_field` and `url_custom_field` post meta.
     228     *
     229     * @access private
     230     * @since 6.5.0
     231     *
     232     * @param string   $block_content Block content.
     233     * @param array    $block The full block, including name and attributes.
     234     */
     235    private function process_block_bindings( $block_content ) {
     236        $block = $this->parsed_block;
     237
     238        // Allowed blocks that support block bindings.
     239        // TODO: Look for a mechanism to opt-in for this. Maybe adding a property to block attributes?
     240        $allowed_blocks = array(
     241            'core/paragraph' => array( 'content' ),
     242            'core/heading'   => array( 'content' ),
     243            'core/image'     => array( 'url', 'title', 'alt' ),
     244            'core/button'    => array( 'url', 'text' ),
     245        );
     246
     247        // If the block doesn't have the bindings property, isn't one of the allowed
     248        // block types, or the bindings property is not an array, return the block content.
     249        if ( ! isset( $block['attrs']['metadata']['bindings'] ) ||
     250                ! is_array( $block['attrs']['metadata']['bindings'] ) ||
     251                ! isset( $allowed_blocks[ $this->name ] )
     252                ) {
     253            return $block_content;
     254        }
     255
     256        $block_bindings_sources = get_all_registered_block_bindings_sources();
     257        $modified_block_content = $block_content;
     258        foreach ( $block['attrs']['metadata']['bindings'] as $binding_attribute => $binding_source ) {
     259            // If the attribute is not in the list, process next attribute.
     260            if ( ! in_array( $binding_attribute, $allowed_blocks[ $this->name ], true ) ) {
     261                continue;
     262            }
     263            // If no source is provided, or that source is not registered, process next attribute.
     264            if ( ! isset( $binding_source['source'] ) || ! is_string( $binding_source['source'] ) || ! isset( $block_bindings_sources[ $binding_source['source'] ] ) ) {
     265                continue;
     266            }
     267
     268            $source_callback = $block_bindings_sources[ $binding_source['source'] ]['get_value_callback'];
     269            // Get the value based on the source.
     270            if ( ! isset( $binding_source['args'] ) ) {
     271                $source_args = array();
     272            } else {
     273                $source_args = $binding_source['args'];
     274            }
     275            $source_value = call_user_func_array( $source_callback, array( $source_args, $this, $binding_attribute ) );
     276            // If the value is null, process next attribute.
     277            if ( is_null( $source_value ) ) {
     278                continue;
     279            }
     280
     281            // Process the HTML based on the block and the attribute.
     282            $modified_block_content = $this->replace_html( $modified_block_content, $this->name, $binding_attribute, $source_value );
     283        }
     284        return $modified_block_content;
     285    }
     286
     287    /**
     288     * Depending on the block attributes, replace the HTML based on the value returned by the source.
     289     *
     290     * @since 6.5.0
     291     *
     292     * @param string $block_content Block content.
     293     * @param string $block_name The name of the block to process.
     294     * @param string $block_attr The attribute of the block we want to process.
     295     * @param string $source_value The value used to replace the HTML.
     296     */
     297    private function replace_html( string $block_content, string $block_name, string $block_attr, string $source_value ) {
     298        $block_type = $this->block_type;
     299        if ( null === $block_type || ! isset( $block_type->attributes[ $block_attr ] ) ) {
     300            return $block_content;
     301        }
     302
     303        // Depending on the attribute source, the processing will be different.
     304        switch ( $block_type->attributes[ $block_attr ]['source'] ) {
     305            case 'html':
     306            case 'rich-text':
     307                $block_reader = new WP_HTML_Tag_Processor( $block_content );
     308
     309                // TODO: Support for CSS selectors whenever they are ready in the HTML API.
     310                // In the meantime, support comma-separated selectors by exploding them into an array.
     311                $selectors = explode( ',', $block_type->attributes[ $block_attr ]['selector'] );
     312                // Add a bookmark to the first tag to be able to iterate over the selectors.
     313                $block_reader->next_tag();
     314                $block_reader->set_bookmark( 'iterate-selectors' );
     315
     316                // TODO: This shouldn't be needed when the `set_inner_html` function is ready.
     317                // Store the parent tag and its attributes to be able to restore them later in the button.
     318                // The button block has a wrapper while the paragraph and heading blocks don't.
     319                if ( 'core/button' === $block_name ) {
     320                    $button_wrapper                 = $block_reader->get_tag();
     321                    $button_wrapper_attribute_names = $block_reader->get_attribute_names_with_prefix( '' );
     322                    $button_wrapper_attrs           = array();
     323                    foreach ( $button_wrapper_attribute_names as $name ) {
     324                        $button_wrapper_attrs[ $name ] = $block_reader->get_attribute( $name );
     325                    }
     326                }
     327
     328                foreach ( $selectors as $selector ) {
     329                    // If the parent tag, or any of its children, matches the selector, replace the HTML.
     330                    if ( strcasecmp( $block_reader->get_tag( $selector ), $selector ) === 0 || $block_reader->next_tag(
     331                        array(
     332                            'tag_name' => $selector,
     333                        )
     334                    ) ) {
     335                        $block_reader->release_bookmark( 'iterate-selectors' );
     336
     337                        // TODO: Use `set_inner_html` method whenever it's ready in the HTML API.
     338                        // Until then, it is hardcoded for the paragraph, heading, and button blocks.
     339                        // Store the tag and its attributes to be able to restore them later.
     340                        $selector_attribute_names = $block_reader->get_attribute_names_with_prefix( '' );
     341                        $selector_attrs           = array();
     342                        foreach ( $selector_attribute_names as $name ) {
     343                            $selector_attrs[ $name ] = $block_reader->get_attribute( $name );
     344                        }
     345                        $selector_markup = "<$selector>" . wp_kses_post( $source_value ) . "</$selector>";
     346                        $amended_content = new WP_HTML_Tag_Processor( $selector_markup );
     347                        $amended_content->next_tag();
     348                        foreach ( $selector_attrs as $attribute_key => $attribute_value ) {
     349                            $amended_content->set_attribute( $attribute_key, $attribute_value );
     350                        }
     351                        if ( 'core/paragraph' === $block_name || 'core/heading' === $block_name ) {
     352                            return $amended_content->get_updated_html();
     353                        }
     354                        if ( 'core/button' === $block_name ) {
     355                            $button_markup  = "<$button_wrapper>{$amended_content->get_updated_html()}</$button_wrapper>";
     356                            $amended_button = new WP_HTML_Tag_Processor( $button_markup );
     357                            $amended_button->next_tag();
     358                            foreach ( $button_wrapper_attrs as $attribute_key => $attribute_value ) {
     359                                $amended_button->set_attribute( $attribute_key, $attribute_value );
     360                            }
     361                            return $amended_button->get_updated_html();
     362                        }
     363                    } else {
     364                        $block_reader->seek( 'iterate-selectors' );
     365                    }
     366                }
     367                $block_reader->release_bookmark( 'iterate-selectors' );
     368                return $block_content;
     369
     370            case 'attribute':
     371                $amended_content = new WP_HTML_Tag_Processor( $block_content );
     372                if ( ! $amended_content->next_tag(
     373                    array(
     374                        // TODO: build the query from CSS selector.
     375                        'tag_name' => $block_type->attributes[ $block_attr ]['selector'],
     376                    )
     377                ) ) {
     378                    return $block_content;
     379                }
     380                $amended_content->set_attribute( $block_type->attributes[ $block_attr ]['attribute'], esc_attr( $source_value ) );
     381                return $amended_content->get_updated_html();
     382                break;
     383
     384            default:
     385                return $block_content;
     386                break;
     387        }
     388        return;
     389    }
     390
     391
     392    /**
    195393     * Generates the render output for the block.
    196394     *
     
    286484            }
    287485        }
     486
     487        // Process the block bindings for this block, if any are registered. This
     488        // will replace the block content with the value from a registered binding source.
     489        $block_content = $this->process_block_bindings( $block_content );
    288490
    289491        /**
  • trunk/src/wp-settings.php

    r57503 r57514  
    377377require ABSPATH . WPINC . '/class-wp-script-modules.php';
    378378require ABSPATH . WPINC . '/script-modules.php';
     379require ABSPATH . WPINC . '/block-bindings/sources/post-meta.php';
     380require ABSPATH . WPINC . '/block-bindings/sources/pattern.php';
    379381require ABSPATH . WPINC . '/interactivity-api.php';
    380382
  • trunk/tests/phpunit/includes/functions.php

    r56472 r57514  
    340340 */
    341341function _unhook_block_registration() {
     342    // Block types.
    342343    require __DIR__ . '/unregister-blocks-hooks.php';
    343344    remove_action( 'init', 'register_core_block_types_from_metadata' );
     
    345346    remove_action( 'init', 'register_block_core_widget_group' );
    346347    remove_action( 'init', 'register_core_block_types_from_metadata' );
     348
     349    // Block binding sources.
     350    remove_action( 'init', '_register_block_bindings_pattern_overrides_source' );
     351    remove_action( 'init', '_register_block_bindings_post_meta_source' );
    347352}
    348353tests_add_filter( 'init', '_unhook_block_registration', 1000 );
  • trunk/tests/phpunit/tests/block-bindings/register.php

    r57385 r57514  
    1818
    1919    /**
     20     * Set up before each test.
     21     *
     22     * @since 6.5.0
     23     */
     24    public function set_up() {
     25        foreach ( get_all_registered_block_bindings_sources() as $source_name => $source_properties ) {
     26            unregister_block_bindings_source( $source_name );
     27        }
     28
     29        parent::set_up();
     30    }
     31
     32    /**
    2033     * Tear down after each test.
    2134     *
     
    2437    public function tear_down() {
    2538        foreach ( get_all_registered_block_bindings_sources() as $source_name => $source_properties ) {
    26             if ( str_starts_with( $source_name, 'test/' ) ) {
    27                 unregister_block_bindings_source( $source_name );
    28             }
     39            unregister_block_bindings_source( $source_name );
    2940        }
    3041
Note: See TracChangeset for help on using the changeset viewer.