Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.06% covered (success)
91.06%
214 / 235
75.00% covered (warning)
75.00%
18 / 24
CRAP
0.00% covered (danger)
0.00%
0 / 1
Automattic\Jetpack\Admin_UI\admin_shell_collect_route
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
Admin_Shell
91.45% covered (success)
91.45%
214 / 234
78.26% covered (warning)
78.26%
18 / 23
111.88
0.00% covered (danger)
0.00%
0 / 1
 register_feature_flag
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 init
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 is_enabled
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 get_members
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 get_host
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 resolve_host
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
9
 screen_id
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setup
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
7
 enqueue_client
21.43% covered (danger)
21.43%
3 / 14
0.00% covered (danger)
0.00%
0 / 1
3.94
 load_guest
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 collect_route
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
4.03
 add_guest_dependencies
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 get_client_config
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
2
 admin_title
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
6.40
 ajax_guest_assets
16.67% covered (danger)
16.67%
1 / 6
0.00% covered (danger)
0.00%
0 / 1
13.26
 guest_assets
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
7
 guard_api_fetch_setup
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
6.10
 printed_handles
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 hook_snapshot
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 added_callbacks
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
6
 remove_added_callbacks
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 normalize_member
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
18
 is_string_list
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2/**
3 * Admin shell: switch between Jetpack wp-build dashboards without a page load.
4 *
5 * @package automattic/jetpack-admin-ui
6 */
7
8namespace Automattic\Jetpack\Admin_UI;
9
10use Automattic\Jetpack\Feature_Flags\Feature_Flags;
11
12/**
13 * Decides whether the admin shell is on, and which dashboards have joined it.
14 */
15class Admin_Shell {
16
17    /**
18     * Feature flag that turns the shell on. Off by default.
19     */
20    const FLAG = 'jetpack-admin-shell';
21
22    /**
23     * Query argument that turns the shell off for one request when set to `0`.
24     */
25    const DISABLE_QUERY_ARG = 'jetpack_admin_shell';
26
27    /**
28     * Member keys that must be non-empty strings.
29     */
30    const REQUIRED_STRING_KEYS = array( 'build_prefix', 'build_page', 'title' );
31
32    /**
33     * The admin-ajax action that returns a guest's page data.
34     */
35    const GUEST_ACTION = 'jetpack_admin_shell_guest';
36
37    /**
38     * Script module ID of the client.
39     */
40    const CLIENT_MODULE = '@automattic/jetpack-admin-shell';
41
42    /**
43     * Hooks where guests add their page data. On a host page, callbacks a guest adds here are removed: the guest endpoint runs them later.
44     */
45    const DEFERRED_HOOKS = array( 'admin_enqueue_scripts', 'admin_print_scripts', 'admin_print_footer_scripts', 'jetpack_admin_js_script_data' );
46
47    /**
48     * The script and style handles wp_enqueue_media() enqueues, printed for members that set `media`.
49     */
50    const MEDIA_HANDLES = array( 'media-editor', 'media-audiovideo', 'mce-view', 'image-edit', 'media-views', 'imgareaselect' );
51
52    /**
53     * Whether init() has run.
54     *
55     * @var bool
56     */
57    private static $initialized = false;
58
59    /**
60     * Routes of the guest being loaded, or null outside load_guest().
61     *
62     * @var array[]|null
63     */
64    private static $collected = null;
65
66    /**
67     * Register the feature flag.
68     *
69     * @return void
70     */
71    public static function register_feature_flag() {
72        Feature_Flags::register(
73            self::FLAG,
74            array(
75                'default'     => false,
76                'description' => 'Switch between Jetpack dashboards in wp-admin without a page load.',
77                'owner'       => 'jetpack-admin-ui',
78            )
79        );
80    }
81
82    /**
83     * Hook the shell up. Each hook checks is_enabled() itself, so this is safe to call on every request.
84     *
85     * @return void
86     */
87    public static function init() {
88        if ( self::$initialized ) {
89            return;
90        }
91        self::$initialized = true;
92
93        // Priority 0: guests must register their modules before the host page builds its import map.
94        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'setup' ), 0 );
95        add_action( 'wp_ajax_' . self::GUEST_ACTION, array( __CLASS__, 'ajax_guest_assets' ) );
96    }
97
98    /**
99     * Whether the shell is on for this request.
100     *
101     * @return bool
102     */
103    public static function is_enabled() {
104        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only opt-out; it can only turn the shell off.
105        if ( isset( $_GET[ self::DISABLE_QUERY_ARG ] ) && '0' === $_GET[ self::DISABLE_QUERY_ARG ] ) {
106            return false;
107        }
108
109        /**
110         * Filters whether Jetpack dashboards share one wp-admin page.
111         *
112         * @since $$next-version$$
113         *
114         * @param bool $enabled Whether the shell is on. Defaults to the `jetpack-admin-shell` feature flag.
115         */
116        return (bool) apply_filters( 'jetpack_admin_shell_enabled', Feature_Flags::is_enabled( self::FLAG ) );
117    }
118
119    /**
120     * Member dashboards, keyed by admin page slug. Entries that don't match the contract are dropped.
121     *
122     * @return array<string, array{build_prefix: string, build_page: string, init_modules: string[], title: string, default_path: string|null, load: callable, capability: string, handles: string[], media: bool}>
123     */
124    public static function get_members() {
125        /**
126         * Filters the dashboards that share the admin shell.
127         *
128         * Each entry is keyed by its admin page slug and holds:
129         * - build_prefix (string)   The wp-build prefix of the generated page functions.
130         * - build_page (string)     The wp-build page name.
131         * - init_modules (string[]) Script modules to import before the app boots.
132         * - title (string)          Document title while the page is shown.
133         * - default_path (string)   Optional. The `p` a bare menu link opens.
134         * - load (callable)         Registers the page's scripts and prints its initial data. Must be safe to run twice, and must not redirect, print or write options.
135         * - capability (string)     Optional, default `manage_options`. Required to load the page into the shell.
136         * - handles (string[])      Optional. Script and style handles the guest endpoint prints, with their dependencies; nothing else is enqueued.
137         * - media (bool)            Optional, default false. Whether the page opens the media library: the endpoint then prints it and its templates too.
138         *
139         * @since $$next-version$$
140         *
141         * @param array $members Member definitions keyed by admin page slug.
142         */
143        $members = apply_filters( 'jetpack_admin_shell_members', array() );
144        if ( ! is_array( $members ) ) {
145            return array();
146        }
147
148        $valid = array();
149        foreach ( $members as $slug => $member ) {
150            $normalized = self::normalize_member( $slug, $member );
151            if ( null === $normalized ) {
152                $message = sprintf( 'Admin shell member "%s" is missing a required key or has one of the wrong type, so it was skipped.', $slug );
153                // Keep on one line: the release tooling only replaces the version in a single-line call.
154                _doing_it_wrong( __METHOD__, esc_html( $message ), '$$next-version$$' );
155                continue;
156            }
157            $valid[ $slug ] = $normalized;
158        }
159
160        return $valid;
161    }
162
163    /**
164     * The member page this request is for, when the shell runs on it.
165     *
166     * @return string|null
167     */
168    public static function get_host() {
169        return self::resolve_host()[0];
170    }
171
172    /**
173     * The host page and the members, so callers that need both run the members filter once.
174     *
175     * @return array{0: string|null, 1: array} Host slug, or null when the shell doesn't run here; members.
176     */
177    private static function resolve_host() {
178        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Only picks which page this is.
179        $page = isset( $_GET['page'] ) && is_string( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : '';
180        // Members are Jetpack submenu pages; checking the screen first keeps the members filter off other admin pages.
181        $on_jetpack_screen = isset( $GLOBALS['hook_suffix'] ) && self::screen_id( $page ) === $GLOBALS['hook_suffix'];
182        if ( '' === $page || ! $on_jetpack_screen || ! self::is_enabled() ) {
183            return array( null, array() );
184        }
185
186        $members = self::get_members();
187        if ( count( $members ) < 2 || ! isset( $members[ $page ] ) ) {
188            return array( null, $members );
189        }
190        return array( $page, $members );
191    }
192
193    /**
194     * The screen ID, hook suffix and body class of a member page.
195     *
196     * @param string $slug Admin page slug.
197     * @return string
198     */
199    public static function screen_id( $slug ) {
200        return 'jetpack_page_' . $slug;
201    }
202
203    /**
204     * On a member page, ready the other members and print the client.
205     *
206     * @return void
207     */
208    public static function setup() {
209        list( $host, $members ) = self::resolve_host();
210        if ( null === $host ) {
211            return;
212        }
213
214        $guests = array();
215        foreach ( $members as $slug => $member ) {
216            if ( $slug === $host || ! current_user_can( $member['capability'] ) ) {
217                continue;
218            }
219            $routes = self::load_guest( $member );
220            if ( null !== $routes ) {
221                $guests[ $slug ] = $routes;
222            }
223        }
224        if ( ! $guests ) {
225            return;
226        }
227
228        add_filter(
229            $members[ $host ]['build_page'] . '-wp-admin_boot_dependencies',
230            static function ( $deps ) use ( $members, $guests ) {
231                return self::add_guest_dependencies( $deps, $members, $guests );
232            }
233        );
234
235        $config = self::get_client_config( $host, $members, $guests );
236        add_filter(
237            'script_module_data_' . self::CLIENT_MODULE,
238            static function () use ( $config ) {
239                return $config;
240            }
241        );
242        self::enqueue_client();
243    }
244
245    /**
246     * Enqueue the client: one script module, built from `src/admin-shell/` by webpack.
247     *
248     * @return void
249     */
250    private static function enqueue_client() {
251        $file = dirname( __DIR__ ) . '/build/admin-shell.js';
252        if ( ! is_readable( $file ) ) {
253            return;
254        }
255        // A hash of the file: CDNs cache script URLs by their version for a long time.
256        wp_enqueue_script_module(
257            self::CLIENT_MODULE,
258            plugins_url( 'build/admin-shell.js', __DIR__ ),
259            // Announces switches. Core prints the live regions it speaks into for its dependents.
260            array(
261                array(
262                    'id'     => '@wordpress/a11y',
263                    'import' => 'dynamic',
264                ),
265            ),
266            substr( (string) md5_file( $file ), 0, 12 )
267        );
268    }
269
270    /**
271     * Run a guest's loader on the host page and collect its routes.
272     *
273     * @param array $member Normalized member.
274     * @return array[]|null Routes, or null when the guest's build isn't there.
275     */
276    private static function load_guest( $member ) {
277        $snapshot = self::hook_snapshot();
278        call_user_func( $member['load'] );
279        self::remove_added_callbacks( $snapshot );
280
281        $register_page_routes = $member['build_prefix'] . '_register_page_routes';
282        $routes_global        = $member['build_prefix'] . '_' . str_replace( '-', '_', $member['build_page'] ) . '_routes_data';
283        if ( ! function_exists( $register_page_routes ) || ! isset( $GLOBALS[ $routes_global ] ) ) {
284            return null;
285        }
286
287        self::$collected = array();
288        // The generated helper only calls a registrar that function_exists() accepts.
289        call_user_func( $register_page_routes, $GLOBALS[ $routes_global ], __NAMESPACE__ . '\admin_shell_collect_route' );
290        $routes          = self::$collected;
291        self::$collected = null;
292
293        return $routes;
294    }
295
296    /**
297     * Receive one route from a guest's generated route registration.
298     *
299     * @param string      $path           Route path.
300     * @param string|null $content_module Content script module ID.
301     * @param string|null $route_module   Route script module ID.
302     * @return void
303     */
304    public static function collect_route( $path, $content_module = null, $route_module = null ) {
305        if ( null === self::$collected ) {
306            return;
307        }
308        $route = array( 'path' => $path );
309        if ( $content_module ) {
310            $route['content_module'] = $content_module;
311        }
312        if ( $route_module ) {
313            $route['route_module'] = $route_module;
314        }
315        self::$collected[] = $route;
316    }
317
318    /**
319     * Add the guests' modules to the host page's import map, without downloading them.
320     *
321     * @param array $deps    Host boot dependencies.
322     * @param array $members Members keyed by slug.
323     * @param array $guests  Guest routes keyed by slug.
324     * @return array
325     */
326    public static function add_guest_dependencies( $deps, $members, $guests ) {
327        foreach ( $guests as $slug => $routes ) {
328            $ids = $members[ $slug ]['init_modules'];
329            foreach ( $routes as $route ) {
330                $ids[] = $route['route_module'] ?? null;
331                $ids[] = $route['content_module'] ?? null;
332            }
333            foreach ( array_filter( $ids ) as $id ) {
334                $deps[] = array(
335                    'import' => 'dynamic',
336                    'id'     => $id,
337                );
338            }
339        }
340        return $deps;
341    }
342
343    /**
344     * What the client needs to know about the host and its guests.
345     *
346     * @param string $host    Host page slug.
347     * @param array  $members Members keyed by slug.
348     * @param array  $guests  Guest routes keyed by slug.
349     * @return array
350     */
351    private static function get_client_config( $host, $members, $guests ) {
352        $config = array(
353            'host'             => $host,
354            'members'          => array(),
355            // Saved headers are per site, user and release: subdirectory multisite sites share one origin.
356            'headerStorageKey' => sprintf( 'jetpack-admin-shell-header:%d:%d:%s:', get_current_blog_id(), get_current_user_id(), Admin_Menu::PACKAGE_VERSION ),
357            'guestAssetsUrl'   => add_query_arg(
358                array(
359                    'action' => self::GUEST_ACTION,
360                    'nonce'  => wp_create_nonce( self::GUEST_ACTION ),
361                ),
362                admin_url( 'admin-ajax.php' )
363            ),
364        );
365        foreach ( array( $host => array() ) + $guests as $slug => $routes ) {
366            $member                     = $members[ $slug ];
367            $config['members'][ $slug ] = array(
368                'mountId'     => $member['build_page'] . '-wp-admin-app',
369                'routes'      => $routes,
370                'initModules' => $member['init_modules'],
371                'defaultPath' => $member['default_path'],
372                'title'       => $member['title'],
373                'adminTitle'  => self::admin_title( $slug, $member['title'] ),
374                'bodyClass'   => self::screen_id( $slug ),
375            );
376        }
377        return $config;
378    }
379
380    /**
381     * The document title a member page gets on its own page load.
382     *
383     * @param string $slug     Admin page slug.
384     * @param string $fallback Title to use when the page isn't in the admin menu.
385     * @return string
386     */
387    private static function admin_title( $slug, $fallback ) {
388        global $submenu;
389        $title = $fallback;
390        foreach ( is_array( $submenu ) ? $submenu : array() as $items ) {
391            foreach ( $items as $item ) {
392                if ( isset( $item[2] ) && $slug === $item[2] ) {
393                    $title = wp_strip_all_tags( $item[3] ?? $item[0] );
394                    break 2;
395                }
396            }
397        }
398        /* translators: Admin screen title. 1: Admin screen name, 2: Network or site name. */
399        $admin_title = sprintf( __( '%1$s &lsaquo; %2$s &#8212; WordPress' ), $title, get_bloginfo( 'name' ) ); // phpcs:ignore WordPress.WP.I18n.MissingArgDomain -- Core's string, so the title matches the one core prints.
400        /** This filter is documented in wp-admin/admin-header.php */
401        $admin_title = apply_filters( 'admin_title', $admin_title, $title ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core filter.
402        return html_entity_decode( wp_strip_all_tags( $admin_title ), ENT_QUOTES, 'UTF-8' );
403    }
404
405    /**
406     * Send the styles and scripts a guest prints on its own page, for the client to run before booting it.
407     *
408     * @return void
409     */
410    public static function ajax_guest_assets() {
411        check_ajax_referer( self::GUEST_ACTION, 'nonce' );
412        // Not `page`: products treat a `page` naming them as a visit to their own screen.
413        $slug = isset( $_GET['member'] ) && is_string( $_GET['member'] ) ? sanitize_key( wp_unslash( $_GET['member'] ) ) : '';
414
415        $html = self::guest_assets( $slug );
416        if ( is_int( $html ) ) {
417            wp_send_json_error( null, $html, JSON_UNESCAPED_SLASHES );
418        }
419        wp_send_json_success( array( 'html' => $html ), 200, JSON_UNESCAPED_SLASHES );
420    }
421
422    /**
423     * The styles and scripts a member prints on its own page.
424     *
425     * @param string $slug Member page slug.
426     * @return string|int The HTML, or an HTTP status: 404 for no such member while the shell is on, 403 when the user can't open it.
427     */
428    public static function guest_assets( $slug ) {
429        $members = self::is_enabled() ? self::get_members() : array();
430        if ( ! isset( $members[ $slug ] ) ) {
431            return 404;
432        }
433        if ( ! current_user_can( $members[ $slug ]['capability'] ) ) {
434            return 403;
435        }
436
437        call_user_func( $members[ $slug ]['load'] );
438        $hook_suffix = self::screen_id( $slug );
439        set_current_screen( $hook_suffix );
440        $GLOBALS['concatenate_scripts'] = false; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- One script tag per handle, so the client can skip what the host already has.
441
442        ob_start();
443        do_action( 'admin_enqueue_scripts', $hook_suffix ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core hook.
444        if ( $members[ $slug ]['media'] ) {
445            wp_enqueue_media();
446        }
447        $handles = self::printed_handles( $members[ $slug ] );
448        // Only the member's own handles, and what they depend on, go back to the page. Printing them
449        // even when nothing enqueued them: a page can rely on another script pulling one in.
450        foreach ( array( wp_scripts(), wp_styles() ) as $dependencies ) {
451            $dependencies->queue = array_values( array_filter( $handles, array( $dependencies, 'query' ) ) );
452        }
453        self::guard_api_fetch_setup();
454        wp_print_styles();
455        // The print hooks attach data to handles (Script_Data does), so they run in full.
456        do_action( 'admin_print_scripts' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core hook.
457        print_head_scripts();
458        print_footer_scripts();
459        do_action( 'admin_print_footer_scripts' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core hook.
460        // Core prints these on admin_footer, which the endpoint doesn't run.
461        if ( $members[ $slug ]['media'] ) {
462            wp_print_media_templates();
463        }
464        return ob_get_clean();
465    }
466
467    /**
468     * Skip core's api-fetch setup when the host page already ran it.
469     *
470     * A second nonce middleware would keep sending the stale nonce after a refresh, so requests loop.
471     *
472     * @return void
473     */
474    private static function guard_api_fetch_setup() {
475        $after = wp_scripts()->get_data( 'wp-api-fetch', 'after' );
476        if ( ! is_array( $after ) ) {
477            return;
478        }
479        foreach ( $after as $i => $code ) {
480            if ( is_string( $code ) && ( false !== strpos( $code, 'createRootURLMiddleware' ) || false !== strpos( $code, 'createNonceMiddleware' ) ) ) {
481                $after[ $i ] = "if ( ! wp.apiFetch.nonceMiddleware ) {\n" . $code . "\n}";
482            }
483        }
484        wp_scripts()->add_data( 'wp-api-fetch', 'after', $after );
485    }
486
487    /**
488     * The script and style handles the guest endpoint prints for a member.
489     *
490     * @param array $member Normalized member.
491     * @return string[]
492     */
493    private static function printed_handles( array $member ): array {
494        return $member['media'] ? array( ...$member['handles'], ...self::MEDIA_HANDLES ) : $member['handles'];
495    }
496
497    /**
498     * Snapshot the callbacks on the deferred hooks.
499     *
500     * @return array<string, array<string, true>>
501     */
502    private static function hook_snapshot() {
503        global $wp_filter;
504        $snapshot = array();
505        foreach ( self::DEFERRED_HOOKS as $hook ) {
506            $snapshot[ $hook ] = array();
507            foreach ( isset( $wp_filter[ $hook ] ) ? $wp_filter[ $hook ]->callbacks : array() as $priority => $callbacks ) {
508                foreach ( array_keys( $callbacks ) as $id ) {
509                    $snapshot[ $hook ][ $priority . '|' . $id ] = true;
510                }
511            }
512        }
513        return $snapshot;
514    }
515
516    /**
517     * Callbacks added to the deferred hooks since the snapshot, in the order the hooks would run them.
518     *
519     * @param array<string, array<string, true>> $snapshot From hook_snapshot().
520     * @return array<string, array<array{function: callable, priority: int}>> Keyed by every deferred hook.
521     */
522    private static function added_callbacks( $snapshot ) {
523        global $wp_filter;
524        $added = array();
525        foreach ( self::DEFERRED_HOOKS as $hook ) {
526            $added[ $hook ] = array();
527            $callbacks      = isset( $wp_filter[ $hook ] ) ? $wp_filter[ $hook ]->callbacks : array();
528            ksort( $callbacks );
529            foreach ( $callbacks as $priority => $by_id ) {
530                foreach ( $by_id as $id => $callback ) {
531                    if ( ! isset( $snapshot[ $hook ][ $priority . '|' . $id ] ) ) {
532                        $added[ $hook ][] = array(
533                            'function' => $callback['function'],
534                            'priority' => $priority,
535                        );
536                    }
537                }
538            }
539        }
540        return $added;
541    }
542
543    /**
544     * Remove callbacks added to the deferred hooks since the snapshot.
545     *
546     * @param array<string, array<string, true>> $snapshot From hook_snapshot().
547     * @return void
548     */
549    private static function remove_added_callbacks( $snapshot ) {
550        foreach ( self::added_callbacks( $snapshot ) as $hook => $callbacks ) {
551            foreach ( $callbacks as $callback ) {
552                remove_filter( $hook, $callback['function'], $callback['priority'] );
553            }
554        }
555    }
556
557    /**
558     * Fill in a member's defaults, or return null when it can't be used.
559     *
560     * @param mixed $slug   Admin page slug.
561     * @param mixed $member Member definition.
562     * @return array|null
563     */
564    private static function normalize_member( $slug, $member ) {
565        if ( ! is_string( $slug ) || sanitize_key( $slug ) !== $slug || '' === $slug || ! is_array( $member ) ) {
566            return null;
567        }
568
569        foreach ( self::REQUIRED_STRING_KEYS as $key ) {
570            if ( ! isset( $member[ $key ] ) || ! is_string( $member[ $key ] ) || '' === $member[ $key ] ) {
571                return null;
572            }
573        }
574
575        if ( ! isset( $member['load'] ) || ! is_callable( $member['load'] ) ) {
576            return null;
577        }
578
579        $member = array_merge(
580            array(
581                'init_modules' => array(),
582                'default_path' => null,
583                'capability'   => 'manage_options',
584                'handles'      => array(),
585                'media'        => false,
586            ),
587            $member
588        );
589
590        if (
591            ! self::is_string_list( $member['init_modules'] )
592            || ! self::is_string_list( $member['handles'] )
593            || ! is_string( $member['capability'] ) || '' === $member['capability']
594            || ( null !== $member['default_path'] && ! is_string( $member['default_path'] ) )
595            || ! is_bool( $member['media'] )
596        ) {
597            return null;
598        }
599
600        return $member;
601    }
602
603    /**
604     * Whether a value is a list of strings.
605     *
606     * @param mixed $value Value to check.
607     * @return bool
608     */
609    private static function is_string_list( $value ) {
610        if ( ! is_array( $value ) || array_values( $value ) !== $value ) {
611            return false;
612        }
613        foreach ( $value as $item ) {
614            if ( ! is_string( $item ) ) {
615                return false;
616            }
617        }
618        return true;
619    }
620}
621
622/**
623 * Plain-function registrar for wp-build's generated route helpers, which only accept a function name.
624 *
625 * @param string      $path           Route path.
626 * @param string|null $content_module Content script module ID.
627 * @param string|null $route_module   Route script module ID.
628 * @return void
629 */
630function admin_shell_collect_route( $path, $content_module = null, $route_module = null ) { // phpcs:ignore Universal.Files.SeparateFunctionsFromOO.Mixed -- Must load with the class; wp-build route helpers only take a function name.
631    Admin_Shell::collect_route( $path, $content_module, $route_module );
632}