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

Experiment: Auto-inserting blocks on the frontend and in the editor (via REST API) #51449

Merged
merged 18 commits into from
Jul 26, 2023
Merged
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
19 changes: 11 additions & 8 deletions lib/compat/wordpress-6.3/rest-api.php
Original file line number Diff line number Diff line change
Expand Up @@ -85,15 +85,18 @@ function add_modified_wp_template_schema() {
}
add_filter( 'rest_api_init', 'add_modified_wp_template_schema' );

/**
* Registers the block patterns REST API routes.
*/
function gutenberg_register_rest_block_patterns() {
$block_patterns = new Gutenberg_REST_Block_Patterns_Controller_6_3();
$block_patterns->register_routes();
// If the Auto-inserting Blocks experiment is enabled, we load the block patterns
// controller in lib/experimental/rest-api.php instead.
if ( ! gutenberg_is_experiment_enabled( 'gutenberg-auto-inserting-blocks' ) ) {
ockham marked this conversation as resolved.
Show resolved Hide resolved
/**
* Registers the block patterns REST API routes.
*/
function gutenberg_register_rest_block_patterns() {
$block_patterns = new Gutenberg_REST_Block_Patterns_Controller_6_3();
$block_patterns->register_routes();
}
add_action( 'rest_api_init', 'gutenberg_register_rest_block_patterns' );
}
add_action( 'rest_api_init', 'gutenberg_register_rest_block_patterns' );


/**
* Registers the Navigation Fallbacks REST API routes.
Expand Down
246 changes: 246 additions & 0 deletions lib/experimental/auto-inserting-blocks.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
<?php
/**
* Auto-inserting blocks.
*
* @package gutenberg
*/

/**
* Return a function that auto-inserts blocks relative to a given block.
dmsnell marked this conversation as resolved.
Show resolved Hide resolved
*
* @param array $inserted_block The block to insert.
* @param string $relative_position The position relative to the given block.
ockham marked this conversation as resolved.
Show resolved Hide resolved
* @param string $anchor_block The block to insert relative to.
dmsnell marked this conversation as resolved.
Show resolved Hide resolved
* @return callable A function that accepts a block's content and returns the content with the inserted block.
*/
function gutenberg_auto_insert_block( $inserted_block, $relative_position, $anchor_block ) {
return function( $block ) use ( $inserted_block, $relative_position, $anchor_block ) {
if ( $anchor_block === $block['blockName'] ) {
if ( 'first_child' === $relative_position ) {
array_unshift( $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( $block['innerContent'], null );
} elseif ( 'last_child' === $relative_position ) {
array_push( $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( $block['innerContent'], null );
}
return $block;
}

$anchor_block_index = array_search( $anchor_block, array_column( $block['innerBlocks'], 'blockName' ), true );
if ( false !== $anchor_block_index && ( 'after' === $relative_position || 'before' === $relative_position ) ) {
if ( 'after' === $relative_position ) {
$anchor_block_index++;
}
array_splice( $block['innerBlocks'], $anchor_block_index, 0, array( $inserted_block ) );

// Find matching `innerContent` chunk index.
$chunk_index = 0;
while ( $anchor_block_index > 0 ) {
if ( ! is_string( $block['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( $block['innerContent'], $chunk_index, 0, array( null ) );
}
return $block;
};
}
Copy link
Member

Choose a reason for hiding this comment

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

how often do we know in advance the block we want to insert and then need to create a function to insert that? what if we instead made a function that actually inserts the block and skipped the anonymous function creation?

if we know we want to always insert the same block we can create our callback with a static value or an enclosed value.

also is this function different than hooking into the existing filters? I wonder if there's reason to insert after more than one block type, in which case we start duplicating code or adding new interfaces, but we already have the ability to do this if we use something like render_block with an appropriate callback.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

how often do we know in advance the block we want to insert and then need to create a function to insert that? what if we instead made a function that actually inserts the block and skipped the anonymous function creation?

if we know we want to always insert the same block we can create our callback with a static value or an enclosed value.

Not sure I'm reading you correctly, but the point of this API is to allow 3rd parties to have their blocks auto-inserted next to pretty much any other block, so we can't really know in advance what blocks will need to be inserted.

also is this function different than hooking into the existing filters? I wonder if there's reason to insert after more than one block type, in which case we start duplicating code or adding new interfaces, but we already have the ability to do this if we use something like render_block with an appropriate callback.

It's definitely possible to insert a block after (or next to) more than one block type, e.g. a like button after Post Content, or as Comment Template's last child.

An earlier version of this (#51294) used render_block, but that had a number of drawbacks -- most notably, it was hard to find out whether or not the block was rendered as part of a user-modified or unmodified template. In the present version of the code, we get this information pretty much for free from the get_block_templates and get_block_file_template filters that we've using.
Furthermore, while the render_block filter worked fine for before/after insertion, it's less practical for first_child or last_child insertion, which inevitably requires some parsed tree structure of blocks; in #51294, I was using render_block_data to that effect.

That aside, I'm not sure how a callback for render_block would look substantially different from what we're doing here that would allow it to eschew the indirection? The problem we're facing is binding, isn't it? We'd like our API to allow people to specify what block they want to auto-insert next to what other block; but pretty much all existing block filters have at best one param that communicates the block that is currently being parsed/rendered/whatever (the anchor block, in our nomenclature), so in order to pass along the "other", auto-inserted block, we need some mechanism to accommodate for that. In Core, I'd do that via a dedicated registry for auto-inserted blocks (as noted here), from which the filters could then read which block needs to be inserted next to the currently processed anchor block; but for the sake of something more self-contained that can be easily implemented within Gutenberg, the filter factories seemed like a fair compromise.

Copy link
Member

Choose a reason for hiding this comment

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

inserted next to pretty much any other block, so we can't really know in advance what blocks will need to be inserted.

this was kind of what led me to ask in the first place, because this function signature implies that we have to know in advance the block we want to insert plus the block type we want to anchor it to.

so in order to pass along the "other", auto-inserted block, we need some mechanism to accommodate for that

I wasn't commenting on the mechanism to do this, but the interface we present, which immediately creates an anonymous function and hides the ability to make decisions about insertion based on the current block.

it seems like if we want to auto-insert after two block types that we have to call gutenberg_auto_insert_block twice, one for each block type. it seems like if we only want to auto-insert based on some block attribute then there's no way to do that.

I'm wondering if we were to invert this so that the consumer passed in the logic for where to insert if it could be less constrained and less complicated. we could pass in an anonymous function which returns a relative position and block to insert, if one ought to be inserted, or a filter that does the same.

add_filter( 'block_auto_insert', function ( $block ) {
	if ( $block['attrs']['show_thing'] ) {
		return array( 'where' => 'after', 'block' => array( … );
	}
} );
add_filter( 'gutenberg_serialize_block', function ( $block ) {
	$auto_insert = apply_filter( 'block_auto_insert', $block );

	if ( null === $auto_insert ) {
		return $block;
	}

	list( 'where' => $where, 'block' => $inserted_block ) = $auto_insert;
	switch ( $where ) {
		case 'before':
			…

		…
	}
} );


/**
* Register blocks for auto-insertion, based on their block.json metadata.
*
* @param array $settings Array of determined settings for registering a block type.
* @param array $metadata Metadata provided for registering a block type.
* @return array Updated settings array.
*/
function gutenberg_register_auto_inserted_blocks( $settings, $metadata ) {
if ( ! isset( $metadata['__experimentalAutoInsert'] ) ) {
return $settings;
}
$auto_insert = $metadata['__experimentalAutoInsert'];

/**
* Map the camelCased position string from block.json to the snake_cased block type position
* used in the auto-inserting block registration function.
*
* @var array
*/
$property_mappings = array(
'before' => 'before',
'after' => 'after',
'firstChild' => 'first_child',
'lastChild' => 'last_child',
);

$inserted_block_name = $metadata['name'];
foreach ( $auto_insert as $anchor_block_name => $position ) {
// Avoid infinite recursion (auto-inserting next to or into self).
if ( $inserted_block_name === $anchor_block_name ) {
_doing_it_wrong(
__METHOD__,
__( 'Cannot auto-insert block next to itself.', 'gutenberg' ),
'6.4.0'
);
continue;
}

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

$mapped_position = $property_mappings[ $position ];

gutenberg_register_auto_inserted_block( $inserted_block_name, $mapped_position, $anchor_block_name );

$settings['auto_insert'][ $anchor_block_name ] = $mapped_position;
}

return $settings;
}
add_filter( 'block_type_metadata_settings', 'gutenberg_register_auto_inserted_blocks', 10, 2 );

/**
* Register block for auto-insertion into the frontend and REST API.
*
* Register a block for auto-insertion into the frontend and into the markup
* returned by the templates and patterns REST API endpoints.
*
* This is currently done by filtering parsed blocks as obtained from a block template
* template part, or pattern and injecting the auto-inserted block where applicable.
*
* @todo In the long run, we'd likely want some sort of registry for auto-inserted blocks.
*
* @param string $inserted_block The name of the block to insert.
* @param string $position The desired position of the auto-inserted block, relative to its anchor block.
* Can be 'before', 'after', 'first_child', or 'last_child'.
* @param string $anchor_block The name of the block to insert the auto-inserted block next to.
* @return void
*/
function gutenberg_register_auto_inserted_block( $inserted_block, $position, $anchor_block ) {
$inserted_block = array(
'blockName' => $inserted_block,
'attrs' => array(),
'innerHTML' => '',
'innerContent' => array(),
'innerBlocks' => array(),
);

$inserter = gutenberg_auto_insert_block( $inserted_block, $position, $anchor_block );
add_filter( 'gutenberg_serialize_block', $inserter, 10, 1 );
Copy link
Member

Choose a reason for hiding this comment

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

it seems here like this is an example of where we can skip the function-creating-function and implement the behavior directly. we don't need an anonymous function in order to pass this empty block around.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I might not be seeing the forest for the trees, but how would we do that (without changing the gutenberg_serialize_block filter's signature -- which is different from gutenberg_auto_insert_block()'s)? 🤔 Note that the empty block is passed a blockName argument (to create an instance of the desired block for auto-insertion).

}

/**
* Parse and reserialize block templates to allow running filters.
*
* By parsing a block template's content and then reserializing it
* via `gutenberg_serialize_blocks()`, we are able to run filters
* on the parsed blocks.
dmsnell marked this conversation as resolved.
Show resolved Hide resolved
*
* @param WP_Block_Template[] $query_result Array of found block templates.
* @return WP_Block_Template[] Updated array of found block templates.
*/
function gutenberg_parse_and_serialize_block_templates( $query_result ) {
foreach ( $query_result as $block_template ) {
if ( 'custom' === $block_template->source ) {
continue;
}
$blocks = parse_blocks( $block_template->content );
$block_template->content = gutenberg_serialize_blocks( $blocks );
}

return $query_result;
}
add_filter( 'get_block_templates', 'gutenberg_parse_and_serialize_block_templates', 10, 1 );
Copy link
Contributor

Choose a reason for hiding this comment

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

Could we change this priority from 10 to PHP_INT_MAX or better yet, a very high number?

WooCommerce BLocks also adds templates through the get_block_templates filter and because it also had a priority of 10, this never ran for that code?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hmm, identical values for priority shouldn't really cause either of the filters not to be run 🤔 (Instead, the one that was added first in terms of code execution flow should be run first.)

To clarify, you're saying that the auto-insertion filter was run, while the WooCommerce Blocks one wasn't?


/**
* Filters the block template object after it has been (potentially) fetched from the theme file.
*
* By parsing a block template's content and then reserializing it
* via `gutenberg_serialize_blocks()`, we are able to run filters
* on the parsed blocks.
dmsnell marked this conversation as resolved.
Show resolved Hide resolved
*
* @param WP_Block_Template|null $block_template The found block template, or null if there is none.
*/
function gutenberg_parse_and_serialize_blocks( $block_template ) {

$blocks = parse_blocks( $block_template->content );
$block_template->content = gutenberg_serialize_blocks( $blocks );

return $block_template;
}
add_filter( 'get_block_file_template', 'gutenberg_parse_and_serialize_blocks', 10, 1 );

// Helper functions.
// -----------------
// The sole purpose of the following two functions (`gutenberg_serialize_block`
// and `gutenberg_serialize_blocks`), which are otherwise copies of their unprefixed
// counterparts (`serialize_block` and `serialize_blocks`) is to apply a filter
// (also called `gutenberg_serialize_block`) as an entry point for modifications
// to the parsed blocks.

/**
* Filterable version of `serialize_block()`.
*
* This function is identical to `serialize_block()`, except that it applies
* the `gutenberg_serialize_block` filter to each block before it is serialized.
*
* @param array $block The block to be serialized.
* @return string The serialized block.
*
* @see serialize_block()
*/
function gutenberg_serialize_block( $block ) {
$block_content = '';

/**
* Filters a parsed block before it is serialized.
*
* @param array $block The block to be serialized.
*/
$block = apply_filters( 'gutenberg_serialize_block', $block );

$index = 0;
foreach ( $block['innerContent'] as $chunk ) {
if ( is_string( $chunk ) ) {
$block_content .= $chunk;
} else { // Compare to WP_Block::render().
$inner_block = $block['innerBlocks'][ $index++ ];
$block_content .= gutenberg_serialize_block( $inner_block );
}
}

if ( ! is_array( $block['attrs'] ) ) {
$block['attrs'] = array();
}

return get_comment_delimited_block_content(
$block['blockName'],
$block['attrs'],
$block_content
);
}
dmsnell marked this conversation as resolved.
Show resolved Hide resolved

/**
* Filterable version of `serialize_blocks()`.
*
* This function is identical to `serialize_blocks()`, except that it applies
* the `gutenberg_serialize_block` filter to each block before it is serialized.
*
* @param array $blocks The blocks to be serialized.
* @return string[] The serialized blocks.
*
* @see serialize_blocks()
*/
function gutenberg_serialize_blocks( $blocks ) {
return implode( '', array_map( 'gutenberg_serialize_block', $blocks ) );
dmsnell marked this conversation as resolved.
Show resolved Hide resolved
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php
/**
* REST API: Gutenberg_REST_Block_Patterns_Controller class
*
* @package Gutenberg
* @subpackage REST_API
*/

/**
* Core class used to access block patterns via the REST API.
*
* @since 6.4.0
*
* @see WP_REST_Controller
*/
class Gutenberg_REST_Block_Patterns_Controller extends Gutenberg_REST_Block_Patterns_Controller_6_3 {
/**
* Prepare a raw block pattern before it gets output in a REST API response.
*
* @todo In the long run, we'd likely want to have a filter in the `WP_Block_Patterns_Registry` class
* instead to allow us plugging in code like this.
*
* @param array $item Raw pattern as registered, before any changes.
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
*/
public function prepare_item_for_response( $item, $request ) {
$response = parent::prepare_item_for_response( $item, $request );
if ( ! gutenberg_is_experiment_enabled( 'gutenberg-auto-inserting-blocks' ) ) {
return $response;
}

$data = $response->get_data();

$blocks = parse_blocks( $data['content'] );
$data['content'] = gutenberg_serialize_blocks( $blocks ); // Serialize or render?

return rest_ensure_response( $data );
}
}
11 changes: 11 additions & 0 deletions lib/experimental/rest-api.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,17 @@
die( 'Silence is golden.' );
}

if ( gutenberg_is_experiment_enabled( 'gutenberg-auto-inserting-blocks' ) ) {
/**
* Registers the block patterns REST API routes.
*/
function gutenberg_register_rest_block_patterns() {
$block_patterns = new Gutenberg_REST_Block_Patterns_Controller();
$block_patterns->register_routes();
}
add_action( 'rest_api_init', 'gutenberg_register_rest_block_patterns' );
}

/**
* Registers the customizer nonces REST API routes.
*/
Expand Down
12 changes: 12 additions & 0 deletions lib/experiments-page.php
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,18 @@ function gutenberg_initialize_experiments_settings() {
)
);

add_settings_field(
'gutenberg-auto-inserting-blocks',
__( 'Auto-inserting blocks', 'gutenberg' ),
'gutenberg_display_experiment_field',
'gutenberg-experiments',
'gutenberg_experiments_section',
array(
'label' => __( 'Test Auto-inserting blocks', 'gutenberg' ),
'id' => 'gutenberg-auto-inserting-blocks',
)
);

register_setting(
'gutenberg-experiments',
'gutenberg-experiments'
Expand Down
6 changes: 6 additions & 0 deletions lib/load.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ function gutenberg_is_experiment_enabled( $name ) {
require_once __DIR__ . '/experimental/class-wp-rest-customizer-nonces.php';
}
require_once __DIR__ . '/experimental/class-gutenberg-rest-template-revision-count.php';
if ( gutenberg_is_experiment_enabled( 'gutenberg-auto-inserting-blocks' ) ) {
require_once __DIR__ . '/experimental/class-gutenberg-rest-block-patterns-controller.php';
}
require_once __DIR__ . '/experimental/rest-api.php';
}

Expand Down Expand Up @@ -117,6 +120,9 @@ function gutenberg_is_experiment_enabled( $name ) {
require __DIR__ . '/experimental/disable-tinymce.php';
}

if ( gutenberg_is_experiment_enabled( 'gutenberg-auto-inserting-blocks' ) ) {
require __DIR__ . '/experimental/auto-inserting-blocks.php';
}
require __DIR__ . '/experimental/interactivity-api/class-wp-interactivity-store.php';
require __DIR__ . '/experimental/interactivity-api/store.php';
require __DIR__ . '/experimental/interactivity-api/scripts.php';
Expand Down
12 changes: 11 additions & 1 deletion packages/block-library/src/pattern/index.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,17 @@ function render_block_core_pattern( $attributes ) {
}

$pattern = $registry->get_registered( $slug );
return do_blocks( $pattern['content'] );
$content = $pattern['content'];

$gutenberg_experiments = get_option( 'gutenberg-experiments' );
if ( $gutenberg_experiments && ! empty( $gutenberg_experiments['gutenberg-auto-inserting-blocks'] ) ) {
// TODO: In the long run, we'd likely want to have a filter in the `WP_Block_Patterns_Registry` class
// instead to allow us plugging in code like this.
$blocks = parse_blocks( $content );
$content = gutenberg_serialize_blocks( $blocks );
}

return do_blocks( $content );
}

add_action( 'init', 'register_block_core_pattern' );