|
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 } |