Skip to content

Commit 86da535

Browse files
committed
Script Loader: Add private script modules via scoped import maps.
Introduces a `scopes` arg on `wp_register_script_module()` and `wp_enqueue_script_module()` that constrains which importers can resolve a module's bare specifier. Modules with `scopes` are emitted under the import map's top-level `scopes` field, not `imports`. Each `scopes` entry is one of: - A non-empty URL prefix string, emitted as-authored. - `array( 'module_id' => $id )`, resolved at print time to the directory of the named module's filtered src URL (CDN-aware via the existing `script_module_loader_src` filter). `scopes => array()` registers a module that is not resolvable via bare specifier from anywhere; static dependencies on such a module are treated like missing dependencies. This is API hygiene + bare-specifier scoping per the import map spec — not a security boundary. Files remain fetchable via direct URL `import()`; resolution failure for out-of-scope importers is a runtime `TypeError`.
1 parent bd4e3c9 commit 86da535

3 files changed

Lines changed: 726 additions & 47 deletions

File tree

src/wp-includes/class-wp-script-modules.php

Lines changed: 244 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
* fetchpriority: 'auto'|'low'|'high',
2222
* textdomain?: string,
2323
* translations_path?: string,
24+
* scopes?: array<int, string|array{ module_id: string }>,
2425
* }
2526
*/
2627
class WP_Script_Modules {
@@ -98,36 +99,54 @@ class WP_Script_Modules {
9899
*
99100
* @since 6.5.0
100101
* @since 6.9.0 Added the $args parameter.
101-
*
102-
* @param string $id The identifier of the script module. Should be unique. It will be used in the
103-
* final import map.
104-
* @param string $src Optional. Full URL of the script module, or path of the script module relative
105-
* to the WordPress root directory. If it is provided and the script module has
106-
* not been registered yet, it will be registered.
107-
* @param array<string|array<string, string>> $deps {
108-
* Optional. List of dependencies.
109-
*
110-
* @type string|array<string, string> ...$0 {
111-
* An array of script module identifiers of the dependencies of this script
112-
* module. The dependencies can be strings or arrays. If they are arrays,
113-
* they need an `id` key with the script module identifier, and can contain
114-
* an `import` key with either `static` or `dynamic`. By default,
115-
* dependencies that don't contain an `import` key are considered static.
116-
*
117-
* @type string $id The script module identifier.
118-
* @type string $import Optional. Import type. May be either `static` or
119-
* `dynamic`. Defaults to `static`.
120-
* }
121-
* }
122-
* @param string|false|null $version Optional. String specifying the script module version number. Defaults to false.
123-
* It is added to the URL as a query string for cache busting purposes. If $version
124-
* is set to false, the version number is the currently installed WordPress version.
125-
* If $version is set to null, no version is added.
126-
* @param array<string, string|bool> $args {
102+
* @since 7.1.0 Added the `scopes` key to the $args parameter.
103+
*
104+
* @param string $id The identifier of the script module. Should be unique. It will be used in the
105+
* final import map.
106+
* @param string $src Optional. Full URL of the script module, or path of the script module relative
107+
* to the WordPress root directory. If it is provided and the script module has
108+
* not been registered yet, it will be registered.
109+
* @param array<string|array<string, string>> $deps {
110+
* Optional. List of dependencies.
111+
*
112+
* @type string|array<string, string> ...$0 {
113+
* An array of script module identifiers of the dependencies of this script
114+
* module. The dependencies can be strings or arrays. If they are arrays,
115+
* they need an `id` key with the script module identifier, and can contain
116+
* an `import` key with either `static` or `dynamic`. By default,
117+
* dependencies that don't contain an `import` key are considered static.
118+
*
119+
* @type string $id The script module identifier.
120+
* @type string $import Optional. Import type. May be either `static` or
121+
* `dynamic`. Defaults to `static`.
122+
* }
123+
* }
124+
* @param string|false|null $version Optional. String specifying the script module version number. Defaults to false.
125+
* It is added to the URL as a query string for cache busting purposes. If $version
126+
* is set to false, the version number is the currently installed WordPress version.
127+
* If $version is set to null, no version is added.
128+
* @param array<string, string|bool|array<string|array<string, string>>|null> $args {
127129
* Optional. An array of additional args. Default empty array.
128130
*
129131
* @type bool $in_footer Whether to print the script module in the footer. Only relevant to block themes. Default 'false'. Optional.
130132
* @type 'auto'|'low'|'high' $fetchpriority Fetch priority. Default 'auto'. Optional.
133+
* @type array|null $scopes Optional. Constrains the importers that can resolve this module's bare specifier.
134+
* When omitted or null, the module is public (top-level `imports`). When an array is
135+
* provided, the module is emitted under the import map's `scopes` keyed by each entry,
136+
* and is not present in top-level `imports`. An empty array means the module is
137+
* registered but cannot be resolved via bare specifier from anywhere; static
138+
* dependencies on such a module are treated like missing dependencies.
139+
* Each entry is one of:
140+
* - A non-empty string URL prefix, emitted as-authored. WordPress URL filters such as
141+
* `script_module_loader_src` are NOT applied to string scopes; authors are
142+
* responsible for matching the final browser URL.
143+
* - `array( 'module_id' => string )` — at print time, resolves to the directory
144+
* portion of the named registered module's filtered src URL. Recommended for
145+
* CDN-aware scoping because it goes through the existing `script_module_loader_src`
146+
* filter.
147+
* This is an API-hygiene mechanism, not a security boundary; the file remains
148+
* fetchable, and any caller can still list the module's id in `$deps` or call
149+
* `wp_enqueue_script_module()` on it. Resolution failure is a runtime `TypeError`.
131150
* }
132151
*/
133152
public function register( string $id, string $src, array $deps = array(), $version = false, array $args = array() ) {
@@ -178,13 +197,55 @@ public function register( string $id, string $src, array $deps = array(), $versi
178197
}
179198
}
180199

181-
$this->registered[ $id ] = array(
200+
$registered_module = array(
182201
'src' => $src,
183202
'version' => $version,
184203
'dependencies' => $dependencies,
185204
'in_footer' => $in_footer,
186205
'fetchpriority' => $fetchpriority,
187206
);
207+
208+
if ( array_key_exists( 'scopes', $args ) && null !== $args['scopes'] ) {
209+
if ( ! is_array( $args['scopes'] ) ) {
210+
_doing_it_wrong(
211+
__METHOD__,
212+
__( 'The "scopes" arg must be null or an array.' ),
213+
'7.1.0'
214+
);
215+
} else {
216+
$validated_scopes = array();
217+
foreach ( $args['scopes'] as $scope_entry ) {
218+
if ( is_string( $scope_entry ) ) {
219+
if ( '' === $scope_entry ) {
220+
_doing_it_wrong(
221+
__METHOD__,
222+
__( 'Empty string scope entry; entries must be non-empty.' ),
223+
'7.1.0'
224+
);
225+
continue;
226+
}
227+
$validated_scopes[] = $scope_entry;
228+
} elseif (
229+
is_array( $scope_entry ) &&
230+
1 === count( $scope_entry ) &&
231+
isset( $scope_entry['module_id'] ) &&
232+
is_string( $scope_entry['module_id'] ) &&
233+
'' !== $scope_entry['module_id']
234+
) {
235+
$validated_scopes[] = array( 'module_id' => $scope_entry['module_id'] );
236+
} else {
237+
_doing_it_wrong(
238+
__METHOD__,
239+
__( 'Each scope entry must be a non-empty string or [ "module_id" => non-empty string ].' ),
240+
'7.1.0'
241+
);
242+
}
243+
}
244+
$registered_module['scopes'] = $validated_scopes;
245+
}
246+
}
247+
248+
$this->registered[ $id ] = $registered_module;
188249
}
189250
}
190251

@@ -270,6 +331,7 @@ public function set_in_footer( string $id, bool $in_footer ): bool {
270331
*
271332
* @since 6.5.0
272333
* @since 6.9.0 Added the $args parameter.
334+
* @since 7.1.0 Added the `scopes` key to the $args parameter.
273335
*
274336
* @param string $id The identifier of the script module. Should be unique. It will be used in the
275337
* final import map.
@@ -295,11 +357,12 @@ public function set_in_footer( string $id, bool $in_footer ): bool {
295357
* It is added to the URL as a query string for cache busting purposes. If $version
296358
* is set to false, the version number is the currently installed WordPress version.
297359
* If $version is set to null, no version is added.
298-
* @param array<string, string|bool> $args {
360+
* @param array<string, string|bool|array<string|array<string, string>>|null> $args {
299361
* Optional. An array of additional args. Default empty array.
300362
*
301363
* @type bool $in_footer Whether to print the script module in the footer. Only relevant to block themes. Default 'false'. Optional.
302364
* @type 'auto'|'low'|'high' $fetchpriority Fetch priority. Default 'auto'. Optional.
365+
* @type array|null $scopes Optional. See {@see WP_Script_Modules::register()} for details.
303366
* }
304367
*/
305368
public function enqueue( string $id, string $src = '', array $deps = array(), $version = false, array $args = array() ) {
@@ -619,7 +682,7 @@ public function print_script_module_preloads() {
619682
*/
620683
public function print_import_map() {
621684
$import_map = $this->get_import_map();
622-
if ( ! empty( $import_map['imports'] ) ) {
685+
if ( ! empty( $import_map['imports'] ) || ! empty( $import_map['scopes'] ) ) {
623686
wp_print_inline_script_tag(
624687
(string) wp_json_encode( $import_map, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ),
625688
array(
@@ -635,11 +698,13 @@ public function print_import_map() {
635698
*
636699
* @since 6.5.0
637700
* @since 7.0.0 Script module dependencies ('module_dependencies') of classic scripts are now included.
701+
* @since 7.1.0 Scoped (private) modules are emitted under a top-level `scopes` key.
638702
*
639703
* @global WP_Scripts $wp_scripts
640704
*
641-
* @return array<string, array<string, string>> Array with an `imports` key mapping to an array of script module
642-
* identifiers and their respective URLs, including the version query.
705+
* @return array Array with an `imports` key mapping module identifiers to URLs (including the version query),
706+
* and an optional `scopes` key mapping scope-prefix URLs to per-scope import maps.
707+
* @phpstan-return array{ imports: array<string, string>, scopes?: array<string, array<string, string>> }
643708
*/
644709
private function get_import_map(): array {
645710
global $wp_scripts;
@@ -714,13 +779,34 @@ private function get_import_map(): array {
714779
array_keys( $this->get_dependencies( array_merge( $this->queue, $classic_script_module_dependencies ) ) )
715780
)
716781
);
782+
783+
$scopes = array();
717784
foreach ( $ids as $id ) {
718785
$src = $this->get_src( $id );
719-
if ( '' !== $src ) {
786+
if ( '' === $src ) {
787+
continue;
788+
}
789+
790+
if ( isset( $this->registered[ $id ]['scopes'] ) ) {
791+
// Scoped (private) modules are emitted only under their resolved scope keys.
792+
// Modules with `scopes => array()` resolve to no keys and are intentionally
793+
// absent from both `imports` and `scopes`.
794+
foreach ( $this->get_scope_keys( $id ) as $scope_key ) {
795+
if ( ! isset( $scopes[ $scope_key ] ) ) {
796+
$scopes[ $scope_key ] = array();
797+
}
798+
$scopes[ $scope_key ][ $id ] = $src;
799+
}
800+
} else {
720801
$imports[ $id ] = $src;
721802
}
722803
}
723-
return array( 'imports' => $imports );
804+
805+
$import_map = array( 'imports' => $imports );
806+
if ( ! empty( $scopes ) ) {
807+
$import_map['scopes'] = $scopes;
808+
}
809+
return $import_map;
724810
}
725811

726812
/**
@@ -911,18 +997,49 @@ private function sort_item_dependencies( string $id, array $import_types, array
911997

912998
// If the item requires dependencies that do not exist, fail.
913999
$missing_dependencies = array_diff( $dependency_ids, array_keys( $this->registered ) );
914-
if ( count( $missing_dependencies ) > 0 ) {
1000+
1001+
// Dependencies on modules registered with empty `scopes` (`scopes => array()`)
1002+
// cannot be resolved via bare specifier from anywhere; treat them as missing
1003+
// so the dependent does not emit and a developer warning surfaces early.
1004+
$unreachable_dependencies = array();
1005+
foreach ( $dependency_ids as $dep_id ) {
1006+
if ( in_array( $dep_id, $missing_dependencies, true ) ) {
1007+
continue;
1008+
}
1009+
if (
1010+
isset( $this->registered[ $dep_id ]['scopes'] ) &&
1011+
array() === $this->registered[ $dep_id ]['scopes']
1012+
) {
1013+
$unreachable_dependencies[] = $dep_id;
1014+
}
1015+
}
1016+
1017+
if ( count( $missing_dependencies ) > 0 || count( $unreachable_dependencies ) > 0 ) {
9151018
if ( ! in_array( $id, $this->modules_with_missing_dependencies, true ) ) {
916-
_doing_it_wrong(
917-
get_class( $this ) . '::register',
918-
sprintf(
919-
/* translators: 1: Script module ID, 2: List of missing dependency IDs. */
920-
__( 'The script module with the ID "%1$s" was enqueued with dependencies that are not registered: %2$s.' ),
921-
$id,
922-
implode( wp_get_list_item_separator(), $missing_dependencies )
923-
),
924-
'6.9.1'
925-
);
1019+
if ( count( $missing_dependencies ) > 0 ) {
1020+
_doing_it_wrong(
1021+
get_class( $this ) . '::register',
1022+
sprintf(
1023+
/* translators: 1: Script module ID, 2: List of missing dependency IDs. */
1024+
__( 'The script module with the ID "%1$s" was enqueued with dependencies that are not registered: %2$s.' ),
1025+
$id,
1026+
implode( wp_get_list_item_separator(), $missing_dependencies )
1027+
),
1028+
'6.9.1'
1029+
);
1030+
}
1031+
if ( count( $unreachable_dependencies ) > 0 ) {
1032+
_doing_it_wrong(
1033+
get_class( $this ) . '::register',
1034+
sprintf(
1035+
/* translators: 1: Script module ID, 2: List of unreachable dependency IDs. */
1036+
__( 'The script module with the ID "%1$s" was enqueued with dependencies that are registered with empty scopes and cannot be resolved via bare specifier: %2$s.' ),
1037+
$id,
1038+
implode( wp_get_list_item_separator(), $unreachable_dependencies )
1039+
),
1040+
'7.1.0'
1041+
);
1042+
}
9261043
$this->modules_with_missing_dependencies[] = $id;
9271044
}
9281045

@@ -1000,6 +1117,77 @@ private function get_src( string $id ): string {
10001117
return $src;
10011118
}
10021119

1120+
/**
1121+
* Resolves the import-map scope keys for a registered scoped script module.
1122+
*
1123+
* String entries are returned as-authored. `[ 'module_id' => $other_id ]` entries are
1124+
* resolved to the directory portion of the named module's filtered src URL (query and
1125+
* fragment stripped, path truncated to the last "/"). Lookup failures are dropped with
1126+
* `_doing_it_wrong`.
1127+
*
1128+
* Returns an empty array for unregistered modules, public modules (no `scopes`), or
1129+
* modules whose `scopes` entries all failed to resolve.
1130+
*
1131+
* @since 7.1.0
1132+
*
1133+
* @param string $id The script module identifier.
1134+
* @return string[] Resolved, unique scope keys.
1135+
*/
1136+
private function get_scope_keys( string $id ): array {
1137+
if ( ! isset( $this->registered[ $id ]['scopes'] ) ) {
1138+
return array();
1139+
}
1140+
1141+
$keys = array();
1142+
foreach ( $this->registered[ $id ]['scopes'] as $entry ) {
1143+
if ( is_string( $entry ) ) {
1144+
$keys[ $entry ] = true;
1145+
continue;
1146+
}
1147+
1148+
$other_id = $entry['module_id'];
1149+
if ( ! isset( $this->registered[ $other_id ] ) ) {
1150+
_doing_it_wrong(
1151+
__METHOD__,
1152+
sprintf(
1153+
/* translators: 1: Scope module id; 2: Owning module id. */
1154+
__( 'Scope entry references unregistered module "%1$s" for module "%2$s".' ),
1155+
$other_id,
1156+
$id
1157+
),
1158+
'7.1.0'
1159+
);
1160+
continue;
1161+
}
1162+
1163+
$other_src = $this->get_src( $other_id );
1164+
if ( '' === $other_src ) {
1165+
_doing_it_wrong(
1166+
__METHOD__,
1167+
sprintf(
1168+
/* translators: 1: Scope module id; 2: Owning module id. */
1169+
__( 'Scope entry references module "%1$s" with empty src for module "%2$s".' ),
1170+
$other_id,
1171+
$id
1172+
),
1173+
'7.1.0'
1174+
);
1175+
continue;
1176+
}
1177+
1178+
// Strip query and fragment, truncate to last "/" to derive directory.
1179+
$path = preg_replace( '~[?#].*$~', '', $other_src );
1180+
$last_slash = strrpos( (string) $path, '/' );
1181+
if ( false === $last_slash ) {
1182+
continue;
1183+
}
1184+
1185+
$keys[ substr( (string) $path, 0, $last_slash + 1 ) ] = true;
1186+
}
1187+
1188+
return array_keys( $keys );
1189+
}
1190+
10031191
/**
10041192
* Print data associated with Script Modules.
10051193
*
@@ -1021,12 +1209,23 @@ public function print_script_module_data(): void {
10211209
}
10221210
$modules[ $id ] = true;
10231211
}
1024-
foreach ( array_keys( $this->get_import_map()['imports'] ) as $id ) {
1212+
$import_map = $this->get_import_map();
1213+
foreach ( array_keys( $import_map['imports'] ) as $id ) {
10251214
if ( '@wordpress/a11y' === $id ) {
10261215
$this->a11y_available = true;
10271216
}
10281217
$modules[ $id ] = true;
10291218
}
1219+
if ( isset( $import_map['scopes'] ) ) {
1220+
foreach ( $import_map['scopes'] as $scope_entries ) {
1221+
foreach ( array_keys( $scope_entries ) as $id ) {
1222+
if ( '@wordpress/a11y' === $id ) {
1223+
$this->a11y_available = true;
1224+
}
1225+
$modules[ $id ] = true;
1226+
}
1227+
}
1228+
}
10301229

10311230
foreach ( array_keys( $modules ) as $module_id ) {
10321231
/**

0 commit comments

Comments
 (0)