@10up/wp-vite-plugins 1.0.0-alpha.3 → 1.0.0-alpha.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,803 @@
1
+ <?php
2
+ /**
3
+ * Page class.
4
+ *
5
+ * @package TenUp\WpBoot
6
+ */
7
+
8
+ declare( strict_types = 1 );
9
+
10
+ namespace TenUp\WpBoot;
11
+
12
+ /**
13
+ * One `@wordpress/boot` admin page in one mode.
14
+ *
15
+ * - 'full-page' takes over the screen: it intercepts `admin_init` for
16
+ * `?page=<slug>`, prints its own HTML document and boots with `init()`.
17
+ * - 'wp-admin' renders inside the regular admin chrome on
18
+ * `?page=<slug>-wp-admin` and boots with `initSinglePage()`.
19
+ *
20
+ * Both fire `<handle>_init` (register routes and menu items there) and filter
21
+ * `<handle>_boot_dependencies`, the names `@wordpress/build` uses.
22
+ *
23
+ * Ported from `@wordpress/build`'s page.php.template (full-page) and
24
+ * page-wp-admin.php.template (wp-admin), the original implementation; each
25
+ * method notes the part it comes from. See the README's "Origin".
26
+ *
27
+ * @link https://github.com/WordPress/gutenberg/blob/trunk/packages/wp-build/templates/page.php.template
28
+ * @link https://github.com/WordPress/gutenberg/blob/trunk/packages/wp-build/templates/page-wp-admin.php.template
29
+ *
30
+ * @phpstan-import-type PageConfig from Pages
31
+ */
32
+ final class Page {
33
+
34
+ /** Full-page takeover mode. */
35
+ public const FULL_PAGE = 'full-page';
36
+
37
+ /** wp-admin integrated mode. */
38
+ public const WP_ADMIN = 'wp-admin';
39
+
40
+ /**
41
+ * The site-entity request core-data makes on load, by the first WordPress
42
+ * version that asks for it. Its `_fields` must match the running
43
+ * core-data's exactly, same fields in the same order, or the preloaded
44
+ * response is never used.
45
+ *
46
+ * @var array<string, string>
47
+ */
48
+ private const SITE_PRELOAD_PATHS = array(
49
+ '7.2-alpha' => '/?_fields=description,generate_animated_image_subsizes,gmt_offset,home,image_max_bit_depth,image_sizes,image_size_threshold,image_strip_meta,name,site_icon,site_icon_url,site_logo,timezone_string,url,page_for_posts,page_on_front,show_on_front',
50
+ '7.1-alpha' => '/?_fields=description,gmt_offset,home,image_max_bit_depth,image_sizes,image_size_threshold,image_strip_meta,name,site_icon,site_icon_url,site_logo,timezone_string,url,page_for_posts,page_on_front,show_on_front',
51
+ '7.0-alpha' => '/?_fields=description,gmt_offset,home,image_sizes,image_size_threshold,image_output_formats,jpeg_interlaced,png_interlaced,gif_interlaced,name,site_icon,site_icon_url,site_logo,timezone_string,url,page_for_posts,page_on_front,show_on_front',
52
+ );
53
+
54
+ /**
55
+ * Script handle, Script Module id and hook prefix: `<slug>` or `<slug>-wp-admin`.
56
+ *
57
+ * @var string
58
+ */
59
+ public readonly string $handle;
60
+
61
+ /**
62
+ * Registered routes, handed to `@wordpress/boot`.
63
+ *
64
+ * @var list<array{path: string, content_module?: string, route_module?: string}>
65
+ */
66
+ private array $routes = array();
67
+
68
+ /**
69
+ * Registered menu items, handed to `@wordpress/boot` in full-page mode.
70
+ *
71
+ * @var list<array{id: string, label: string, to: string, parent?: string, parent_type?: string}>
72
+ */
73
+ private array $menu_items = array();
74
+
75
+ /**
76
+ * Create a page.
77
+ *
78
+ * @param Pages $build The build the page belongs to.
79
+ * @param array $config The page, as the build wrote it.
80
+ * @param string $mode Page::FULL_PAGE or Page::WP_ADMIN.
81
+ *
82
+ * @phpstan-param PageConfig $config
83
+ */
84
+ public function __construct(
85
+ private readonly Pages $build,
86
+ private readonly array $config,
87
+ private readonly string $mode,
88
+ ) {
89
+ $this->handle = self::WP_ADMIN === $mode ? $config['slug'] . '-wp-admin' : $config['slug'];
90
+ }
91
+
92
+ /**
93
+ * Hook the page into WordPress.
94
+ *
95
+ * @param bool $owns_menu Whether this page registers the admin menu entry.
96
+ * @return void
97
+ */
98
+ public function register_hooks( bool $owns_menu ): void {
99
+ add_action( $this->handle . '_init', array( $this, 'register_route_modules' ) );
100
+
101
+ if ( self::WP_ADMIN === $this->mode ) {
102
+ add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_scripts' ) );
103
+ } else {
104
+ add_action( 'admin_init', array( $this, 'intercept_render' ) );
105
+ }
106
+
107
+ if ( $owns_menu ) {
108
+ add_action( 'admin_menu', array( $this, 'register_menu' ) );
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Register a route.
114
+ *
115
+ * @param string $path Route path, e.g. '/types/$type/edit/$id'.
116
+ * @param string|null $content_module Script Module id of the route's content (stage/inspector).
117
+ * @param string|null $route_module Script Module id of the route's lifecycle hooks.
118
+ * @return void
119
+ */
120
+ public function register_route( string $path, ?string $content_module = null, ?string $route_module = null ): void {
121
+ $route = array( 'path' => $path );
122
+
123
+ if ( ! empty( $content_module ) ) {
124
+ $route['content_module'] = $content_module;
125
+ }
126
+ if ( ! empty( $route_module ) ) {
127
+ $route['route_module'] = $route_module;
128
+ }
129
+
130
+ $this->routes[] = $route;
131
+ }
132
+
133
+ /**
134
+ * Register a menu item.
135
+ *
136
+ * @param string $id Menu item id.
137
+ * @param string $label Display label.
138
+ * @param string $to Route path to navigate to.
139
+ * @param string $parent_id Parent menu item id.
140
+ * @param string $parent_type 'drilldown' or 'dropdown'. Full-page mode only.
141
+ * @return void
142
+ */
143
+ public function register_menu_item( string $id, string $label, string $to, string $parent_id = '', string $parent_type = '' ): void {
144
+ $menu_item = array(
145
+ 'id' => $id,
146
+ 'label' => $label,
147
+ 'to' => $to,
148
+ );
149
+
150
+ if ( ! empty( $parent_id ) ) {
151
+ $menu_item['parent'] = $parent_id;
152
+ }
153
+
154
+ if ( self::FULL_PAGE === $this->mode && in_array( $parent_type, array( 'drilldown', 'dropdown' ), true ) ) {
155
+ $menu_item['parent_type'] = $parent_type;
156
+ }
157
+
158
+ $this->menu_items[] = $menu_item;
159
+ }
160
+
161
+ /**
162
+ * The registered routes.
163
+ *
164
+ * @return list<array{path: string, content_module?: string, route_module?: string}>
165
+ */
166
+ public function routes(): array {
167
+ return $this->routes;
168
+ }
169
+
170
+ /**
171
+ * The registered menu items.
172
+ *
173
+ * @return list<array{id: string, label: string, to: string, parent?: string, parent_type?: string}>
174
+ */
175
+ public function menu_items(): array {
176
+ return $this->menu_items;
177
+ }
178
+
179
+ /**
180
+ * Register the Script Modules (and stylesheet) of every route the build
181
+ * produced, then register the routes with the page. Runs on
182
+ * `<handle>_init`.
183
+ *
184
+ * From routes-registration.php.template's `<prefix>_register_page_routes()`; the stylesheet is ours.
185
+ *
186
+ * Route modules are `<handle_prefix>/routes/<slug>/<route>/{content,route}`.
187
+ * Each is deregistered first, so a plugin can override a module core
188
+ * registered under the same id.
189
+ *
190
+ * @return void
191
+ */
192
+ public function register_route_modules(): void {
193
+ foreach ( $this->config['routes'] as $route ) {
194
+ $content_module = null;
195
+ $route_module = null;
196
+ $module_prefix = $this->build->handle_prefix . '/routes/' . $this->config['slug'] . '/' . $route['name'];
197
+
198
+ if ( $route['has_content'] ) {
199
+ $asset = $this->asset( $route['dir'] . '/content.asset.php' );
200
+
201
+ if ( null !== $asset ) {
202
+ $content_module = $module_prefix . '/content';
203
+ $this->register_module( $content_module, $route['dir'] . '/content.js', $asset );
204
+
205
+ // The page stylesheet, when the entry imported any CSS.
206
+ if ( file_exists( $this->build->path( $route['dir'] . '/content.css' ) ) ) {
207
+ $style_handle = str_replace( '/', '-', $content_module );
208
+ wp_register_style( $style_handle, $this->build->url( $route['dir'] . '/content.css' ), array(), $asset['version'] ?? false );
209
+ wp_enqueue_style( $style_handle );
210
+ }
211
+ }
212
+ }
213
+
214
+ if ( $route['has_route'] ) {
215
+ $asset = $this->asset( $route['dir'] . '/route.asset.php' );
216
+
217
+ if ( null !== $asset ) {
218
+ $route_module = $module_prefix . '/route';
219
+ $this->register_module( $route_module, $route['dir'] . '/route.js', $asset );
220
+ }
221
+ }
222
+
223
+ $this->register_route( $route['path'], $content_module, $route_module );
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Preload the page's REST paths into `wp.apiFetch`: the page's
229
+ * `restPreload`, else what core's own pages preload.
230
+ *
231
+ * From both templates' `<prefix>_<slug>_preload_data()`; the filter is page-wp-admin.php.template's, here in both modes.
232
+ *
233
+ * @return void
234
+ */
235
+ public function preload_data(): void {
236
+ $paths = $this->config['rest_preload'] ?? array( self::site_preload_path(), array( '/wp/v2/settings', 'OPTIONS' ) );
237
+
238
+ /**
239
+ * Filters the REST API paths preloaded for this page, the name
240
+ * `@wordpress/build` uses: `<prefix>_<slug>[_wp_admin]_preload_paths`.
241
+ *
242
+ * Each entry is a path (GET) or a `[ path, method ]` pair.
243
+ *
244
+ * @param array $paths Paths to preload.
245
+ */
246
+ $paths = apply_filters( str_replace( '-', '_', $this->build->prefix . '_' . $this->handle ) . '_preload_paths', $paths );
247
+
248
+ $preload_data = array();
249
+ foreach ( $paths as $path ) {
250
+ // Core also takes array( $path, $method ), though its docblock says string.
251
+ $preload_data = rest_preload_api_request( $preload_data, $path );
252
+ }
253
+
254
+ wp_add_inline_script(
255
+ 'wp-api-fetch',
256
+ sprintf(
257
+ 'wp.apiFetch.use( wp.apiFetch.createPreloadingMiddleware( %s ) );',
258
+ wp_json_encode( $preload_data )
259
+ ),
260
+ 'after'
261
+ );
262
+ }
263
+
264
+ /**
265
+ * The site-entity preload path for the running WordPress version.
266
+ *
267
+ * From the `_fields` path both templates hardcode, one per WordPress version.
268
+ *
269
+ * With the Gutenberg plugin active, core-data comes from the plugin and
270
+ * may ask for other fields; pages can set `restPreload` for that.
271
+ *
272
+ * @return string
273
+ */
274
+ public static function site_preload_path(): string {
275
+ $version = get_bloginfo( 'version' );
276
+
277
+ foreach ( self::SITE_PRELOAD_PATHS as $since => $path ) {
278
+ if ( version_compare( $version, $since, '>=' ) ) {
279
+ return $path;
280
+ }
281
+ }
282
+
283
+ return self::SITE_PRELOAD_PATHS['7.0-alpha'];
284
+ }
285
+
286
+ /**
287
+ * Boot a wp-admin page on its own screen. Runs on `admin_enqueue_scripts`.
288
+ *
289
+ * From page-wp-admin.php.template's `<prefix>_<slug>_enqueue_scripts()`.
290
+ *
291
+ * @return void
292
+ */
293
+ public function enqueue_scripts(): void {
294
+ $screen = get_current_screen();
295
+
296
+ $is_our_page = (
297
+ ( isset( $_GET['page'] ) && $this->handle === $_GET['page'] ) || // phpcs:ignore WordPress.Security.NonceVerification.Recommended
298
+ ( $screen && $this->config['slug'] === $screen->id )
299
+ );
300
+
301
+ if ( $is_our_page ) {
302
+ $this->enqueue_boot();
303
+ }
304
+ }
305
+
306
+ /**
307
+ * Render a full-page page early, before WordPress prints its own admin
308
+ * template. Runs on `admin_init`.
309
+ *
310
+ * From page.php.template's `<prefix>_<slug>_intercept_render()`.
311
+ *
312
+ * This bypasses the menu callback's own checks, and `admin_init` also
313
+ * fires for logged-out requests (admin-post.php), so it checks login and
314
+ * the page's capability itself.
315
+ *
316
+ * @return void
317
+ */
318
+ public function intercept_render(): void {
319
+ if ( ! isset( $_GET['page'] ) || $this->handle !== $_GET['page'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
320
+ return;
321
+ }
322
+
323
+ if ( ! is_user_logged_in() ) {
324
+ auth_redirect();
325
+ }
326
+
327
+ if ( ! current_user_can( $this->config['capability'] ) ) {
328
+ // Core's own string, so core's translation applies.
329
+ wp_die( esc_html__( 'Sorry, you are not allowed to access this page.' ), 403 ); // phpcs:ignore WordPress.WP.I18n.MissingArgDomain
330
+ }
331
+
332
+ $this->render_page();
333
+ }
334
+
335
+ /**
336
+ * The page's render callback: what the admin menu entry points at.
337
+ *
338
+ * Full-page mode prints a whole HTML document and exits.
339
+ *
340
+ * @return void
341
+ */
342
+ public function render_page(): void {
343
+ if ( self::WP_ADMIN === $this->mode ) {
344
+ $this->render_wp_admin();
345
+ return;
346
+ }
347
+
348
+ $this->render_document();
349
+ exit;
350
+ }
351
+
352
+ /**
353
+ * Register the page's admin menu entry. Runs on `admin_menu`.
354
+ *
355
+ * No `parent` (or 'options-general.php') adds it under Settings, `false`
356
+ * or 'toplevel' adds a top-level entry, anything else is a submenu.
357
+ *
358
+ * @return void
359
+ */
360
+ public function register_menu(): void {
361
+ if ( ! isset( $this->config['menu'] ) ) {
362
+ return;
363
+ }
364
+
365
+ $menu = $this->config['menu'];
366
+ $title = ( $menu['title'] )();
367
+ $capability = $menu['capability'] ?? $this->config['capability'];
368
+ $parent = $menu['parent'] ?? 'options-general.php';
369
+ $callback = array( $this, 'render_page' );
370
+
371
+ if ( false === $parent || 'toplevel' === $parent ) {
372
+ add_menu_page( $title, $title, $capability, $this->handle, $callback, $menu['icon'] ?? 'dashicons-admin-generic', $menu['position'] ?? null );
373
+ } elseif ( 'options-general.php' === $parent ) {
374
+ add_options_page( $title, $title, $capability, $this->handle, $callback );
375
+ } else {
376
+ add_submenu_page( $parent, $title, $title, $capability, $this->handle, $callback );
377
+ }
378
+ }
379
+
380
+ /**
381
+ * Print the full-page document: WordPress's admin head and footer
382
+ * printing around an empty mount point.
383
+ *
384
+ * From page.php.template's `<prefix>_<slug>_render_page()`.
385
+ *
386
+ * @return void
387
+ */
388
+ public function render_document(): void {
389
+ set_current_screen();
390
+
391
+ remove_action( 'admin_head', 'wp_admin_bar_header' );
392
+
393
+ // Drop whatever `admin_init` enqueued.
394
+ foreach ( wp_scripts()->queue as $script ) {
395
+ wp_dequeue_script( $script );
396
+ }
397
+ foreach ( wp_styles()->queue as $style ) {
398
+ wp_dequeue_style( $style );
399
+ }
400
+
401
+ $this->enqueue_boot();
402
+
403
+ ?>
404
+ <!DOCTYPE html>
405
+ <html <?php language_attributes(); ?>>
406
+ <head>
407
+ <meta charset="<?php bloginfo( 'charset' ); ?>">
408
+ <meta name="viewport" content="width=device-width, initial-scale=1">
409
+ <title><?php echo esc_html( get_admin_page_title() ); ?></title>
410
+ <style>
411
+ html {
412
+ background: #f1f1f1;
413
+ color: #444;
414
+ font-family: -apple-system, system-ui, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
415
+ font-size: 13px;
416
+ line-height: 1.4em;
417
+ }
418
+ body {
419
+ margin: 0;
420
+ }
421
+ #wpadminbar { display: none; }
422
+ /* wp-admin's common.css, which this document doesn't load, has this rule. */
423
+ .js .hide-if-js { display: none; }
424
+ </style>
425
+ <?php
426
+ global $hook_suffix;
427
+ // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
428
+ $hook_suffix = $this->handle;
429
+
430
+ // BEGIN see wp-admin/admin-header.php.
431
+ print_admin_styles();
432
+ print_head_scripts();
433
+
434
+ /** This action is documented in wp-admin/admin-header.php */
435
+ do_action( "admin_head-{$hook_suffix}" ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores
436
+
437
+ /** This action is documented in wp-admin/admin-header.php */
438
+ do_action( 'admin_head' );
439
+ // END see wp-admin/admin-header.php.
440
+ ?>
441
+ </head>
442
+ <body class="<?php echo esc_attr( $this->handle ); ?> no-js">
443
+ <?php // See wp-admin/admin-header.php. ?>
444
+ <script type="text/javascript">
445
+ document.body.className = document.body.className.replace( 'no-js', 'js' );
446
+ </script>
447
+ <?php $this->render_no_js_notice( 'margin: 20px;' ); ?>
448
+ <div id="<?php echo esc_attr( $this->mount_id() ); ?>" style="height: 100vh; box-sizing: border-box;"></div>
449
+ <?php
450
+ // BEGIN see wp-admin/admin-footer.php.
451
+
452
+ /** This action is documented in wp-admin/admin-footer.php */
453
+ do_action( 'admin_footer', $hook_suffix );
454
+
455
+ // Print the import map first so it's available to inline scripts.
456
+ wp_script_modules()->print_import_map();
457
+ print_footer_scripts();
458
+ wp_script_modules()->print_enqueued_script_modules();
459
+ wp_script_modules()->print_script_module_preloads();
460
+ wp_script_modules()->print_script_module_data();
461
+
462
+ /** This action is documented in wp-admin/admin-footer.php */
463
+ do_action( "admin_footer-{$hook_suffix}" ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores
464
+ // END see wp-admin/admin-footer.php.
465
+ ?>
466
+ </body>
467
+ </html>
468
+ <?php
469
+ }
470
+
471
+ /**
472
+ * Print the wp-admin mount point, with the critical styles that keep the
473
+ * layout from shifting.
474
+ *
475
+ * From page-wp-admin.php.template's `<prefix>_<slug>_render_page()`.
476
+ *
477
+ * @return void
478
+ */
479
+ private function render_wp_admin(): void {
480
+ ?>
481
+ <style>
482
+ /* Critical styles to prevent layout shifts - inlined for immediate application */
483
+
484
+ #wpwrap {
485
+ overflow-y: auto;
486
+ }
487
+ body.js {
488
+ background: #fff;
489
+ }
490
+
491
+ /* Reset wp-admin padding */
492
+ body.js #wpcontent {
493
+ padding-inline-start: 0;
494
+ }
495
+ body.js #wpbody-content {
496
+ padding-bottom: 0;
497
+ }
498
+
499
+ /* Hide legacy admin elements */
500
+ body.js #wpbody-content > div:not(#<?php echo esc_attr( $this->mount_id() ); ?>):not(#screen-meta) {
501
+ display: none;
502
+ }
503
+ body.js #wpfooter {
504
+ display: none;
505
+ }
506
+
507
+ /* Accessibility regions */
508
+ .a11y-speak-region {
509
+ inset-inline-start: -1px;
510
+ top: -1px;
511
+ }
512
+
513
+ /* Admin menu indicators */
514
+ ul#adminmenu a.wp-has-current-submenu::after,
515
+ ul#adminmenu > li.current > a.current::after {
516
+ border-inline-end-color: #fff;
517
+ }
518
+
519
+ /* Media frame fix */
520
+ .media-frame select.attachment-filters:last-of-type {
521
+ width: auto;
522
+ max-width: 100%;
523
+ }
524
+
525
+ /* Responsive overflow fix for #wpwrap */
526
+ @media (min-width: 782px) {
527
+ #wpwrap {
528
+ overflow-y: initial;
529
+ }
530
+ }
531
+ </style>
532
+ <?php
533
+ $this->render_no_js_notice();
534
+ // Boot's layout before its CSS Modules (Gutenberg #81756) sizes the
535
+ // viewport by this class; drop it once WordPress 7.2 is the minimum.
536
+ ?>
537
+ <div id="<?php echo esc_attr( $this->mount_id() ); ?>" class="boot-layout-container"></div>
538
+ <?php
539
+ }
540
+
541
+ /**
542
+ * Print what the page shows without JavaScript: its title and an error
543
+ * notice. `hide-if-js` hides it once the page boots.
544
+ *
545
+ * From the `wrap hide-if-js` block of both templates.
546
+ *
547
+ * @param string $style Inline style for the wrapper.
548
+ * @return void
549
+ */
550
+ private function render_no_js_notice( string $style = '' ): void {
551
+ ?>
552
+ <div class="wrap hide-if-js"<?php echo $style ? ' style="' . esc_attr( $style ) . '"' : ''; ?>>
553
+ <h1 class="wp-heading-inline"><?php echo esc_html( get_admin_page_title() ); ?></h1>
554
+ <?php
555
+ wp_admin_notice(
556
+ // Core's own string, so core's translation applies.
557
+ esc_html__( 'This screen requires JavaScript. Enable JavaScript in your browser settings and reload the page.' ), // phpcs:ignore WordPress.WP.I18n.MissingArgDomain
558
+ array( 'type' => 'error' )
559
+ );
560
+ ?>
561
+ </div>
562
+ <?php
563
+ }
564
+
565
+ /**
566
+ * Fire `<handle>_init`, then enqueue everything that boots the page.
567
+ *
568
+ * From the enqueue half of both templates' render/enqueue functions.
569
+ *
570
+ * The prerequisites carrier is an empty-src classic script depending on
571
+ * every classic handle the page's modules read off `window.*` (from the
572
+ * routes' `.asset.php` sidecars and core's boot module), so those exist by
573
+ * the time the modules run. It also hosts the inline bootstrap. The boot
574
+ * graph hangs off the `loader.js` anchor module. Nothing is enqueued when
575
+ * the build left no sidecars.
576
+ *
577
+ * @return void
578
+ */
579
+ private function enqueue_boot(): void {
580
+ /**
581
+ * Fires when the page initializes, so extensions can register routes
582
+ * and menu items.
583
+ */
584
+ do_action( $this->handle . '_init' );
585
+
586
+ if ( self::FULL_PAGE === $this->mode && function_exists( 'wp_enqueue_command_palette_assets' ) ) {
587
+ wp_enqueue_command_palette_assets();
588
+ }
589
+
590
+ $this->preload_data();
591
+
592
+ $script_dependencies = array();
593
+ $version = false;
594
+ $has_assets = false;
595
+
596
+ foreach ( $this->asset_files() as $asset_file ) {
597
+ $asset = $this->asset( $asset_file );
598
+
599
+ if ( null === $asset ) {
600
+ continue;
601
+ }
602
+
603
+ $has_assets = true;
604
+ $script_dependencies = array_merge( $script_dependencies, $asset['dependencies'] ?? array() );
605
+
606
+ if ( false === $version && ! empty( $asset['version'] ) ) {
607
+ $version = $asset['version'];
608
+ }
609
+ }
610
+
611
+ if ( ! $has_assets ) {
612
+ return;
613
+ }
614
+
615
+ // Core's boot module carries classic dependencies of its own.
616
+ $boot_asset_file = ABSPATH . WPINC . '/js/dist/script-modules/boot/index.min.asset.php';
617
+ if ( file_exists( $boot_asset_file ) ) {
618
+ $boot_asset = require $boot_asset_file;
619
+ $script_dependencies = array_merge( $script_dependencies, $boot_asset['dependencies'] ?? array() );
620
+ }
621
+
622
+ $script_dependencies = array_values( array_unique( $script_dependencies ) );
623
+ $carrier = $this->handle . '-prerequisites';
624
+
625
+ // A `false` src, not '': an empty-src carrier script.
626
+ wp_register_script( $carrier, false, $script_dependencies, $version, true );
627
+ wp_add_inline_script( $carrier, $this->bootstrap_script() );
628
+
629
+ wp_register_style(
630
+ $carrier,
631
+ false,
632
+ array_values( array_filter( $script_dependencies, static fn ( string $handle ): bool => wp_style_is( $handle, 'registered' ) ) ),
633
+ $version
634
+ );
635
+
636
+ // Dummy Script Module whose dependencies are the page's boot graph.
637
+ wp_register_script_module( $this->handle, $this->build->url( 'modules/' . $this->config['slug'] . '/loader.js' ), $this->boot_dependencies() );
638
+
639
+ wp_enqueue_script( $carrier );
640
+ wp_enqueue_script_module( $this->handle );
641
+ wp_enqueue_style( $carrier );
642
+ }
643
+
644
+ /**
645
+ * The inline script that boots the app.
646
+ *
647
+ * From the `wp_add_inline_script()` calls of both templates.
648
+ *
649
+ * Full-page mode calls `init()` right away. wp-admin mode defers the
650
+ * `initSinglePage()` import to DOMContentLoaded, so every classic
651
+ * dependency of `@wordpress/boot` has run first; a modulepreloaded boot
652
+ * module can otherwise evaluate before `wp.theme.privateApis` exists and
653
+ * throw "Cannot unlock an undefined object" (core #65103).
654
+ *
655
+ * @return string
656
+ */
657
+ private function bootstrap_script(): string {
658
+ $json_flags = JSON_HEX_TAG | JSON_UNESCAPED_SLASHES;
659
+
660
+ if ( self::FULL_PAGE === $this->mode ) {
661
+ return sprintf(
662
+ 'import("@wordpress/boot").then(mod => mod.init({mountId: "%s", menuItems: %s, routes: %s, initModules: %s, dashboardLink: "%s"}));',
663
+ $this->mount_id(),
664
+ wp_json_encode( $this->menu_items, $json_flags ),
665
+ wp_json_encode( $this->routes, $json_flags ),
666
+ wp_json_encode( $this->config['init_modules'], $json_flags ),
667
+ esc_url( admin_url( '/' ) )
668
+ );
669
+ }
670
+
671
+ $init = <<<'JS'
672
+ ( mountId, routes, initModules ) => {
673
+ const run = async () => {
674
+ const mod = await import( "@wordpress/boot" );
675
+ mod.initSinglePage( { mountId, routes, initModules } );
676
+ };
677
+ if ( document.readyState === "loading" ) {
678
+ document.addEventListener( "DOMContentLoaded", run );
679
+ } else {
680
+ run();
681
+ }
682
+ }
683
+ JS;
684
+
685
+ return sprintf(
686
+ '( %s )( %s, %s, %s );',
687
+ $init,
688
+ wp_json_encode( $this->mount_id(), $json_flags ),
689
+ wp_json_encode( $this->routes, $json_flags ),
690
+ wp_json_encode( $this->config['init_modules'], $json_flags )
691
+ );
692
+ }
693
+
694
+ /**
695
+ * The page's Script Module graph: `@wordpress/boot` and the init modules
696
+ * statically, each route's `route` module statically and its `content`
697
+ * module dynamically.
698
+ *
699
+ * From both templates' `$boot_dependencies`.
700
+ *
701
+ * @return array<int, array{import: string, id: string}>
702
+ */
703
+ private function boot_dependencies(): array {
704
+ $dependencies = array(
705
+ array(
706
+ 'import' => 'static',
707
+ 'id' => '@wordpress/boot',
708
+ ),
709
+ );
710
+
711
+ foreach ( $this->config['init_modules'] as $module ) {
712
+ $dependencies[] = array(
713
+ 'import' => 'static',
714
+ 'id' => $module,
715
+ );
716
+ }
717
+
718
+ foreach ( $this->routes as $route ) {
719
+ if ( isset( $route['route_module'] ) ) {
720
+ $dependencies[] = array(
721
+ 'import' => 'static',
722
+ 'id' => $route['route_module'],
723
+ );
724
+ }
725
+ if ( isset( $route['content_module'] ) ) {
726
+ $dependencies[] = array(
727
+ 'import' => 'dynamic',
728
+ 'id' => $route['content_module'],
729
+ );
730
+ }
731
+ }
732
+
733
+ /**
734
+ * Filters the page's boot Script Module dependencies.
735
+ *
736
+ * Append entries with 'import' ('static' or 'dynamic') and 'id' (Script
737
+ * Module id) keys to load more modules with the page.
738
+ *
739
+ * @param array $dependencies Boot dependencies for the page.
740
+ */
741
+ return apply_filters( $this->handle . '_boot_dependencies', $dependencies );
742
+ }
743
+
744
+ /**
745
+ * Build-relative paths of every route's `.asset.php` sidecars.
746
+ *
747
+ * @return list<string>
748
+ */
749
+ private function asset_files(): array {
750
+ $files = array();
751
+
752
+ foreach ( $this->config['routes'] as $route ) {
753
+ if ( $route['has_content'] ) {
754
+ $files[] = $route['dir'] . '/content.asset.php';
755
+ }
756
+ if ( $route['has_route'] ) {
757
+ $files[] = $route['dir'] . '/route.asset.php';
758
+ }
759
+ }
760
+
761
+ return $files;
762
+ }
763
+
764
+ /**
765
+ * Read a `.asset.php` sidecar.
766
+ *
767
+ * @param string $file Build-relative path.
768
+ * @return array<string, mixed>|null Null when the build didn't write it.
769
+ */
770
+ private function asset( string $file ): ?array {
771
+ $path = $this->build->path( $file );
772
+
773
+ if ( ! file_exists( $path ) ) {
774
+ return null;
775
+ }
776
+
777
+ $asset = require $path;
778
+
779
+ return is_array( $asset ) ? $asset : null;
780
+ }
781
+
782
+ /**
783
+ * Register one route Script Module from its sidecar.
784
+ *
785
+ * @param string $id Script Module id.
786
+ * @param string $file Build-relative module path.
787
+ * @param array<string, mixed> $asset The module's `.asset.php` sidecar.
788
+ * @return void
789
+ */
790
+ private function register_module( string $id, string $file, array $asset ): void {
791
+ wp_deregister_script_module( $id );
792
+ wp_register_script_module( $id, $this->build->url( $file ), $asset['module_dependencies'] ?? array(), $asset['version'] ?? false );
793
+ }
794
+
795
+ /**
796
+ * The id of the element the app mounts into.
797
+ *
798
+ * @return string
799
+ */
800
+ private function mount_id(): string {
801
+ return $this->handle . '-app';
802
+ }
803
+ }