Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
96979a7
Block Bindings: Add fallback
ockham Aug 28, 2025
123894d
Block Bindings: Use fallback for Date block
ockham Aug 28, 2025
3856411
Add backport changelog
ockham Aug 28, 2025
5729676
In tests, set datetime attribute indirectly.
ockham Sep 1, 2025
6993e73
Extract and process context
ockham Sep 1, 2025
d0ddc0b
Add test coverage for new Block Bindings features
ockham Sep 3, 2025
abc4bf6
Apply new Block Bindings logic to core/button block
ockham Sep 3, 2025
67a1f32
Move remove_filter() from tear_down to wpTearDownAfterClass
ockham Sep 3, 2025
57d8cbe
Guard render_callback against unset attribute
ockham Sep 3, 2025
378c48c
Minor comment clarification
ockham Sep 4, 2025
f42e6d7
No need to add filter during setup
ockham Sep 4, 2025
adcd737
Massage code to look more like Core
ockham Sep 4, 2025
3ac63b3
Remove attributes that were handled by 6.8 early
ockham Sep 4, 2025
4f12184
Update pattern overrides test to make sure they work with newly suppo…
ockham Sep 4, 2025
b77c107
Implement support for pattern overrides in fallback
ockham Sep 4, 2025
7178e53
Whitespace tweaks
ockham Sep 4, 2025
5954aaa
Update context test to make sure they work with newly supported block…
ockham Sep 4, 2025
5bfa0bb
Add block_bindings_supported_attributes filter during setup, absorb t…
ockham Sep 4, 2025
dfdafb4
Whitespace
ockham Sep 4, 2025
8e22cdb
Switch another test to test/block
ockham Sep 4, 2025
8b28f22
Remove unnecessary source declaration
ockham Sep 4, 2025
d0db38f
Merge context tests
ockham Sep 4, 2025
0e39815
Merge tests for different get_value_callbacks
ockham Sep 4, 2025
9f5a12e
Move block_bindings_supported_attributes filter to set_up()
ockham Sep 4, 2025
16421dc
Add PHPDoc for set_up
ockham Sep 4, 2025
ca7d426
Remove @since PHPDocs
ockham Sep 5, 2025
4556ab5
Remove @ticket references from PHPDoc in tests
ockham Sep 5, 2025
292e249
Shut up WPCS
ockham Sep 5, 2025
30680fe
Remove obsolete function parameter
ockham Sep 5, 2025
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
3 changes: 3 additions & 0 deletions backport-changelog/6.9/9469.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
https://github.com/WordPress/wordpress-develop/pull/9469

* https://github.com/WordPress/gutenberg/pull/71389
348 changes: 348 additions & 0 deletions lib/compat/wordpress-6.9/block-bindings.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,348 @@
<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName // Needed for WP_Block_Context_Extractor helper class.
/**
* Block Bindings: Support for generically setting rich-text block attributes.
*
* @since 6.9.0
* @package gutenberg
* @subpackage Block Bindings
*/

/**
* Callback function for the render_block filter.
*
* @since 6.9.0
*
* @param string $block_content The block content.
* @param array $block The full block, including name and attributes.
* @param WP_Block $instance The block instance.
*/
function gutenberg_block_bindings_render_block( $block_content, $block, $instance ) {
static $inside_block_bindings_render = false;
if ( $inside_block_bindings_render ) {
return $block_content;
}

$attributes = $instance->parsed_block['attrs'];
// Process the block bindings and get attributes updated with the values from the sources.
$computed_attributes = gutenberg_process_block_bindings( $instance );
if ( empty( $computed_attributes ) ) {
return $block_content;
}

// Merge the computed attributes with the original attributes.
$instance->attributes = array_merge( $attributes, $computed_attributes );

/**
* This filter is called from WP_Block::render(), after the block content has
* already been rendered. However, dynamic blocks expect their render() method
* to receive block attributes to have their bound values. This means that we have
* to re-render the block here.
* To do so, we'll set a flag that this filter checks when invoked to avoid infinite
* recursion. Furthermore, we can unset all of the block's bindings, as we know that
* they have been processed by the time we reach this point.
*/
unset( $instance->parsed_block['attrs']['metadata']['bindings'] );
$inside_block_bindings_render = true;
$block_content = $instance->render();
$inside_block_bindings_render = false;

if ( ! empty( $computed_attributes ) && ! empty( $block_content ) ) {
foreach ( $computed_attributes as $attribute_name => $source_value ) {
$block_content = gutenberg_replace_html( $block_content, $attribute_name, $source_value, $instance->block_type );
}
}

return $block_content;
}
add_filter( 'render_block', 'gutenberg_block_bindings_render_block', 10, 3 );

