diff -r 48c4eec2b7e6 -r 8c2e4d02f4ef wp/wp-includes/blocks.php --- a/wp/wp-includes/blocks.php Fri Sep 05 18:40:08 2025 +0200 +++ b/wp/wp-includes/blocks.php Fri Sep 05 18:52:52 2025 +0200 @@ -328,8 +328,9 @@ $style_path_norm = wp_normalize_path( realpath( dirname( $metadata['file'] ) . '/' . $style_path ) ); $style_uri = get_block_asset_url( $style_path_norm ); - $version = ! $is_core_block && isset( $metadata['version'] ) ? $metadata['version'] : false; - $result = wp_register_style( + $block_version = ! $is_core_block && isset( $metadata['version'] ) ? $metadata['version'] : false; + $version = $style_path_norm && defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ? filemtime( $style_path_norm ) : $block_version; + $result = wp_register_style( $style_handle_name, $style_uri, array(), @@ -376,6 +377,48 @@ } /** + * Registers all block types from a block metadata collection. + * + * This can either reference a previously registered metadata collection or, if the `$manifest` parameter is provided, + * register the metadata collection directly within the same function call. + * + * @since 6.8.0 + * @see wp_register_block_metadata_collection() + * @see register_block_type_from_metadata() + * + * @param string $path The absolute base path for the collection ( e.g., WP_PLUGIN_DIR . '/my-plugin/blocks/' ). + * @param string $manifest Optional. The absolute path to the manifest file containing the metadata collection, in + * order to register the collection. If this parameter is not provided, the `$path` parameter + * must reference a previously registered block metadata collection. + */ +function wp_register_block_types_from_metadata_collection( $path, $manifest = '' ) { + if ( $manifest ) { + wp_register_block_metadata_collection( $path, $manifest ); + } + + $block_metadata_files = WP_Block_Metadata_Registry::get_collection_block_metadata_files( $path ); + foreach ( $block_metadata_files as $block_metadata_file ) { + register_block_type_from_metadata( $block_metadata_file ); + } +} + +/** + * Registers a block metadata collection. + * + * This function allows core and third-party plugins to register their block metadata + * collections in a centralized location. Registering collections can improve performance + * by avoiding multiple reads from the filesystem and parsing JSON. + * + * @since 6.7.0 + * + * @param string $path The base path in which block files for the collection reside. + * @param string $manifest The path to the manifest file for the collection. + */ +function wp_register_block_metadata_collection( $path, $manifest ) { + WP_Block_Metadata_Registry::register_collection( $path, $manifest ); +} + +/** * Registers a block type from the metadata stored in the `block.json` file. * * @since 5.5.0 @@ -385,6 +428,7 @@ * @since 6.3.0 Added `selectors` field. * @since 6.4.0 Added support for `blockHooks` field. * @since 6.5.0 Added support for `allowedBlocks`, `viewScriptModule`, and `viewStyle` fields. + * @since 6.7.0 Allow PHP filename as `variations` argument. * * @param string $file_or_folder Path to the JSON file with metadata definition for * the block or path to the folder where the `block.json` file is located. @@ -401,34 +445,23 @@ * instead of reading a JSON file per-block, and then decoding from JSON to PHP. * Using a static variable ensures that the metadata is only read once per request. */ - static $core_blocks_meta; - if ( ! $core_blocks_meta ) { - $core_blocks_meta = require ABSPATH . WPINC . '/blocks/blocks-json.php'; - } + + $file_or_folder = wp_normalize_path( $file_or_folder ); $metadata_file = ( ! str_ends_with( $file_or_folder, 'block.json' ) ) ? trailingslashit( $file_or_folder ) . 'block.json' : $file_or_folder; - $is_core_block = str_starts_with( $file_or_folder, ABSPATH . WPINC ); - // If the block is not a core block, the metadata file must exist. + $is_core_block = str_starts_with( $file_or_folder, wp_normalize_path( ABSPATH . WPINC ) ); $metadata_file_exists = $is_core_block || file_exists( $metadata_file ); - if ( ! $metadata_file_exists && empty( $args['name'] ) ) { - return false; - } + $registry_metadata = WP_Block_Metadata_Registry::get_metadata( $file_or_folder ); - // Try to get metadata from the static cache for core blocks. - $metadata = array(); - if ( $is_core_block ) { - $core_block_name = str_replace( ABSPATH . WPINC . '/blocks/', '', $file_or_folder ); - if ( ! empty( $core_blocks_meta[ $core_block_name ] ) ) { - $metadata = $core_blocks_meta[ $core_block_name ]; - } - } - - // If metadata is not found in the static cache, read it from the file. - if ( $metadata_file_exists && empty( $metadata ) ) { + if ( $registry_metadata ) { + $metadata = $registry_metadata; + } elseif ( $metadata_file_exists ) { $metadata = wp_json_file_decode( $metadata_file, array( 'associative' => true ) ); + } else { + $metadata = array(); } if ( ! is_array( $metadata ) || ( empty( $metadata['name'] ) && empty( $args['name'] ) ) ) { @@ -522,6 +555,34 @@ } } + // If `variations` is a string, it's the name of a PHP file that + // generates the variations. + if ( ! empty( $metadata['variations'] ) && is_string( $metadata['variations'] ) ) { + $variations_path = wp_normalize_path( + realpath( + dirname( $metadata['file'] ) . '/' . + remove_block_asset_path_prefix( $metadata['variations'] ) + ) + ); + if ( $variations_path ) { + /** + * Generates the list of block variations. + * + * @since 6.7.0 + * + * @return string Returns the list of block variations. + */ + $settings['variation_callback'] = static function () use ( $variations_path ) { + $variations = require $variations_path; + return $variations; + }; + // The block instance's `variations` field is only allowed to be an array + // (of known block variations). We unset it so that the block instance will + // provide a getter that returns the result of the `variation_callback` instead. + unset( $settings['variations'] ); + } + } + $settings = array_merge( $settings, $args ); $script_fields = array( @@ -880,7 +941,7 @@ * @param string $relative_position The relative position of the hooked blocks. * Can be one of 'before', 'after', 'first_child', or 'last_child'. * @param string $anchor_block_type The anchor block type. - * @param WP_Block_Template|WP_Post|array $context The block template, template part, `wp_navigation` post type, + * @param WP_Block_Template|WP_Post|array $context The block template, template part, post object, * or pattern that the anchor block belongs to. */ $hooked_block_types = apply_filters( 'hooked_block_types', $hooked_block_types, $relative_position, $anchor_block_type, $context ); @@ -903,7 +964,7 @@ * @param string $hooked_block_type The hooked block type name. * @param string $relative_position The relative position of the hooked block. * @param array $parsed_anchor_block The anchor block, in parsed block array format. - * @param WP_Block_Template|WP_Post|array $context The block template, template part, `wp_navigation` post type, + * @param WP_Block_Template|WP_Post|array $context The block template, template part, post object, * or pattern that the anchor block belongs to. */ $parsed_hooked_block = apply_filters( 'hooked_block', $parsed_hooked_block, $hooked_block_type, $relative_position, $parsed_anchor_block, $context ); @@ -919,7 +980,7 @@ * @param string $hooked_block_type The hooked block type name. * @param string $relative_position The relative position of the hooked block. * @param array $parsed_anchor_block The anchor block, in parsed block array format. - * @param WP_Block_Template|WP_Post|array $context The block template, template part, `wp_navigation` post type, + * @param WP_Block_Template|WP_Post|array $context The block template, template part, post object, * or pattern that the anchor block belongs to. */ $parsed_hooked_block = apply_filters( "hooked_block_{$hooked_block_type}", $parsed_hooked_block, $hooked_block_type, $relative_position, $parsed_anchor_block, $context ); @@ -1006,28 +1067,223 @@ * Runs the hooked blocks algorithm on the given content. * * @since 6.6.0 + * @since 6.7.0 Injects the `theme` attribute into Template Part blocks, even if no hooked blocks are registered. + * @since 6.8.0 Have the `$context` parameter default to `null`, in which case `get_post()` will be called to use the current post as context. * @access private * - * @param string $content Serialized content. - * @param WP_Block_Template|WP_Post|array $context A block template, template part, `wp_navigation` post object, - * or pattern that the blocks belong to. - * @param callable $callback A function that will be called for each block to generate - * the markup for a given list of blocks that are hooked to it. - * Default: 'insert_hooked_blocks'. + * @param string $content Serialized content. + * @param WP_Block_Template|WP_Post|array|null $context A block template, template part, post object, or pattern + * that the blocks belong to. If set to `null`, `get_post()` + * will be called to use the current post as context. + * Default: `null`. + * @param callable $callback A function that will be called for each block to generate + * the markup for a given list of blocks that are hooked to it. + * Default: 'insert_hooked_blocks'. * @return string The serialized markup. */ -function apply_block_hooks_to_content( $content, $context, $callback = 'insert_hooked_blocks' ) { +function apply_block_hooks_to_content( $content, $context = null, $callback = 'insert_hooked_blocks' ) { + // Default to the current post if no context is provided. + if ( null === $context ) { + $context = get_post(); + } + $hooked_blocks = get_hooked_blocks(); - if ( empty( $hooked_blocks ) && ! has_filter( 'hooked_block_types' ) ) { - return $content; + + $before_block_visitor = '_inject_theme_attribute_in_template_part_block'; + $after_block_visitor = null; + if ( ! empty( $hooked_blocks ) || has_filter( 'hooked_block_types' ) ) { + $before_block_visitor = make_before_block_visitor( $hooked_blocks, $context, $callback ); + $after_block_visitor = make_after_block_visitor( $hooked_blocks, $context, $callback ); + } + + $block_allows_multiple_instances = array(); + /* + * Remove hooked blocks from `$hooked_block_types` if they have `multiple` set to false and + * are already present in `$content`. + */ + foreach ( $hooked_blocks as $anchor_block_type => $relative_positions ) { + foreach ( $relative_positions as $relative_position => $hooked_block_types ) { + foreach ( $hooked_block_types as $index => $hooked_block_type ) { + $hooked_block_type_definition = + WP_Block_Type_Registry::get_instance()->get_registered( $hooked_block_type ); + + $block_allows_multiple_instances[ $hooked_block_type ] = + block_has_support( $hooked_block_type_definition, 'multiple', true ); + + if ( + ! $block_allows_multiple_instances[ $hooked_block_type ] && + has_block( $hooked_block_type, $content ) + ) { + unset( $hooked_blocks[ $anchor_block_type ][ $relative_position ][ $index ] ); + } + } + if ( empty( $hooked_blocks[ $anchor_block_type ][ $relative_position ] ) ) { + unset( $hooked_blocks[ $anchor_block_type ][ $relative_position ] ); + } + } + if ( empty( $hooked_blocks[ $anchor_block_type ] ) ) { + unset( $hooked_blocks[ $anchor_block_type ] ); + } } - $blocks = parse_blocks( $content ); + /* + * We also need to cover the case where the hooked block is not present in + * `$content` at first and we're allowed to insert it once -- but not again. + */ + $suppress_single_instance_blocks = static function ( $hooked_block_types ) use ( &$block_allows_multiple_instances, $content ) { + static $single_instance_blocks_present_in_content = array(); + foreach ( $hooked_block_types as $index => $hooked_block_type ) { + if ( ! isset( $block_allows_multiple_instances[ $hooked_block_type ] ) ) { + $hooked_block_type_definition = + WP_Block_Type_Registry::get_instance()->get_registered( $hooked_block_type ); + + $block_allows_multiple_instances[ $hooked_block_type ] = + block_has_support( $hooked_block_type_definition, 'multiple', true ); + } + + if ( $block_allows_multiple_instances[ $hooked_block_type ] ) { + continue; + } + + // The block doesn't allow multiple instances, so we need to check if it's already present. + if ( + in_array( $hooked_block_type, $single_instance_blocks_present_in_content, true ) || + has_block( $hooked_block_type, $content ) + ) { + unset( $hooked_block_types[ $index ] ); + } else { + // We can insert the block once, but need to remember not to insert it again. + $single_instance_blocks_present_in_content[] = $hooked_block_type; + } + } + return $hooked_block_types; + }; + add_filter( 'hooked_block_types', $suppress_single_instance_blocks, PHP_INT_MAX ); + $content = traverse_and_serialize_blocks( + parse_blocks( $content ), + $before_block_visitor, + $after_block_visitor + ); + remove_filter( 'hooked_block_types', $suppress_single_instance_blocks, PHP_INT_MAX ); + + return $content; +} + +/** + * Run the Block Hooks algorithm on a post object's content. + * + * This function is different from `apply_block_hooks_to_content` in that + * it takes ignored hooked block information from the post's metadata into + * account. This ensures that any blocks hooked as first or last child + * of the block that corresponds to the post type are handled correctly. + * + * @since 6.8.0 + * @access private + * + * @param string $content Serialized content. + * @param WP_Post|null $post A post object that the content belongs to. If set to `null`, + * `get_post()` will be called to use the current post as context. + * Default: `null`. + * @param callable $callback A function that will be called for each block to generate + * the markup for a given list of blocks that are hooked to it. + * Default: 'insert_hooked_blocks'. + * @return string The serialized markup. + */ +function apply_block_hooks_to_content_from_post_object( $content, $post = null, $callback = 'insert_hooked_blocks' ) { + // Default to the current post if no context is provided. + if ( null === $post ) { + $post = get_post(); + } + + if ( ! $post instanceof WP_Post ) { + return apply_block_hooks_to_content( $content, $post, $callback ); + } - $before_block_visitor = make_before_block_visitor( $hooked_blocks, $context, $callback ); - $after_block_visitor = make_after_block_visitor( $hooked_blocks, $context, $callback ); + /* + * If the content was created using the classic editor or using a single Classic block + * (`core/freeform`), it might not contain any block markup at all. + * However, we still might need to inject hooked blocks in the first child or last child + * positions of the parent block. To be able to apply the Block Hooks algorithm, we wrap + * the content in a `core/freeform` wrapper block. + */ + if ( ! has_blocks( $content ) ) { + $original_content = $content; + + $content_wrapped_in_classic_block = get_comment_delimited_block_content( + 'core/freeform', + array(), + $content + ); + + $content = $content_wrapped_in_classic_block; + } + + $attributes = array(); + + // If context is a post object, `ignoredHookedBlocks` information is stored in its post meta. + $ignored_hooked_blocks = get_post_meta( $post->ID, '_wp_ignored_hooked_blocks', true ); + if ( ! empty( $ignored_hooked_blocks ) ) { + $ignored_hooked_blocks = json_decode( $ignored_hooked_blocks, true ); + $attributes['metadata'] = array( + 'ignoredHookedBlocks' => $ignored_hooked_blocks, + ); + } - return traverse_and_serialize_blocks( $blocks, $before_block_visitor, $after_block_visitor ); + /* + * We need to wrap the content in a temporary wrapper block with that metadata + * so the Block Hooks algorithm can insert blocks that are hooked as first or last child + * of the wrapper block. + * To that end, we need to determine the wrapper block type based on the post type. + */ + if ( 'wp_navigation' === $post->post_type ) { + $wrapper_block_type = 'core/navigation'; + } elseif ( 'wp_block' === $post->post_type ) { + $wrapper_block_type = 'core/block'; + } else { + $wrapper_block_type = 'core/post-content'; + } + + $content = get_comment_delimited_block_content( + $wrapper_block_type, + $attributes, + $content + ); + + /* + * We need to avoid inserting any blocks hooked into the `before` and `after` positions + * of the temporary wrapper block that we create to wrap the content. + * See https://core.trac.wordpress.org/ticket/63287 for more details. + */ + $suppress_blocks_from_insertion_before_and_after_wrapper_block = static function ( $hooked_block_types, $relative_position, $anchor_block_type ) use ( $wrapper_block_type ) { + if ( + $wrapper_block_type === $anchor_block_type && + in_array( $relative_position, array( 'before', 'after' ), true ) + ) { + return array(); + } + return $hooked_block_types; + }; + + // Apply Block Hooks. + add_filter( 'hooked_block_types', $suppress_blocks_from_insertion_before_and_after_wrapper_block, PHP_INT_MAX, 3 ); + $content = apply_block_hooks_to_content( $content, $post, $callback ); + remove_filter( 'hooked_block_types', $suppress_blocks_from_insertion_before_and_after_wrapper_block, PHP_INT_MAX ); + + // Finally, we need to remove the temporary wrapper block. + $content = remove_serialized_parent_block( $content ); + + // If we wrapped the content in a `core/freeform` block, we also need to remove that. + if ( ! empty( $content_wrapped_in_classic_block ) ) { + /* + * We cannot simply use remove_serialized_parent_block() here, + * as that function assumes that the block wrapper is at the top level. + * However, there might now be a hooked block inserted next to it + * (as first or last child of the parent). + */ + $content = str_replace( $content_wrapped_in_classic_block, $original_content, $content ); + } + + return $content; } /** @@ -1046,10 +1302,28 @@ } /** - * Updates the wp_postmeta with the list of ignored hooked blocks where the inner blocks are stored as post content. - * Currently only supports `wp_navigation` post types. + * Accepts the serialized markup of a block and its inner blocks, and returns serialized markup of the wrapper block. + * + * @since 6.7.0 + * @access private + * + * @see remove_serialized_parent_block() + * + * @param string $serialized_block The serialized markup of a block and its inner blocks. + * @return string The serialized markup of the wrapper block. + */ +function extract_serialized_parent_block( $serialized_block ) { + $start = strpos( $serialized_block, '-->' ) + strlen( '-->' ); + $end = strrpos( $serialized_block, '