diff --git a/src/wp-includes/class-wp-script-modules.php b/src/wp-includes/class-wp-script-modules.php index 154c951513047..20053590a9dcd 100644 --- a/src/wp-includes/class-wp-script-modules.php +++ b/src/wp-includes/class-wp-script-modules.php @@ -21,6 +21,7 @@ * fetchpriority: 'auto'|'low'|'high', * textdomain?: string, * translations_path?: string, + * scopes?: array, * } */ class WP_Script_Modules { @@ -98,36 +99,55 @@ class WP_Script_Modules { * * @since 6.5.0 * @since 6.9.0 Added the $args parameter. - * - * @param string $id The identifier of the script module. Should be unique. It will be used in the - * final import map. - * @param string $src Optional. Full URL of the script module, or path of the script module relative - * to the WordPress root directory. If it is provided and the script module has - * not been registered yet, it will be registered. - * @param array> $deps { - * Optional. List of dependencies. - * - * @type string|array ...$0 { - * An array of script module identifiers of the dependencies of this script - * module. The dependencies can be strings or arrays. If they are arrays, - * they need an `id` key with the script module identifier, and can contain - * an `import` key with either `static` or `dynamic`. By default, - * dependencies that don't contain an `import` key are considered static. - * - * @type string $id The script module identifier. - * @type string $import Optional. Import type. May be either `static` or - * `dynamic`. Defaults to `static`. - * } - * } - * @param string|false|null $version Optional. String specifying the script module version number. Defaults to false. - * It is added to the URL as a query string for cache busting purposes. If $version - * is set to false, the version number is the currently installed WordPress version. - * If $version is set to null, no version is added. - * @param array $args { + * @since 7.1.0 Added the `scopes` key to the $args parameter. + * + * @param string $id The identifier of the script module. Should be unique. It will be used in the + * final import map. + * @param string $src Optional. Full URL of the script module, or path of the script module relative + * to the WordPress root directory. If it is provided and the script module has + * not been registered yet, it will be registered. + * @param array> $deps { + * Optional. List of dependencies. + * + * @type string|array ...$0 { + * An array of script module identifiers of the dependencies of this script + * module. The dependencies can be strings or arrays. If they are arrays, + * they need an `id` key with the script module identifier, and can contain + * an `import` key with either `static` or `dynamic`. By default, + * dependencies that don't contain an `import` key are considered static. + * + * @type string $id The script module identifier. + * @type string $import Optional. Import type. May be either `static` or + * `dynamic`. Defaults to `static`. + * } + * } + * @param string|false|null $version Optional. String specifying the script module version number. Defaults to false. + * It is added to the URL as a query string for cache busting purposes. If $version + * is set to false, the version number is the currently installed WordPress version. + * If $version is set to null, no version is added. + * @param array>|null> $args { * Optional. An array of additional args. Default empty array. * * @type bool $in_footer Whether to print the script module in the footer. Only relevant to block themes. Default 'false'. Optional. * @type 'auto'|'low'|'high' $fetchpriority Fetch priority. Default 'auto'. Optional. + * @type array|null $scopes Optional. Constrains the importers that can resolve this module's bare specifier. + * When omitted or null, the module is public (top-level `imports`). When an array is + * provided, the module is emitted under the import map's `scopes` keyed by each entry, + * and is not present in top-level `imports`. An empty array means the module is + * registered but cannot be resolved via bare specifier from anywhere; declared + * dependencies on such a module (static or dynamic) are treated like missing + * dependencies and the dependent will not be emitted. + * Each entry is one of: + * - A non-empty string URL prefix, emitted as-authored. WordPress URL filters such as + * `script_module_loader_src` are NOT applied to string scopes; authors are + * responsible for matching the final browser URL. + * - `array( 'module_id' => string )` — at print time, resolves to the directory + * portion of the named registered module's filtered src URL. Recommended for + * CDN-aware scoping because it goes through the existing `script_module_loader_src` + * filter. + * This is an API-hygiene mechanism, not a security boundary; the file remains + * fetchable, and any caller can still list the module's id in `$deps` or call + * `wp_enqueue_script_module()` on it. Resolution failure is a runtime `TypeError`. * } */ public function register( string $id, string $src, array $deps = array(), $version = false, array $args = array() ) { @@ -178,13 +198,68 @@ public function register( string $id, string $src, array $deps = array(), $versi } } - $this->registered[ $id ] = array( + $registered_module = array( 'src' => $src, 'version' => $version, 'dependencies' => $dependencies, 'in_footer' => $in_footer, 'fetchpriority' => $fetchpriority, ); + + if ( array_key_exists( 'scopes', $args ) && null !== $args['scopes'] ) { + if ( ! is_array( $args['scopes'] ) ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: 1: Module identifier; 2: Received type. */ + __( 'The "scopes" arg for module "%1$s" must be null or an array; received %2$s.' ), + $id, + gettype( $args['scopes'] ) + ), + '7.1.0' + ); + } else { + $validated_scopes = array(); + foreach ( $args['scopes'] as $scope_entry ) { + if ( is_string( $scope_entry ) ) { + if ( '' === $scope_entry ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: %s: Module identifier. */ + __( 'A scope entry for module "%s" must be a non-empty string.' ), + $id + ), + '7.1.0' + ); + continue; + } + $validated_scopes[] = $scope_entry; + } elseif ( + is_array( $scope_entry ) && + 1 === count( $scope_entry ) && + isset( $scope_entry['module_id'] ) && + is_string( $scope_entry['module_id'] ) && + '' !== $scope_entry['module_id'] + ) { + $validated_scopes[] = array( 'module_id' => $scope_entry['module_id'] ); + } else { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: %s: Module identifier. */ + __( 'A scope entry for module "%s" must be a non-empty string or an array with a single "module_id" key whose value is a non-empty string.' ), + $id + ), + '7.1.0' + ); + } + } + $registered_module['scopes'] = $validated_scopes; + } + } + + $this->registered[ $id ] = $registered_module; } } @@ -270,6 +345,7 @@ public function set_in_footer( string $id, bool $in_footer ): bool { * * @since 6.5.0 * @since 6.9.0 Added the $args parameter. + * @since 7.1.0 Added the `scopes` key to the $args parameter. * * @param string $id The identifier of the script module. Should be unique. It will be used in the * final import map. @@ -295,11 +371,12 @@ public function set_in_footer( string $id, bool $in_footer ): bool { * It is added to the URL as a query string for cache busting purposes. If $version * is set to false, the version number is the currently installed WordPress version. * If $version is set to null, no version is added. - * @param array $args { + * @param array>|null> $args { * Optional. An array of additional args. Default empty array. * * @type bool $in_footer Whether to print the script module in the footer. Only relevant to block themes. Default 'false'. Optional. * @type 'auto'|'low'|'high' $fetchpriority Fetch priority. Default 'auto'. Optional. + * @type array|null $scopes Optional. See {@see WP_Script_Modules::register()} for details. * } */ public function enqueue( string $id, string $src = '', array $deps = array(), $version = false, array $args = array() ) { @@ -619,7 +696,7 @@ public function print_script_module_preloads() { */ public function print_import_map() { $import_map = $this->get_import_map(); - if ( ! empty( $import_map['imports'] ) ) { + if ( ! empty( $import_map['imports'] ) || ! empty( $import_map['scopes'] ) ) { wp_print_inline_script_tag( (string) wp_json_encode( $import_map, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ), array( @@ -635,11 +712,13 @@ public function print_import_map() { * * @since 6.5.0 * @since 7.0.0 Script module dependencies ('module_dependencies') of classic scripts are now included. + * @since 7.1.0 Scoped (private) modules are emitted under a top-level `scopes` key. * * @global WP_Scripts $wp_scripts * - * @return array> Array with an `imports` key mapping to an array of script module - * identifiers and their respective URLs, including the version query. + * @return array Array with an `imports` key mapping module identifiers to URLs (including the version query), + * and an optional `scopes` key mapping scope-prefix URLs to per-scope import maps. + * @phpstan-return array{ imports: array, scopes?: array> } */ private function get_import_map(): array { global $wp_scripts; @@ -665,7 +744,8 @@ private function get_import_map(): array { $module_dependencies = $wp_scripts->get_data( $handle, 'module_dependencies' ); if ( is_array( $module_dependencies ) ) { - $missing_module_dependencies = array(); + $missing_module_dependencies = array(); + $unreachable_module_dependencies = array(); foreach ( $module_dependencies as $module ) { if ( is_string( $module ) ) { $id = $module; @@ -679,6 +759,11 @@ private function get_import_map(): array { if ( ! isset( $this->registered[ $id ] ) ) { $missing_module_dependencies[] = $id; + } elseif ( + isset( $this->registered[ $id ]['scopes'] ) && + array() === $this->registered[ $id ]['scopes'] + ) { + $unreachable_module_dependencies[] = $id; } else { $classic_script_module_dependencies[] = $id; } @@ -697,6 +782,20 @@ private function get_import_map(): array { '7.0.0' ); } + + if ( count( $unreachable_module_dependencies ) > 0 ) { + _doing_it_wrong( + 'WP_Scripts::add_data', + sprintf( + /* translators: 1: Script handle, 2: 'module_dependencies', 3: List of unreachable dependency IDs. */ + __( 'The script with the handle "%1$s" was enqueued with script module dependencies ("%2$s") that are registered with empty scopes and cannot be resolved via bare specifier: %3$s.' ), + $handle, + 'module_dependencies', + implode( wp_get_list_item_separator(), $unreachable_module_dependencies ) + ), + '7.1.0' + ); + } } foreach ( $wp_scripts->registered[ $handle ]->deps as $dep ) { @@ -707,20 +806,85 @@ private function get_import_map(): array { } } - // Note: the script modules in $this->queue are not included in the importmap because they get printed as scripts. + /* + * Walk the dependency graph from queue items + classic-script module dependencies. + * Queue items themselves are not added to `$ids` (they are printed as `