diff --git a/src/wp-includes/blocks.php b/src/wp-includes/blocks.php index ca84130995429..34f1d921dda66 100644 --- a/src/wp-includes/blocks.php +++ b/src/wp-includes/blocks.php @@ -375,6 +375,22 @@ function get_block_metadata_i18n_schema() { return $i18n_block_schema; } +/** + * 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. * @@ -402,34 +418,21 @@ function register_block_type_from_metadata( $file_or_folder, $args = array() ) { * 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'; - } $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, ABSPATH . WPINC ); $metadata_file_exists = $is_core_block || file_exists( $metadata_file ); - if ( ! $metadata_file_exists && empty( $args['name'] ) ) { - return false; - } - - // 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 ]; - } - } + $registry_metadata = WP_Block_Metadata_Registry::get_metadata( $file_or_folder ); - // 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'] ) ) ) { diff --git a/src/wp-includes/blocks/index.php b/src/wp-includes/blocks/index.php index 360d14d62d0cd..0e4412ba6d2ef 100644 --- a/src/wp-includes/blocks/index.php +++ b/src/wp-includes/blocks/index.php @@ -160,3 +160,20 @@ function register_core_block_types_from_metadata() { } } add_action( 'init', 'register_core_block_types_from_metadata' ); + +/** + * Registers the core block metadata collection. + * + * This function is hooked into the 'init' action with a priority of 9, + * ensuring that the core block metadata is registered before the regular + * block initialization that happens at priority 10. + * + * @since 6.7.0 + */ +function wp_register_core_block_metadata_collection() { + wp_register_block_metadata_collection( + BLOCKS_PATH, + BLOCKS_PATH . 'blocks-json.php' + ); +} +add_action( 'init', 'wp_register_core_block_metadata_collection', 9 ); diff --git a/src/wp-includes/class-wp-block-metadata-registry.php b/src/wp-includes/class-wp-block-metadata-registry.php new file mode 100644 index 0000000000000..17bbf320d51b5 --- /dev/null +++ b/src/wp-includes/class-wp-block-metadata-registry.php @@ -0,0 +1,273 @@ +> + */ + private static $collections = array(); + + /** + * Caches the last matched collection path for performance optimization. + * + * @since 6.7.0 + * @var string|null + */ + private static $last_matched_collection = null; + + /** + * Stores the WordPress 'wp-includes' directory path. + * + * @since 6.7.0 + * @var string|null + */ + private static $wpinc_dir = null; + + /** + * Stores the normalized WordPress plugin directory path. + * + * @since 6.7.0 + * @var string|null + */ + private static $plugin_dir = null; + + /** + * Registers a block metadata collection. + * + * This method allows registering a collection of block metadata from a single + * manifest file, improving performance for large sets of blocks. + * + * The manifest file should be a PHP file that returns an associative array, where + * the keys are the block identifiers (without their namespace) and the values are + * the corresponding block metadata arrays. The block identifiers must match the + * parent directory name for the respective `block.json` file. + * + * Example manifest file structure: + * ``` + * return array( + * 'example-block' => array( + * 'title' => 'Example Block', + * 'category' => 'widgets', + * 'icon' => 'smiley', + * // ... other block metadata + * ), + * 'another-block' => array( + * 'title' => 'Another Block', + * 'category' => 'formatting', + * 'icon' => 'star-filled', + * // ... other block metadata + * ), + * // ... more block metadata entries + * ); + * ``` + * + * @since 6.7.0 + * + * @param string $path The absolute base path for the collection ( e.g., WP_PLUGIN_DIR . '/my-plugin/blocks/' ). + * @param string $manifest The absolute path to the manifest file containing the metadata collection. + * @return bool True if the collection was registered successfully, false otherwise. + */ + public static function register_collection( $path, $manifest ) { + $path = wp_normalize_path( rtrim( $path, '/' ) ); + + $wpinc_dir = self::get_wpinc_dir(); + $plugin_dir = self::get_plugin_dir(); + + // Check if the path is valid: + if ( str_starts_with( $path, $plugin_dir ) ) { + // For plugins, ensure the path is within a specific plugin directory and not the base plugin directory. + $relative_path = substr( $path, strlen( $plugin_dir ) + 1 ); + $plugin_name = strtok( $relative_path, '/' ); + + if ( empty( $plugin_name ) || $plugin_name === $relative_path ) { + _doing_it_wrong( + __METHOD__, + __( 'Block metadata collections can only be registered for a specific plugin. The provided path is neither a core path nor a valid plugin path.' ), + '6.7.0' + ); + return false; + } + } elseif ( ! str_starts_with( $path, $wpinc_dir ) ) { + // If it's neither a plugin directory path nor within 'wp-includes', the path is invalid. + _doing_it_wrong( + __METHOD__, + __( 'Block metadata collections can only be registered for a specific plugin. The provided path is neither a core path nor a valid plugin path.' ), + '6.7.0' + ); + return false; + } + + if ( ! file_exists( $manifest ) ) { + _doing_it_wrong( + __METHOD__, + __( 'The specified manifest file does not exist.' ), + '6.7.0' + ); + return false; + } + + self::$collections[ $path ] = array( + 'manifest' => $manifest, + 'metadata' => null, + ); + + return true; + } + + /** + * Retrieves block metadata for a given block within a specific collection. + * + * This method uses the registered collections to efficiently lookup + * block metadata without reading individual `block.json` files. + * + * @since 6.7.0 + * + * @param string $file_or_folder The path to the file or folder containing the block. + * @return array|null The block metadata for the block, or null if not found. + */ + public static function get_metadata( $file_or_folder ) { + $path = self::find_collection_path( $file_or_folder ); + if ( ! $path ) { + return null; + } + + $collection = &self::$collections[ $path ]; + + if ( null === $collection['metadata'] ) { + // Load the manifest file if not already loaded + $collection['metadata'] = require $collection['manifest']; + } + + // Get the block name from the path. + $block_name = self::default_identifier_callback( $file_or_folder ); + + return isset( $collection['metadata'][ $block_name ] ) ? $collection['metadata'][ $block_name ] : null; + } + + /** + * Finds the collection path for a given file or folder. + * + * @since 6.7.0 + * + * @param string $file_or_folder The path to the file or folder. + * @return string|null The collection path if found, or null if not found. + */ + private static function find_collection_path( $file_or_folder ) { + if ( empty( $file_or_folder ) ) { + return null; + } + + // Check the last matched collection first, since block registration usually happens in batches per plugin or theme. + $path = wp_normalize_path( rtrim( $file_or_folder, '/' ) ); + if ( self::$last_matched_collection && str_starts_with( $path, self::$last_matched_collection ) ) { + return self::$last_matched_collection; + } + + $collection_paths = array_keys( self::$collections ); + foreach ( $collection_paths as $collection_path ) { + if ( str_starts_with( $path, $collection_path ) ) { + self::$last_matched_collection = $collection_path; + return $collection_path; + } + } + return null; + } + + /** + * Checks if metadata exists for a given block name in a specific collection. + * + * @since 6.7.0 + * + * @param string $file_or_folder The path to the file or folder containing the block metadata. + * @return bool True if metadata exists for the block, false otherwise. + */ + public static function has_metadata( $file_or_folder ) { + return null !== self::get_metadata( $file_or_folder ); + } + + /** + * Default identifier function to determine the block identifier from a given path. + * + * This function extracts the block identifier from the path: + * - For 'block.json' files, it uses the parent directory name. + * - For directories, it uses the directory name itself. + * - For empty paths, it returns an empty string. + * + * For example: + * - Path: '/wp-content/plugins/my-plugin/blocks/example/block.json' + * Identifier: 'example' + * - Path: '/wp-content/plugins/my-plugin/blocks/another-block' + * Identifier: 'another-block' + * + * This default behavior matches the standard WordPress block structure. + * + * @since 6.7.0 + * + * @param string $path The file or folder path to determine the block identifier from. + * @return string The block identifier, or an empty string if the path is empty. + */ + private static function default_identifier_callback( $path ) { + // Ensure $path is not empty to prevent unexpected behavior. + if ( empty( $path ) ) { + return ''; + } + + if ( str_ends_with( $path, 'block.json' ) ) { + // Return the parent directory name if it's a block.json file. + return basename( dirname( $path ) ); + } + + // Otherwise, assume it's a directory and return its name. + return basename( $path ); + } + + /** + * Gets the WordPress 'wp-includes' directory path. + * + * @since 6.7.0 + * + * @return string The WordPress 'wp-includes' directory path. + */ + private static function get_wpinc_dir() { + if ( ! isset( self::$wpinc_dir ) ) { + self::$wpinc_dir = wp_normalize_path( ABSPATH . WPINC ); + } + return self::$wpinc_dir; + } + + /** + * Gets the normalized WordPress plugin directory path. + * + * @since 6.7.0 + * + * @return string The normalized WordPress plugin directory path. + */ + private static function get_plugin_dir() { + if ( ! isset( self::$plugin_dir ) ) { + self::$plugin_dir = wp_normalize_path( WP_PLUGIN_DIR ); + } + return self::$plugin_dir; + } +} diff --git a/src/wp-settings.php b/src/wp-settings.php index b4cebee435e22..62ba4d5dee6ca 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -355,6 +355,7 @@ require ABSPATH . WPINC . '/class-wp-block-type-registry.php'; require ABSPATH . WPINC . '/class-wp-block.php'; require ABSPATH . WPINC . '/class-wp-block-list.php'; +require ABSPATH . WPINC . '/class-wp-block-metadata-registry.php'; require ABSPATH . WPINC . '/class-wp-block-parser-block.php'; require ABSPATH . WPINC . '/class-wp-block-parser-frame.php'; require ABSPATH . WPINC . '/class-wp-block-parser.php'; diff --git a/tests/phpunit/tests/blocks/registerBlockTypeFromMetadataWithRegistry.php b/tests/phpunit/tests/blocks/registerBlockTypeFromMetadataWithRegistry.php new file mode 100644 index 0000000000000..b778a3fb5c838 --- /dev/null +++ b/tests/phpunit/tests/blocks/registerBlockTypeFromMetadataWithRegistry.php @@ -0,0 +1,102 @@ +temp_manifest_file = wp_tempnam( 'block-metadata-manifest' ); + } + + public function tear_down() { + $this->unregister_test_blocks(); + unlink( $this->temp_manifest_file ); + parent::tear_down(); + } + + public function test_register_block_type_from_metadata_with_registry() { + $plugin_path = WP_PLUGIN_DIR . '/test-plugin'; + $block_json_path = $plugin_path . '/blocks/test-block/block.json'; + + // Create a manifest file with metadata for our test block + $manifest_data = array( + 'test-block' => array( + 'name' => 'test-suite/test-block', + 'title' => 'Custom Test Block', + 'category' => 'widgets', + 'icon' => 'smiley', + 'description' => 'A test block registered via WP_Block_Metadata_Registry', + 'supports' => array( 'html' => false ), + 'textdomain' => 'test-plugin', + ), + ); + file_put_contents( $this->temp_manifest_file, 'temp_manifest_file ); + + // Attempt to register the block + $registered_block = register_block_type_from_metadata( $block_json_path ); + + // Assert that the block was registered successfully + $this->assertInstanceOf( 'WP_Block_Type', $registered_block ); + $this->assertEquals( 'test-suite/test-block', $registered_block->name ); + $this->assertEquals( 'Custom Test Block', $registered_block->title ); + $this->assertEquals( 'widgets', $registered_block->category ); + $this->assertEquals( 'smiley', $registered_block->icon ); + $this->assertEquals( 'A test block registered via WP_Block_Metadata_Registry', $registered_block->description ); + $this->assertEquals( array( 'html' => false ), $registered_block->supports ); + } + + public function test_register_block_type_from_metadata_with_registry_and_override() { + $plugin_path = WP_PLUGIN_DIR . '/test-plugin-2'; + $block_json_path = $plugin_path . '/blocks/test-block/block.json'; + + // Create a manifest file with metadata for our test block + $manifest_data = array( + 'test-block' => array( + 'name' => 'test-suite/test-block', + 'title' => 'Custom Test Block', + 'category' => 'widgets', + 'icon' => 'smiley', + 'description' => 'A test block registered via WP_Block_Metadata_Registry', + 'supports' => array( 'html' => false ), + ), + ); + file_put_contents( $this->temp_manifest_file, 'temp_manifest_file ); + + // Attempt to register the block with some overrides + $registered_block = register_block_type_from_metadata( + $block_json_path, + array( + 'title' => 'Overridden Title', + 'supports' => array( 'html' => true ), + ) + ); + + // Assert that the block was registered successfully with overrides + $this->assertInstanceOf( 'WP_Block_Type', $registered_block ); + $this->assertEquals( 'test-suite/test-block', $registered_block->name ); + $this->assertEquals( 'Overridden Title', $registered_block->title ); + $this->assertEquals( 'widgets', $registered_block->category ); + $this->assertEquals( 'smiley', $registered_block->icon ); + $this->assertEquals( 'A test block registered via WP_Block_Metadata_Registry', $registered_block->description ); + $this->assertEquals( array( 'html' => true ), $registered_block->supports ); + } + + private function unregister_test_blocks() { + $registry = WP_Block_Type_Registry::get_instance(); + $block_name = 'test-suite/test-block'; + + if ( $registry->is_registered( $block_name ) ) { + $registry->unregister( $block_name ); + } + } +} diff --git a/tests/phpunit/tests/blocks/wpBlockMetadataRegistry.php b/tests/phpunit/tests/blocks/wpBlockMetadataRegistry.php new file mode 100644 index 0000000000000..3f0ec006b0a0a --- /dev/null +++ b/tests/phpunit/tests/blocks/wpBlockMetadataRegistry.php @@ -0,0 +1,91 @@ +temp_manifest_file = wp_tempnam( 'block-metadata-manifest' ); + } + + public function tear_down() { + unlink( $this->temp_manifest_file ); + parent::tear_down(); + } + + public function test_register_collection_and_get_metadata() { + $path = WP_PLUGIN_DIR . '/test/path'; + $manifest_data = array( + 'test-block' => array( + 'name' => 'test-block', + 'title' => 'Test Block', + ), + ); + + file_put_contents( $this->temp_manifest_file, 'temp_manifest_file ); + + $retrieved_metadata = WP_Block_Metadata_Registry::get_metadata( $path . '/test-block' ); + $this->assertEquals( $manifest_data['test-block'], $retrieved_metadata ); + } + + public function test_get_nonexistent_metadata() { + $path = WP_PLUGIN_DIR . '/nonexistent/path'; + $retrieved_metadata = WP_Block_Metadata_Registry::get_metadata( $path . '/nonexistent-block' ); + $this->assertNull( $retrieved_metadata ); + } + + public function test_has_metadata() { + $path = WP_PLUGIN_DIR . '/another/test/path'; + $manifest_data = array( + 'existing-block' => array( + 'name' => 'existing-block', + 'title' => 'Existing Block', + ), + ); + + file_put_contents( $this->temp_manifest_file, 'temp_manifest_file ); + + $this->assertTrue( WP_Block_Metadata_Registry::has_metadata( $path . '/existing-block' ) ); + $this->assertFalse( WP_Block_Metadata_Registry::has_metadata( $path . '/non-existing-block' ) ); + } + + public function test_register_collection_with_core_path() { + $core_path = ABSPATH . WPINC . '/blocks'; + $result = WP_Block_Metadata_Registry::register_collection( $core_path, $this->temp_manifest_file ); + $this->assertTrue( $result, 'Core path should be registered successfully' ); + } + + public function test_register_collection_with_valid_plugin_path() { + $plugin_path = WP_PLUGIN_DIR . '/my-plugin/blocks'; + $result = WP_Block_Metadata_Registry::register_collection( $plugin_path, $this->temp_manifest_file ); + $this->assertTrue( $result, 'Valid plugin path should be registered successfully' ); + } + + public function test_register_collection_with_invalid_plugin_path() { + $invalid_plugin_path = WP_PLUGIN_DIR; + + $this->setExpectedIncorrectUsage( 'WP_Block_Metadata_Registry::register_collection' ); + + $result = WP_Block_Metadata_Registry::register_collection( $invalid_plugin_path, $this->temp_manifest_file ); + $this->assertFalse( $result, 'Invalid plugin path should not be registered' ); + } + + public function test_register_collection_with_non_existent_path() { + $non_existent_path = '/path/that/does/not/exist'; + + $this->setExpectedIncorrectUsage( 'WP_Block_Metadata_Registry::register_collection' ); + + $result = WP_Block_Metadata_Registry::register_collection( $non_existent_path, $this->temp_manifest_file ); + $this->assertFalse( $result, 'Non-existent path should not be registered' ); + } +}