Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Block Hooks (a.k.a. Auto-inserting Blocks) #5158

Closed
wants to merge 17 commits into from
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
43 changes: 42 additions & 1 deletion src/wp-includes/block-template-utils.php
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,44 @@ function _inject_theme_attribute_in_block_template_content( $template_content )
return $template_content;
}

/**
* Returns a function that...
*
* @since 6.4.0
* @access private
*
* @param WP_Block_Template $block_template a block template.
* @return callable A function that returns a block.
*/
function _parsed_block_visitor( $block_template ) {
return function( $block ) use ( $block_template ) {
if (
'core/template-part' === $block['blockName'] &&
! isset( $block['attrs']['theme'] )
) {
$block['attrs']['theme'] = get_stylesheet();
}
Comment on lines +527 to +532
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (
'core/template-part' === $block['blockName'] &&
! isset( $block['attrs']['theme'] )
) {
$block['attrs']['theme'] = get_stylesheet();
}
$block = _inject_theme_attribute_in_template_part_block( $block );


$hooked_blocks = get_hooked_blocks( $block['blockName'] );
foreach ( $hooked_blocks as $hooked_block_type => $relative_position ) {
$hooked_block = array(
'blockName' => $hooked_block_type,
'attrs' => array(),
'innerHTML' => '',
'innerContent' => array(),
'innerBlocks' => array(),
);
// Need to pass full current block, (potentially its parent -- for sibiling insertion), relative position, and hooked_block.
$block = insert_hooked_block( $hooked_block, $relative_position, $block );
/**
* This filter allows modifiying the auto-inserting behavior...
*/
$block = apply_filters( 'insert_hooked_block', $block, $hooked_blocks[ $hooked_block_type ], $block_template );
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might use separate filters for template and pattern insertion

Suggested change
$block = apply_filters( 'insert_hooked_block', $block, $hooked_blocks[ $hooked_block_type ], $block_template );
$block = apply_filters( 'insert_hooked_block_into_template', $block, $hooked_blocks[ $hooked_block_type ], $block_template );

}
return $block;
};
}

/**
* Parses a block template and removes the theme attribute from each template part.
*
Expand Down Expand Up @@ -565,7 +603,6 @@ function _build_block_template_result_from_file( $template_file, $template_type
$template = new WP_Block_Template();
$template->id = $theme . '//' . $template_file['slug'];
$template->theme = $theme;
$template->content = _inject_theme_attribute_in_block_template_content( $template_content );
$template->slug = $template_file['slug'];
$template->source = 'theme';
$template->type = $template_type;
Expand All @@ -589,6 +626,10 @@ function _build_block_template_result_from_file( $template_file, $template_type
$template->area = $template_file['area'];
}

$blocks = parse_blocks( $template_content );
$visitor = _parsed_block_visitor( $template );
$template->content = serialize_blocks( $blocks, $visitor );

return $template;
}

Expand Down
156 changes: 150 additions & 6 deletions src/wp-includes/blocks.php
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,39 @@ function register_block_type_from_metadata( $file_or_folder, $args = array() ) {
}
}

if ( ! empty( $metadata['blockHooks'] ) ) {
/**
* Map camelCased position string (from block.json) to snake_cased block type position.
*
* @var array
*/
$position_mappings = array(
'before' => 'before',
'after' => 'after',
'firstChild' => 'first_child',
'lastChild' => 'last_child',
);

$settings['block_hooks'] = array();
foreach ( $metadata['blockHooks'] as $anchor_block_name => $position ) {
// Avoid infinite recursion (hooking to itself).
if ( $metadata['name'] === $anchor_block_name ) {
_doing_it_wrong(
__METHOD__,
__( 'Cannot hook block to itself.', 'gutenberg' ),
'6.4.0'
);
continue;
}

if ( ! isset( $position_mappings[ $position ] ) ) {
continue;
}

$settings['block_hooks'][ $anchor_block_name ] = $position_mappings[ $position ];
}
}