/**
* Processes the block bindings and updates the block attributes with the values from the sources.
*
* 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 update the block's attributes
* with the values coming from the bindings.
*
* ### Example
*
* The "bindings" property for an Image block might look like this:
*
* ```json
* {
* "metadata": {
* "bindings": {
* "title": {
* "source": "core/post-meta",
* "args": { "key": "text_custom_field" }
* },
* "url": {
* "source": "core/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.
*
* @since 6.9.0
*
* @param WP_Block $instance The block instance.
* @return array The computed block attributes for the provided block bindings.
*/
function gutenberg_process_block_bindings( $instance ) {
$block_type = $instance->name;
$parsed_block = $instance->parsed_block;
$computed_attributes = array();

// List of block attributes supported by Block Bindings in WP 6.8.
$block_bindings_supported_attributes_6_8 = array(
'core/paragraph' => array( 'content' ),
'core/heading' => array( 'content' ),
'core/image' => array( 'id', 'url', 'title', 'alt' ),
'core/button' => array( 'url', 'text', 'linkTarget', 'rel' ),
);
$supported_block_attributes =
$block_bindings_supported_attributes_6_8[ $block_type ] ??
array();

/**
* Filters the supported block attributes for block bindings.
*
* The dynamic portion of the hook name, `$block_type`, refers to the block type
* whose attributes are being filtered.
*
* @since 6.9.0
*
* @param string[] $supported_block_attributes The block's attributes that are supported by block bindings.
*/
$supported_block_attributes = apply_filters(
"block_bindings_supported_attributes_{$block_type}",
$supported_block_attributes
);

/*
* Remove attributes that we know are processed by WP 6.8 from the list,
* except if we're dealing with the button block, since WP 6.8 capitalizes its
* tag name (e.g. <DIV>).
*/
if ( 'core/button' !== $block_type && isset( $block_bindings_supported_attributes_6_8[ $block_type ] ) ) {
$supported_block_attributes = array_diff(
$supported_block_attributes,
$block_bindings_supported_attributes_6_8[ $block_type ]
);
}

// If the block doesn't have the bindings property, isn't one of the supported
// block types, or the bindings property is not an array, return the block content.
if (
empty( $supported_block_attributes ) ||
empty( $parsed_block['attrs']['metadata']['bindings'] ) ||
! is_array( $parsed_block['attrs']['metadata']['bindings'] )
) {
return $computed_attributes;
}

$bindings = $parsed_block['attrs']['metadata']['bindings'];

/*
* If the default binding is set for pattern overrides, replace it
* with a pattern override binding for all supported attributes.
*/
if (
isset( $bindings['__default']['source'] ) &&
'core/pattern-overrides' === $bindings['__default']['source']
) {
$updated_bindings = array();

/*
* Build a binding array of all supported attributes.
* Note that this also omits the `__default` attribute from the
* resulting array.
*/
foreach ( $supported_block_attributes as $attribute_name ) {
// Retain any non-pattern override bindings that might be present.
$updated_bindings[ $attribute_name ] = isset( $bindings[ $attribute_name ] )
? $bindings[ $attribute_name ]
: array( 'source' => 'core/pattern-overrides' );
}
$bindings = $updated_bindings;
/*
* Update the bindings metadata of the computed attributes.
* This ensures the block receives the expanded __default binding metadata when it renders.
*/
$computed_attributes['metadata'] = array_merge(
$parsed_block['attrs']['metadata'],
array( 'bindings' => $bindings )
);
}

foreach ( $bindings as $attribute_name => $block_binding ) {
// If the attribute is not in the supported list, process next attribute.
if ( ! in_array( $attribute_name, $supported_block_attributes, true ) ) {
continue;
}
// If no source is provided, or that source is not registered, process next attribute.
if ( ! isset( $block_binding['source'] ) || ! is_string( $block_binding['source'] ) ) {
continue;
}

$block_binding_source = get_block_bindings_source( $block_binding['source'] );
if ( null === $block_binding_source ) {
continue;
}

if ( ! class_exists( 'WP_Block_Context_Extractor' ) ) {
// phpcs:ignore Gutenberg.Commenting.SinceTag.MissingClassSinceTag
class WP_Block_Context_Extractor extends WP_Block {
/**
* Static methods of subclasses have access to protected properties
* of instances of the parent class.
* In this case, this gives us access to `available_context`.
*/
// phpcs:ignore Gutenberg.Commenting.SinceTag.MissingMethodSinceTag
public static function get_available_context( $instance ) {
return $instance->available_context;
}
}
}
$available_context = WP_Block_Context_Extractor::get_available_context( $instance );

// Adds the necessary context defined by the source.
if ( ! empty( $block_binding_source->uses_context ) ) {
foreach ( $block_binding_source->uses_context as $context_name ) {
if ( array_key_exists( $context_name, $available_context ) ) {
$instance->context[ $context_name ] = $available_context[ $context_name ];
}
}
}

$source_args = ! empty( $block_binding['args'] ) && is_array( $block_binding['args'] ) ? $block_binding['args'] : array();
$source_value = $block_binding_source->get_value( $source_args, $instance, $attribute_name );

// If the value is not null, process the HTML based on the block and the attribute.
if ( ! is_null( $source_value ) ) {
$computed_attributes[ $attribute_name ] = $source_value;
}
}

return $computed_attributes;
}

