2121 * fetchpriority: 'auto'|'low'|'high',
2222 * textdomain?: string,
2323 * translations_path?: string,
24+ * scopes?: array<int, string|array{ module_id: string }>,
2425 * }
2526 */
2627class 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