if ( ! empty( $metadata['render'] ) ) {
$template_path = wp_normalize_path(
realpath(
Expand Down Expand Up @@ -794,16 +827,22 @@ function get_comment_delimited_block_content( $block_name, $block_attributes, $b
* instead preserve the markup as parsed.
*
* @since 5.3.1
* @since 6.4.0 The `$callback` parameter was added.
*
* @param array $block A representative array of a single parsed block object. See WP_Block_Parser_Block.
* @param array $block A representative array of a single parsed block object. See WP_Block_Parser_Block.
* @param string $callback Optional. Callback to run on the block before serialization. Default null.
* @return string String of rendered HTML.
*/
function serialize_block( $block ) {
function serialize_block( $block, $callback = null ) {
if ( is_callable( $callback ) ) {
$block = call_user_func( $callback, $block );
}

$block_content = '';

$index = 0;
foreach ( $block['innerContent'] as $chunk ) {
$block_content .= is_string( $chunk ) ? $chunk : serialize_block( $block['innerBlocks'][ $index++ ] );
$block_content .= is_string( $chunk ) ? $chunk : serialize_block( $block['innerBlocks'][ $index++ ], $callback );
}

if ( ! is_array( $block['attrs'] ) ) {
Expand All @@ -822,12 +861,18 @@ function serialize_block( $block ) {
* parsed blocks.
*
* @since 5.3.1
* @since 6.4.0 The `$callback` parameter was added.
*
* @param array[] $blocks An array of representative arrays of parsed block objects. See serialize_block().
* @param array[] $blocks An array of representative arrays of parsed block objects. See serialize_block().
* @param string $callback Optional. Callback to run on the blocks before serialization. Default null.
* @return string String of rendered HTML.
*/
function serialize_blocks( $blocks ) {
return implode( '', array_map( 'serialize_block', $blocks ) );
function serialize_blocks( $blocks, $callback = null ) {
$result = '';
foreach ( $blocks as $block ) {
$result .= serialize_block( $block, $callback );
};
return $result;
}

/**
Expand Down Expand Up @@ -1620,3 +1665,102 @@ function get_comments_pagination_arrow( $block, $pagination_type = 'next' ) {
}
return null;
}

/**
* Retrieves block types (and positions) hooked into the given block.
*
* @since 6.4.0
*
* @param string $name Block type name including namespace.
* @return array Associative array of `$block_type_name => $position` pairs.
*/
function get_hooked_blocks( $name ) {
$block_types = WP_Block_Type_Registry::get_instance()->get_all_registered();
$hooked_blocks = array();
foreach ( $block_types as $block_type ) {
foreach ( $block_type->block_hooks as $anchor_block_type => $relative_position ) {
if ( $anchor_block_type === $name ) {
$hooked_blocks[ $block_type->name ] = $relative_position;
}
}
}
return $hooked_blocks;
}
Comment on lines +1677 to +1688
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gziolo and I discussed adding a cache for this to WP_Block_Type_Registry that is updated whenever a block is registered or unregistered, respectively.

(We might want to implement the cache in a separate class for better encapsulation and call the relevant class methods from WP_Block_Type_Registry's register() and unregister() methods.)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First version is ready with #5239. Let's integrate it with the auto-insertion logic first, because it might impact how it's going to be optimized.


function insert_hooked_blocks( $block ) {
$hooked_blocks = get_hooked_blocks( $block['blockName'] );
foreach ( $hooked_blocks as $hooked_block_type => $relative_position ) {
$hooked_block = array(
'blockName' => $hooked_block_type,
'attrs' => array(),
'innerHTML' => '',
'innerContent' => array(),
'innerBlocks' => array(),
);
// Need to pass full current block, (possibly its parent), relative position, and hooked_block.
$block = insert_hooked_block( $hooked_block, $relative_position, $block );
Comment on lines +1691 to +1701
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One way to implement sibling insertion could be by changing this code by treating the two cases separately (child vs sibling insertion) -- a bit more similar to what we're doing in GB:

	$hooked_blocks = get_hooked_blocks( $block['blockName'] );
	$sibling_hooked_blocks = array_filter( $hooked_blocks, ... ); // position is 'before' or 'after'
	$child_hooked_blocks = array_filter( $hooked_blocks , ... ); // position is 'first_child' or 'last_child'

	foreach ( $child_hooked_blocks as $hooked_block_type => $relative_position ) {
		/* Same implementation as before */
	}

	foreach ( $sibling_hooked_blocks as $hooked_block_type => $relative_position ) {
		$anchor_block_index = array_search( $hooked_block_type, array_column( $block['innerBlocks'], 'blockName' ), true );
		if ( false !== $anchor_block_index && ( 'after' === $relative_position || 'before' === $relative_position ) )
 		{
			/* See GB's gutenberg_insert_hooked_block */
		}

(Rough draft; we might want to move some of that logic into insert_hooked_block, if possible.)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The crucial conceptual difference is that for child insertion, the anchor block is the one that needs to be modified (by inserting the first or last child into its inner blocks), whereas for sibling insertion, it isn't; instead, it is the anchor block's parent -- which is an additional piece of information that we need in that case.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They are definitely different, the case for first_child and last_child is way simpler to integrate as it can be done on with the new callback param as that's mostly about applying modifications to the $block object in exactly the same way as in the Gutenberg plugin.

I still don't have a clear picture about the sibling insertion so whatever works, should be implemented initially. We can discuss further optimization later, but I can't tell based on the abstract code as it's a very complex use case. I'm still impressed how you managed to integrate it using only existing filters in the plugin.

}
return $block;
}

/**
* Auto-inserts a block next to a given "anchor" block.
*
* This is a helper function used in the implementation of block hooks.
* It is not meant for public use.
*
* The auto-inserted block can be inserted before or after the anchor block,
* or as the first or last child of the anchor block.
*
* Note that the this mutates the automatically inserted block's
* designated parent block by inserting into the parent's `innerBlocks` array,
* and by updating the parent's `innerContent` array accordingly.
*
* @param array $inserted_block The block to insert.
* @param string $relative_position The position relative to the given block.
* Can be 'before', 'after', 'first_child', or 'last_child'.
* @param array $anchor_block The automatically inserted block will be inserted next to instances of this block type.
* @return array The modified anchor block.
*/
function insert_hooked_block( $inserted_block, $relative_position, $anchor_block ) {
if ( 'first_child' === $relative_position ) {
array_unshift( $anchor_block['innerBlocks'], $inserted_block );
/*
* Since WP_Block::render() iterates over `inner_content` (rather than `inner_blocks`)
* when rendering blocks, we also need to prepend a value (`null`, to mark a block
* location) to that array.
*/
array_unshift( $anchor_block['innerContent'], null );
} elseif ( 'last_child' === $relative_position ) {
array_push( $anchor_block['innerBlocks'], $inserted_block );
// Since WP_Block::render() iterates over `inner_content` (rather than `inner_blocks`)
// when rendering blocks, we also need to prepend a value (`null`, to mark a block
// location) to that array.
array_push( $anchor_block['innerContent'], null );
}
Comment on lines +1726 to +1740
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to update based on WordPress/gutenberg#54349!

return $anchor_block;

// TODO: Implement sibling insertion. We might need to pass the anchor's parent block for this to work.
//
// $anchor_block_index = array_search( $anchor_block, $anchor_block_parent['innerBlocks'], true ); // Will this work?
// if ( false !== $anchor_block_index && ( 'after' === $relative_position || 'before' === $relative_position ) ) {
// if ( 'after' === $relative_position ) {
// $anchor_block_index++;
// }
// array_splice( $anchor_block_parent['innerBlocks'], $anchor_block_index, 0, array( $inserted_block ) );

// // Find matching `innerContent` chunk index.
// $chunk_index = 0;
// while ( $anchor_block_index > 0 ) {
// if ( ! is_string( $anchor_block_parent['innerContent'][ $chunk_index ] ) ) {
// $anchor_block_index--;
// }
// $chunk_index++;
// }
// // Since WP_Block::render() iterates over `inner_content` (rather than `inner_blocks`)
// // when rendering blocks, we also need to insert a value (`null`, to mark a block
// // location) into that array.
// array_splice( $anchor_block_parent['innerContent'], $chunk_index, 0, array( null ) );
// }
// return $anchor_block;
}
16 changes: 14 additions & 2 deletions src/wp-includes/class-wp-block-patterns-registry.php
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,11 @@ public function get_registered( $pattern_name ) {
return null;
}

return $this->registered_patterns[ $pattern_name ];
$pattern = $this->registered_patterns[ $pattern_name ];
$blocks = parse_blocks( $pattern['content'] );
$visitor = _parsed_block_visitor( $pattern ); // TODO: Should we use different functions for template vs pattern?
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably something like

Suggested change
$visitor = _parsed_block_visitor( $pattern ); // TODO: Should we use different functions for template vs pattern?
$visitor = _parsed_block_visitor_for_pattern( $pattern ); // TODO: Use nicer factory name!

In that function, apply a insert_hooked_block_into_pattern filter (akin to https://github.com/WordPress/wordpress-develop/pull/5158/files#r1325968103).

$pattern['content'] = serialize_blocks( $blocks, $visitor );
Comment on lines +168 to +171
Copy link
Contributor Author

@ockham ockham Sep 11, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO (unrelated to Block Hooks): We probably want some kind of filter here to allow modifying the pattern content -- otherwise, there's no way for plugins (and GB) to modify patterns.

return $pattern;
}

/**
Expand All @@ -178,11 +182,19 @@ public function get_registered( $pattern_name ) {
* and per style.
*/
public function get_all_registered( $outside_init_only = false ) {
return array_values(
$patterns = array_values(
$outside_init_only
? $this->registered_patterns_outside_init
: $this->registered_patterns
);

foreach ( $patterns as $index => $pattern ) {
$blocks = parse_blocks( $pattern['content'] );
$visitor = _parsed_block_visitor( $pattern ); // TODO: Should we use different functions for template vs pattern?
$patterns[ $index ]['content'] = serialize_blocks( $blocks, $visitor );
}
Comment on lines +192 to +195
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO: Reuse logic from get_registered.


return $patterns;
}

/**
Expand Down
14 changes: 14 additions & 0 deletions src/wp-includes/class-wp-block-type.php
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,18 @@ class WP_Block_Type {
*/
public $style_handles = array();

/**
* Block hooks for this block type.
*
* A block hook is specified by a block type and a relative position.
* The hooked block will be automatically inserted in the given position
* next to the "anchor" block whenever the latter is encountered.
*
* @since 6.4.0
* @var array
*/
public $block_hooks = array();

/**
* Deprecated block type properties for script and style handles.
*
Expand Down Expand Up @@ -254,6 +266,7 @@ class WP_Block_Type {
* `editor_style_handles`, and `style_handles` properties.
* Deprecated the `editor_script`, `script`, `view_script`, `editor_style`, and `style` properties.
* @since 6.3.0 Added the `selectors` property.
* @since 6.4.0 Added the `block_hooks` property.
*
* @see register_block_type()
*
Expand All @@ -279,6 +292,7 @@ class WP_Block_Type {
* @type array[] $variations Block variations.
* @type array $selectors Custom CSS selectors for theme.json style generation.
* @type array|null $supports Supported features.
* @type array $block_hooks Block hooks.
* @type array|null $example Structured data for the block preview.
* @type callable|null $render_callback Block type render callback.
* @type array|null $attributes Block type attributes property schemas.
Expand Down