/**
* Depending on the block attribute name, replace its value in the HTML based on the value provided.
*
* @since 6.5.0
*
* @param string $block_content Block content.
* @param string $attribute_name The attribute name to replace.
* @param mixed $source_value The value used to replace in the HTML.
* @param WP_Block_Type $block_type The block type.
* @return string The modified block content.
*/
function gutenberg_replace_html( string $block_content, string $attribute_name, $source_value, WP_Block_Type $block_type ) {
if ( ! isset( $block_type->attributes[ $attribute_name ]['source'] ) ) {
return $block_content;
}

// Depending on the attribute source, the processing will be different.
switch ( $block_type->attributes[ $attribute_name ]['source'] ) {
case 'html':
case 'rich-text':
$block_reader = gutenberg_get_block_bindings_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[ $attribute_name ]['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' );

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' );
$block_reader->replace_rich_text( wp_kses_post( $source_value ) );
return $block_reader->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[ $attribute_name ]['selector'],
)
) ) {
return $block_content;
}
$amended_content->set_attribute( $block_type->attributes[ $attribute_name ]['attribute'], $source_value );
return $amended_content->get_updated_html();

default:
return $block_content;
}
}

function gutenberg_get_block_bindings_processor( string $block_content ) {
$internal_processor_class = new class('', WP_HTML_Processor::CONSTRUCTOR_UNLOCK_CODE) extends WP_HTML_Processor {
/**
* 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.
*/
// phpcs:ignore Gutenberg.CodeAnalysis.GuardedFunctionAndClassNames.FunctionNotGuardedAgainstRedeclaration
public function replace_rich_text( $rich_text ) {
if ( $this->is_tag_closer() || ! $this->expects_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 `_`.
$tag_opener = $this->bookmarks['__wp_block_bindings_tag_opener'];
$start = $tag_opener->start + $tag_opener->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' );
$tag_closer = $this->bookmarks['__wp_block_bindings_tag_closer'];
$end = $tag_closer->start;
$this->release_bookmark( '_wp_block_bindings_tag_closer' );

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

return true;
}
};

return $internal_processor_class::create_fragment( $block_content );
}
1 change: 1 addition & 0 deletions lib/load.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ function gutenberg_is_experiment_enabled( $name ) {
require __DIR__ . '/compat/wordpress-6.8/rest-api.php';

// WordPress 6.9 compat.
require __DIR__ . '/compat/wordpress-6.9/block-bindings.php';
require __DIR__ . '/compat/wordpress-6.9/post-data-block-bindings.php';
require __DIR__ . '/compat/wordpress-6.9/rest-api.php';
require __DIR__ . '/compat/wordpress-6.9/class-gutenberg-hierarchical-sort.php';
Expand Down
Loading
Loading