wp/wp-includes/class-wp-block-metadata-registry.php
changeset 22 8c2e4d02f4ef
equal deleted inserted replaced
21:48c4eec2b7e6 22:8c2e4d02f4ef
       
     1 <?php
       
     2 /**
       
     3  * Block Metadata Registry
       
     4  *
       
     5  * @package WordPress
       
     6  * @subpackage Blocks
       
     7  * @since 6.7.0
       
     8  */
       
     9 
       
    10 /**
       
    11  * Class used for managing block metadata collections.
       
    12  *
       
    13  * The WP_Block_Metadata_Registry allows plugins to register metadata for large
       
    14  * collections of blocks (e.g., 50-100+) using a single PHP file. This approach
       
    15  * reduces the need to read and decode multiple `block.json` files, enhancing
       
    16  * performance through opcode caching.
       
    17  *
       
    18  * @since 6.7.0
       
    19  */
       
    20 class WP_Block_Metadata_Registry {
       
    21 
       
    22 	/**
       
    23 	 * Container for storing block metadata collections.
       
    24 	 *
       
    25 	 * Each entry maps a base path to its corresponding metadata and callback.
       
    26 	 *
       
    27 	 * @since 6.7.0
       
    28 	 * @var array<string, array<string, mixed>>
       
    29 	 */
       
    30 	private static $collections = array();
       
    31 
       
    32 	/**
       
    33 	 * Caches the last matched collection path for performance optimization.
       
    34 	 *
       
    35 	 * @since 6.7.0
       
    36 	 * @var string|null
       
    37 	 */
       
    38 	private static $last_matched_collection = null;
       
    39 
       
    40 	/**
       
    41 	 * Stores the default allowed collection root paths.
       
    42 	 *
       
    43 	 * @since 6.7.2
       
    44 	 * @var string[]|null
       
    45 	 */
       
    46 	private static $default_collection_roots = null;
       
    47 
       
    48 	/**
       
    49 	 * Registers a block metadata collection.
       
    50 	 *
       
    51 	 * This method allows registering a collection of block metadata from a single
       
    52 	 * manifest file, improving performance for large sets of blocks.
       
    53 	 *
       
    54 	 * The manifest file should be a PHP file that returns an associative array, where
       
    55 	 * the keys are the block identifiers (without their namespace) and the values are
       
    56 	 * the corresponding block metadata arrays. The block identifiers must match the
       
    57 	 * parent directory name for the respective `block.json` file.
       
    58 	 *
       
    59 	 * Example manifest file structure:
       
    60 	 * ```
       
    61 	 * return array(
       
    62 	 *     'example-block' => array(
       
    63 	 *         'title' => 'Example Block',
       
    64 	 *         'category' => 'widgets',
       
    65 	 *         'icon' => 'smiley',
       
    66 	 *         // ... other block metadata
       
    67 	 *     ),
       
    68 	 *     'another-block' => array(
       
    69 	 *         'title' => 'Another Block',
       
    70 	 *         'category' => 'formatting',
       
    71 	 *         'icon' => 'star-filled',
       
    72 	 *         // ... other block metadata
       
    73 	 *     ),
       
    74 	 *     // ... more block metadata entries
       
    75 	 * );
       
    76 	 * ```
       
    77 	 *
       
    78 	 * @since 6.7.0
       
    79 	 *
       
    80 	 * @param string $path     The absolute base path for the collection ( e.g., WP_PLUGIN_DIR . '/my-plugin/blocks/' ).
       
    81 	 * @param string $manifest The absolute path to the manifest file containing the metadata collection.
       
    82 	 * @return bool True if the collection was registered successfully, false otherwise.
       
    83 	 */
       
    84 	public static function register_collection( $path, $manifest ) {
       
    85 		$path = rtrim( wp_normalize_path( $path ), '/' );
       
    86 
       
    87 		$collection_roots = self::get_default_collection_roots();
       
    88 
       
    89 		/**
       
    90 		 * Filters the root directory paths for block metadata collections.
       
    91 		 *
       
    92 		 * Any block metadata collection that is registered must not use any of these paths, or any parent directory
       
    93 		 * path of them. Most commonly, block metadata collections should reside within one of these paths, though in
       
    94 		 * some scenarios they may also reside in entirely different directories (e.g. in case of symlinked plugins).
       
    95 		 *
       
    96 		 * Example:
       
    97 		 * * It is allowed to register a collection with path `WP_PLUGIN_DIR . '/my-plugin'`.
       
    98 		 * * It is not allowed to register a collection with path `WP_PLUGIN_DIR`.
       
    99 		 * * It is not allowed to register a collection with path `dirname( WP_PLUGIN_DIR )`.
       
   100 		 *
       
   101 		 * The default list encompasses the `wp-includes` directory, as well as the root directories for plugins,
       
   102 		 * must-use plugins, and themes. This filter can be used to expand the list, e.g. to custom directories that
       
   103 		 * contain symlinked plugins, so that these root directories cannot be used themselves for a block metadata
       
   104 		 * collection either.
       
   105 		 *
       
   106 		 * @since 6.7.2
       
   107 		 *
       
   108 		 * @param string[] $collection_roots List of allowed metadata collection root paths.
       
   109 		 */
       
   110 		$collection_roots = apply_filters( 'wp_allowed_block_metadata_collection_roots', $collection_roots );
       
   111 
       
   112 		$collection_roots = array_unique(
       
   113 			array_map(
       
   114 				static function ( $allowed_root ) {
       
   115 					return rtrim( wp_normalize_path( $allowed_root ), '/' );
       
   116 				},
       
   117 				$collection_roots
       
   118 			)
       
   119 		);
       
   120 
       
   121 		// Check if the path is valid:
       
   122 		if ( ! self::is_valid_collection_path( $path, $collection_roots ) ) {
       
   123 			_doing_it_wrong(
       
   124 				__METHOD__,
       
   125 				sprintf(
       
   126 					/* translators: %s: list of allowed collection roots */
       
   127 					__( 'Block metadata collections cannot be registered as one of the following directories or their parent directories: %s' ),
       
   128 					esc_html( implode( wp_get_list_item_separator(), $collection_roots ) )
       
   129 				),
       
   130 				'6.7.2'
       
   131 			);
       
   132 			return false;
       
   133 		}
       
   134 
       
   135 		if ( ! file_exists( $manifest ) ) {
       
   136 			_doing_it_wrong(
       
   137 				__METHOD__,
       
   138 				__( 'The specified manifest file does not exist.' ),
       
   139 				'6.7.0'
       
   140 			);
       
   141 			return false;
       
   142 		}
       
   143 
       
   144 		self::$collections[ $path ] = array(
       
   145 			'manifest' => $manifest,
       
   146 			'metadata' => null,
       
   147 		);
       
   148 
       
   149 		return true;
       
   150 	}
       
   151 
       
   152 	/**
       
   153 	 * Retrieves block metadata for a given block within a specific collection.
       
   154 	 *
       
   155 	 * This method uses the registered collections to efficiently lookup
       
   156 	 * block metadata without reading individual `block.json` files.
       
   157 	 *
       
   158 	 * @since 6.7.0
       
   159 	 *
       
   160 	 * @param string $file_or_folder The path to the file or folder containing the block.
       
   161 	 * @return array|null The block metadata for the block, or null if not found.
       
   162 	 */
       
   163 	public static function get_metadata( $file_or_folder ) {
       
   164 		$file_or_folder = wp_normalize_path( $file_or_folder );
       
   165 
       
   166 		$path = self::find_collection_path( $file_or_folder );
       
   167 		if ( ! $path ) {
       
   168 			return null;
       
   169 		}
       
   170 
       
   171 		$collection = &self::$collections[ $path ];
       
   172 
       
   173 		if ( null === $collection['metadata'] ) {
       
   174 			// Load the manifest file if not already loaded
       
   175 			$collection['metadata'] = require $collection['manifest'];
       
   176 		}
       
   177 
       
   178 		// Get the block name from the path.
       
   179 		$block_name = self::default_identifier_callback( $file_or_folder );
       
   180 
       
   181 		return isset( $collection['metadata'][ $block_name ] ) ? $collection['metadata'][ $block_name ] : null;
       
   182 	}
       
   183 
       
   184 	/**
       
   185 	 * Gets the list of absolute paths to all block metadata files that are part of the given collection.
       
   186 	 *
       
   187 	 * For instance, if a block metadata collection is registered with path `WP_PLUGIN_DIR . '/my-plugin/blocks/'`,
       
   188 	 * and the manifest file includes metadata for two blocks `'block-a'` and `'block-b'`, the result of this method
       
   189 	 * will be an array containing:
       
   190 	 * * `WP_PLUGIN_DIR . '/my-plugin/blocks/block-a/block.json'`
       
   191 	 * * `WP_PLUGIN_DIR . '/my-plugin/blocks/block-b/block.json'`
       
   192 	 *
       
   193 	 * @since 6.8.0
       
   194 	 *
       
   195 	 * @param string $path The absolute base path for a previously registered collection.
       
   196 	 * @return string[] List of block metadata file paths, or an empty array if the given `$path` is invalid.
       
   197 	 */
       
   198 	public static function get_collection_block_metadata_files( $path ) {
       
   199 		$path = rtrim( wp_normalize_path( $path ), '/' );
       
   200 
       
   201 		if ( ! isset( self::$collections[ $path ] ) ) {
       
   202 			_doing_it_wrong(
       
   203 				__METHOD__,
       
   204 				__( 'No registered block metadata collection was found for the provided path.' ),
       
   205 				'6.8.0'
       
   206 			);
       
   207 			return array();
       
   208 		}
       
   209 
       
   210 		$collection = &self::$collections[ $path ];
       
   211 
       
   212 		if ( null === $collection['metadata'] ) {
       
   213 			// Load the manifest file if not already loaded.
       
   214 			$collection['metadata'] = require $collection['manifest'];
       
   215 		}
       
   216 
       
   217 		return array_map(
       
   218 			// No normalization necessary since `$path` is already normalized and `$block_name` is just a folder name.
       
   219 			static function ( $block_name ) use ( $path ) {
       
   220 				return "{$path}/{$block_name}/block.json";
       
   221 			},
       
   222 			array_keys( $collection['metadata'] )
       
   223 		);
       
   224 	}
       
   225 
       
   226 	/**
       
   227 	 * Finds the collection path for a given file or folder.
       
   228 	 *
       
   229 	 * @since 6.7.0
       
   230 	 *
       
   231 	 * @param string $file_or_folder The normalized path to the file or folder.
       
   232 	 * @return string|null The normalized collection path if found, or null if not found.
       
   233 	 */
       
   234 	private static function find_collection_path( $file_or_folder ) {
       
   235 		if ( empty( $file_or_folder ) ) {
       
   236 			return null;
       
   237 		}
       
   238 
       
   239 		// Check the last matched collection first, since block registration usually happens in batches per plugin or theme.
       
   240 		$path = rtrim( $file_or_folder, '/' );
       
   241 		if ( self::$last_matched_collection && str_starts_with( $path, self::$last_matched_collection ) ) {
       
   242 			return self::$last_matched_collection;
       
   243 		}
       
   244 
       
   245 		$collection_paths = array_keys( self::$collections );
       
   246 		foreach ( $collection_paths as $collection_path ) {
       
   247 			if ( str_starts_with( $path, $collection_path ) ) {
       
   248 				self::$last_matched_collection = $collection_path;
       
   249 				return $collection_path;
       
   250 			}
       
   251 		}
       
   252 		return null;
       
   253 	}
       
   254 
       
   255 	/**
       
   256 	 * Checks if metadata exists for a given block name in a specific collection.
       
   257 	 *
       
   258 	 * @since 6.7.0
       
   259 	 *
       
   260 	 * @param string $file_or_folder The path to the file or folder containing the block metadata.
       
   261 	 * @return bool True if metadata exists for the block, false otherwise.
       
   262 	 */
       
   263 	public static function has_metadata( $file_or_folder ) {
       
   264 		return null !== self::get_metadata( $file_or_folder );
       
   265 	}
       
   266 
       
   267 	/**
       
   268 	 * Default identifier function to determine the block identifier from a given path.
       
   269 	 *
       
   270 	 * This function extracts the block identifier from the path:
       
   271 	 * - For 'block.json' files, it uses the parent directory name.
       
   272 	 * - For directories, it uses the directory name itself.
       
   273 	 * - For empty paths, it returns an empty string.
       
   274 	 *
       
   275 	 * For example:
       
   276 	 * - Path: '/wp-content/plugins/my-plugin/blocks/example/block.json'
       
   277 	 *   Identifier: 'example'
       
   278 	 * - Path: '/wp-content/plugins/my-plugin/blocks/another-block'
       
   279 	 *   Identifier: 'another-block'
       
   280 	 *
       
   281 	 * This default behavior matches the standard WordPress block structure.
       
   282 	 *
       
   283 	 * @since 6.7.0
       
   284 	 *
       
   285 	 * @param string $path The normalized file or folder path to determine the block identifier from.
       
   286 	 * @return string The block identifier, or an empty string if the path is empty.
       
   287 	 */
       
   288 	private static function default_identifier_callback( $path ) {
       
   289 		// Ensure $path is not empty to prevent unexpected behavior.
       
   290 		if ( empty( $path ) ) {
       
   291 			return '';
       
   292 		}
       
   293 
       
   294 		if ( str_ends_with( $path, 'block.json' ) ) {
       
   295 			// Return the parent directory name if it's a block.json file.
       
   296 			return basename( dirname( $path ) );
       
   297 		}
       
   298 
       
   299 		// Otherwise, assume it's a directory and return its name.
       
   300 		return basename( $path );
       
   301 	}
       
   302 
       
   303 	/**
       
   304 	 * Checks whether the given block metadata collection path is valid against the list of collection roots.
       
   305 	 *
       
   306 	 * @since 6.7.2
       
   307 	 *
       
   308 	 * @param string   $path             Normalized block metadata collection path, without trailing slash.
       
   309 	 * @param string[] $collection_roots List of normalized collection root paths, without trailing slashes.
       
   310 	 * @return bool True if the path is allowed, false otherwise.
       
   311 	 */
       
   312 	private static function is_valid_collection_path( $path, $collection_roots ) {
       
   313 		foreach ( $collection_roots as $allowed_root ) {
       
   314 			// If the path matches any root exactly, it is invalid.
       
   315 			if ( $allowed_root === $path ) {
       
   316 				return false;
       
   317 			}
       
   318 
       
   319 			// If the path is a parent path of any of the roots, it is invalid.
       
   320 			if ( str_starts_with( $allowed_root, $path ) ) {
       
   321 				return false;
       
   322 			}
       
   323 		}
       
   324 
       
   325 		return true;
       
   326 	}
       
   327 
       
   328 	/**
       
   329 	 * Gets the default collection root directory paths.
       
   330 	 *
       
   331 	 * @since 6.7.2
       
   332 	 *
       
   333 	 * @return string[] List of directory paths within which metadata collections are allowed.
       
   334 	 */
       
   335 	private static function get_default_collection_roots() {
       
   336 		if ( isset( self::$default_collection_roots ) ) {
       
   337 			return self::$default_collection_roots;
       
   338 		}
       
   339 
       
   340 		$collection_roots = array(
       
   341 			wp_normalize_path( ABSPATH . WPINC ),
       
   342 			wp_normalize_path( WP_CONTENT_DIR ),
       
   343 			wp_normalize_path( WPMU_PLUGIN_DIR ),
       
   344 			wp_normalize_path( WP_PLUGIN_DIR ),
       
   345 		);
       
   346 
       
   347 		$theme_roots = get_theme_roots();
       
   348 		if ( ! is_array( $theme_roots ) ) {
       
   349 			$theme_roots = array( $theme_roots );
       
   350 		}
       
   351 		foreach ( $theme_roots as $theme_root ) {
       
   352 			$collection_roots[] = trailingslashit( wp_normalize_path( WP_CONTENT_DIR ) ) . ltrim( wp_normalize_path( $theme_root ), '/' );
       
   353 		}
       
   354 
       
   355 		self::$default_collection_roots = array_unique( $collection_roots );
       
   356 		return self::$default_collection_roots;
       
   357 	}
       
   358 }