| 1 |
<?php |
| 2 |
/** |
| 3 |
* Block hooks. |
| 4 |
* |
| 5 |
* @package gutenberg |
| 6 |
*/ |
| 7 |
|
| 8 |
/** |
| 9 |
* Register hooked blocks for automatic insertion, based on their block.json metadata. |
| 10 |
* |
| 11 |
* @param array $settings Array of determined settings for registering a block type. |
| 12 |
* @param array $metadata Metadata provided for registering a block type. |
| 13 |
* @return array Updated settings array. |
| 14 |
*/ |
| 15 |
function gutenberg_add_hooked_blocks( $settings, $metadata ) { |
| 16 |
if ( ! isset( $metadata['__experimentalBlockHooks'] ) ) { |
| 17 |
return $settings; |
| 18 |
} |
| 19 |
$block_hooks = $metadata['__experimentalBlockHooks']; |
| 20 |
|
| 21 |
/** |
| 22 |
* Map the camelCased position string from block.json to the snake_cased block type position |
| 23 |
* used in the hooked block registration function. |
| 24 |
* |
| 25 |
* @var array |
| 26 |
*/ |
| 27 |
$property_mappings = array( |
| 28 |
'before' => 'before', |
| 29 |
'after' => 'after', |
| 30 |
'firstChild' => 'first_child', |
| 31 |
'lastChild' => 'last_child', |
| 32 |
); |
| 33 |
|
| 34 |
$inserted_block_name = $metadata['name']; |
| 35 |
foreach ( $block_hooks as $anchor_block_name => $position ) { |
| 36 |
// Avoid infinite recursion (hooking to itself). |
| 37 |
if ( $inserted_block_name === $anchor_block_name ) { |
| 38 |
_doing_it_wrong( |
| 39 |
__METHOD__, |
| 40 |
__( 'Cannot hook block to itself.', 'gutenberg' ), |
| 41 |
'6.4.0' |
| 42 |
); |
| 43 |
continue; |
| 44 |
} |
| 45 |
|
| 46 |
if ( ! isset( $property_mappings[ $position ] ) ) { |
| 47 |
continue; |
| 48 |
} |
| 49 |
|
| 50 |
$mapped_position = $property_mappings[ $position ]; |
| 51 |
|
| 52 |
gutenberg_add_hooked_block( $inserted_block_name, $mapped_position, $anchor_block_name ); |
| 53 |
|
| 54 |
$settings['block_hooks'][ $anchor_block_name ] = $mapped_position; |
| 55 |
} |
| 56 |
|
| 57 |
// Copied from `get_block_editor_server_block_settings()`. |
| 58 |
$fields_to_pick = array( |
| 59 |
'api_version' => 'apiVersion', |
| 60 |
'title' => 'title', |
| 61 |
'description' => 'description', |
| 62 |
'icon' => 'icon', |
| 63 |
'attributes' => 'attributes', |
| 64 |
'provides_context' => 'providesContext', |
| 65 |
'uses_context' => 'usesContext', |
| 66 |
'selectors' => 'selectors', |
| 67 |
'supports' => 'supports', |
| 68 |
'category' => 'category', |
| 69 |
'styles' => 'styles', |
| 70 |
'textdomain' => 'textdomain', |
| 71 |
'parent' => 'parent', |
| 72 |
'ancestor' => 'ancestor', |
| 73 |
'keywords' => 'keywords', |
| 74 |
'example' => 'example', |
| 75 |
'variations' => 'variations', |
| 76 |
); |
| 77 |
// Add `block_hooks` to the list of fields to pick. |
| 78 |
$fields_to_pick['block_hooks'] = 'blockHooks'; |
| 79 |
|
| 80 |
$exposed_settings = array_intersect_key( $settings, $fields_to_pick ); |
| 81 |
|
| 82 |
// TODO: Make work for blocks registered via direct call to gutenberg_add_hooked_block(). |
| 83 |
wp_add_inline_script( |
| 84 |
'wp-blocks', |
| 85 |
'wp.blocks.unstable__bootstrapServerSideBlockDefinitions(' . wp_json_encode( array( $inserted_block_name => $exposed_settings ) ) . ');' |
| 86 |
); |
| 87 |
|
| 88 |
return $settings; |
| 89 |
} |
| 90 |
add_filter( 'block_type_metadata_settings', 'gutenberg_add_hooked_blocks', 10, 2 ); |
| 91 |
|
| 92 |
/** |
| 93 |
* Register a hooked block for automatic insertion into a given block hook. |
| 94 |
* |
| 95 |
* A block hook is specified by a block type and a relative position. The hooked block |
| 96 |
* will be automatically inserted in the given position next to the "anchor" block |
| 97 |
* whenever the latter is encountered. This applies both to the frontend and to the markup |
| 98 |
* returned by the templates and patterns REST API endpoints. |
| 99 |
* |
| 100 |
* This is currently done by filtering parsed blocks as obtained from a block template, |
| 101 |
* template part, or pattern, and injecting the hooked block where applicable. |
| 102 |
* |
| 103 |
* @todo In the long run, we'd likely want some sort of registry for hooked blocks. |
| 104 |
* |
| 105 |
* @param string $hooked_block The name of the block to insert. |
| 106 |
* @param string $position The desired position of the hooked block, relative to its anchor block. |
| 107 |
* Can be 'before', 'after', 'first_child', or 'last_child'. |
| 108 |
* @param string $anchor_block The name of the block to insert the hooked block next to. |
| 109 |
* @return void |
| 110 |
*/ |
| 111 |
function gutenberg_add_hooked_block( $hooked_block, $position, $anchor_block ) { |
| 112 |
$hooked_block_array = array( |
| 113 |
'blockName' => $hooked_block, |
| 114 |
'attrs' => array(), |
| 115 |
'innerHTML' => '', |
| 116 |
'innerContent' => array(), |
| 117 |
'innerBlocks' => array(), |
| 118 |
); |
| 119 |
|
| 120 |
$inserter = gutenberg_insert_hooked_block( $hooked_block_array, $position, $anchor_block ); |
| 121 |
add_filter( 'gutenberg_serialize_block', $inserter, 10, 1 ); |
| 122 |
|
| 123 |
/* |
| 124 |
* The block-types REST API controller uses objects of the `WP_Block_Type` class, which are |
| 125 |
* in turn created upon block type registration. However, that class does not contain |
| 126 |
* a `block_hooks` property (and is not easily extensible), so we have to use a different |
| 127 |
* mechanism to communicate to the controller which hooked blocks have been registered for |
| 128 |
* automatic insertion. We're doing so here (i.e. upon block registration), by adding a filter to |
| 129 |
* the controller's response. |
| 130 |
*/ |
| 131 |
$controller_extender = gutenberg_add_block_hooks_field_to_block_type_controller( $hooked_block, $position, $anchor_block ); |
| 132 |
add_filter( 'rest_prepare_block_type', $controller_extender, 10, 2 ); |
| 133 |
} |
| 134 |
|
| 135 |
/** |
| 136 |
* Return a function that auto-inserts a block next to a given "anchor" block. |
| 137 |
* |
| 138 |
* This is a helper function used in the implementation of block hooks. |
| 139 |
* It is not meant for public use. |
| 140 |
* |
| 141 |
* The auto-inserted block can be inserted before or after the anchor block, |
| 142 |
* or as the first or last child of the anchor block. |
| 143 |
* |
| 144 |
* Note that the returned function mutates the automatically inserted block's |
| 145 |
* designated parent block by inserting into the parent's `innerBlocks` array, |
| 146 |
* and by updating the parent's `innerContent` array accordingly. |
| 147 |
* |
| 148 |
* @param array $inserted_block The block to insert. |
| 149 |
* @param string $relative_position The position relative to the given block. |
| 150 |
* Can be 'before', 'after', 'first_child', or 'last_child'. |
| 151 |
* @param string $anchor_block_type The automatically inserted block will be inserted next to instances of this block type. |
| 152 |
* @return callable A function that accepts a block's content and returns the content with the inserted block. |
| 153 |
*/ |
| 154 |
function gutenberg_insert_hooked_block( $inserted_block, $relative_position, $anchor_block_type ) { |
| 155 |
return function( $block ) use ( $inserted_block, $relative_position, $anchor_block_type ) { |
| 156 |
if ( $anchor_block_type === $block['blockName'] ) { |
| 157 |
if ( 'first_child' === $relative_position ) { |
| 158 |
array_unshift( $block['innerBlocks'], $inserted_block ); |
| 159 |
// Since WP_Block::render() iterates over `inner_content` (rather than `inner_blocks`) |
| 160 |
// when rendering blocks, we also need to prepend a value (`null`, to mark a block |
| 161 |
// location) to that array. |
| 162 |
array_unshift( $block['innerContent'], null ); |
| 163 |
} elseif ( 'last_child' === $relative_position ) { |
| 164 |
array_push( $block['innerBlocks'], $inserted_block ); |
| 165 |
// Since WP_Block::render() iterates over `inner_content` (rather than `inner_blocks`) |
| 166 |
// when rendering blocks, we also need to prepend a value (`null`, to mark a block |
| 167 |
// location) to that array. |
| 168 |
array_push( $block['innerContent'], null ); |
| 169 |
} |
| 170 |
return $block; |
| 171 |
} |
| 172 |
|
| 173 |
$anchor_block_index = array_search( $anchor_block_type, array_column( $block['innerBlocks'], 'blockName' ), true ); |
| 174 |
if ( false !== $anchor_block_index && ( 'after' === $relative_position || 'before' === $relative_position ) ) { |
| 175 |
if ( 'after' === $relative_position ) { |
| 176 |
$anchor_block_index++; |
| 177 |
} |
| 178 |
array_splice( $block['innerBlocks'], $anchor_block_index, 0, array( $inserted_block ) ); |
| 179 |
|
| 180 |
// Find matching `innerContent` chunk index. |
| 181 |
$chunk_index = 0; |
| 182 |
while ( $anchor_block_index > 0 ) { |
| 183 |
if ( ! is_string( $block['innerContent'][ $chunk_index ] ) ) { |
| 184 |
$anchor_block_index--; |
| 185 |
} |
| 186 |
$chunk_index++; |
| 187 |
} |
| 188 |
// Since WP_Block::render() iterates over `inner_content` (rather than `inner_blocks`) |
| 189 |
// when rendering blocks, we also need to insert a value (`null`, to mark a block |
| 190 |
// location) into that array. |
| 191 |
array_splice( $block['innerContent'], $chunk_index, 0, array( null ) ); |
| 192 |
} |
| 193 |
return $block; |
| 194 |
}; |
| 195 |
} |
| 196 |
|
| 197 |
/** |
| 198 |
* Add block hooks information to a block type's controller. |
| 199 |
* |
| 200 |
* @param array $inserted_block_type The type of block to insert. |
| 201 |
* @param string $position The position relative to the anchor block. |
| 202 |
* Can be 'before', 'after', 'first_child', or 'last_child'. |
| 203 |
* @param string $anchor_block_type The hooked block will be inserted next to instances of this block type. |
| 204 |
* @return callable A filter for the `rest_prepare_block_type` hook that adds a `block_hooks` field to the network response. |
| 205 |
*/ |
| 206 |
function gutenberg_add_block_hooks_field_to_block_type_controller( $inserted_block_type, $position, $anchor_block_type ) { |
| 207 |
return function( $response, $block_type ) use ( $inserted_block_type, $position, $anchor_block_type ) { |
| 208 |
if ( $block_type->name !== $inserted_block_type ) { |
| 209 |
return $response; |
| 210 |
} |
| 211 |
|
| 212 |
$data = $response->get_data(); |
| 213 |
if ( ! isset( $data['block_hooks'] ) ) { |
| 214 |
$data['block_hooks'] = array(); |
| 215 |
} |
| 216 |
$data['block_hooks'][ $anchor_block_type ] = $position; |
| 217 |
$response->set_data( $data ); |
| 218 |
return $response; |
| 219 |
}; |
| 220 |
} |
| 221 |
|
| 222 |
/** |
| 223 |
* Parse and reserialize block templates to allow running filters. |
| 224 |
* |
| 225 |
* By parsing a block template's content and then reserializing it |
| 226 |
* via `gutenberg_serialize_blocks()`, we are able to run filters |
| 227 |
* on the parsed blocks. This allows us to modify (parsed) blocks during |
| 228 |
* depth-first traversal already provided by the serialization process, |
| 229 |
* rather than having to do so in a separate pass. |
| 230 |
* |
| 231 |
* @param WP_Block_Template[] $query_result Array of found block templates. |
| 232 |
* @return WP_Block_Template[] Updated array of found block templates. |
| 233 |
*/ |
| 234 |
function gutenberg_parse_and_serialize_block_templates( $query_result ) { |
| 235 |
foreach ( $query_result as $block_template ) { |
| 236 |
if ( 'custom' === $block_template->source ) { |
| 237 |
continue; |
| 238 |
} |
| 239 |
$blocks = parse_blocks( $block_template->content ); |
| 240 |
$block_template->content = gutenberg_serialize_blocks( $blocks ); |
| 241 |
} |
| 242 |
|
| 243 |
return $query_result; |
| 244 |
} |
| 245 |
add_filter( 'get_block_templates', 'gutenberg_parse_and_serialize_block_templates', 10, 1 ); |
| 246 |
|
| 247 |
/** |
| 248 |
* Filters the block template object after it has been (potentially) fetched from the theme file. |
| 249 |
* |
| 250 |
* By parsing a block template's content and then reserializing it |
| 251 |
* via `gutenberg_serialize_blocks()`, we are able to run filters |
| 252 |
* on the parsed blocks. This allows us to modify (parsed) blocks during |
| 253 |
* depth-first traversal already provided by the serialization process, |
| 254 |
* rather than having to do so in a separate pass. |
| 255 |
* |
| 256 |
* @param WP_Block_Template|null $block_template The found block template, or null if there is none. |
| 257 |
*/ |
| 258 |
function gutenberg_parse_and_serialize_blocks( $block_template ) { |
| 259 |
|
| 260 |
$blocks = parse_blocks( $block_template->content ); |
| 261 |
$block_template->content = gutenberg_serialize_blocks( $blocks ); |
| 262 |
|
| 263 |
return $block_template; |
| 264 |
} |
| 265 |
add_filter( 'get_block_file_template', 'gutenberg_parse_and_serialize_blocks', 10, 1 ); |
| 266 |
|
| 267 |
// Helper functions. |
| 268 |
// ----------------- |
| 269 |
// The sole purpose of the following two functions (`gutenberg_serialize_block` |
| 270 |
// and `gutenberg_serialize_blocks`), which are otherwise copies of their unprefixed |
| 271 |
// counterparts (`serialize_block` and `serialize_blocks`) is to apply a filter |
| 272 |
// (also called `gutenberg_serialize_block`) as an entry point for modifications |
| 273 |
// to the parsed blocks. |
| 274 |
|
| 275 |
/** |
| 276 |
* Filterable version of `serialize_block()`. |
| 277 |
* |
| 278 |
* This function is identical to `serialize_block()`, except that it applies |
| 279 |
* the `gutenberg_serialize_block` filter to each block before it is serialized. |
| 280 |
* |
| 281 |
* @param array $block The block to be serialized. |
| 282 |
* @return string The serialized block. |
| 283 |
* |
| 284 |
* @see serialize_block() |
| 285 |
*/ |
| 286 |
function gutenberg_serialize_block( $block ) { |
| 287 |
$block_content = ''; |
| 288 |
|
| 289 |
/** |
| 290 |
* Filters a parsed block before it is serialized. |
| 291 |
* |
| 292 |
* @param array $block The block to be serialized. |
| 293 |
*/ |
| 294 |
$block = apply_filters( 'gutenberg_serialize_block', $block ); |
| 295 |
|
| 296 |
$index = 0; |
| 297 |
foreach ( $block['innerContent'] as $chunk ) { |
| 298 |
if ( is_string( $chunk ) ) { |
| 299 |
$block_content .= $chunk; |
| 300 |
} else { // Compare to WP_Block::render(). |
| 301 |
$inner_block = $block['innerBlocks'][ $index++ ]; |
| 302 |
$block_content .= gutenberg_serialize_block( $inner_block ); |
| 303 |
} |
| 304 |
} |
| 305 |
|
| 306 |
if ( ! is_array( $block['attrs'] ) ) { |
| 307 |
$block['attrs'] = array(); |
| 308 |
} |
| 309 |
|
| 310 |
return get_comment_delimited_block_content( |
| 311 |
$block['blockName'], |
| 312 |
$block['attrs'], |
| 313 |
$block_content |
| 314 |
); |
| 315 |
} |
| 316 |
|
| 317 |
/** |
| 318 |
* Filterable version of `serialize_blocks()`. |
| 319 |
* |
| 320 |
* This function is identical to `serialize_blocks()`, except that it applies |
| 321 |
* the `gutenberg_serialize_block` filter to each block before it is serialized. |
| 322 |
* |
| 323 |
* @param array $blocks The blocks to be serialized. |
| 324 |
* @return string[] The serialized blocks. |
| 325 |
* |
| 326 |
* @see serialize_blocks() |
| 327 |
*/ |
| 328 |
function gutenberg_serialize_blocks( $blocks ) { |
| 329 |
return implode( '', array_map( 'gutenberg_serialize_block', $blocks ) ); |
| 330 |
} |
| 331 |
|
| 332 |
/** |
| 333 |
* Register the `block_hooks` field for the block-types REST API controller. |
| 334 |
* |
| 335 |
* @return void |
| 336 |
*/ |
| 337 |
function gutenberg_register_block_hooks_rest_field() { |
| 338 |
register_rest_field( |
| 339 |
'block-type', |
| 340 |
'block_hooks', |
| 341 |
array( |
| 342 |
'schema' => array( |
| 343 |
'description' => __( 'This block is automatically inserted near any occurence of the block types used as keys of this map, into a relative position given by the corresponding value.', 'gutenberg' ), |
| 344 |
'patternProperties' => array( |
| 345 |
'^[a-zA-Z0-9-]+/[a-zA-Z0-9-]+$' => array( |
| 346 |
'type' => 'string', |
| 347 |
'enum' => array( 'before', 'after', 'first_child', 'last_child' ), |
| 348 |
), |
| 349 |
), |
| 350 |
), |
| 351 |
) |
| 352 |
); |
| 353 |
} |
| 354 |
add_action( 'rest_api_init', 'gutenberg_register_block_hooks_rest_field' ); |
| 355 |
|