Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
73.49% covered (warning)
73.49%
61 / 83
58.82% covered (warning)
58.82%
10 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
WPCOM_WP_Build_Page
74.39% covered (warning)
74.39%
61 / 82
58.82% covered (warning)
58.82%
10 / 17
81.54
0.00% covered (danger)
0.00%
0 / 1
 get_page_title
n/a
0 / 0
n/a
0 / 0
0
 get_menu_title
n/a
0 / 0
n/a
0 / 0
0
 get_initial_state
n/a
0 / 0
n/a
0 / 0
0
 should_register
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 init
55.56% covered (warning)
55.56%
5 / 9
0.00% covered (danger)
0.00%
0 / 1
5.40
 build_dir
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 has_build
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_routes
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
6.07
 get_render_callback
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_page_url
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_page_request
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
4.59
 register_page
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
4.00
 owns_page
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_screen_id
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 reset
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 slug_is_claimed
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 load_wp_build
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 alias_screen_id
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 restore_screen_id
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 enqueue_page_data
73.33% covered (warning)
73.33%
11 / 15
0.00% covered (danger)
0.00%
0 / 1
10.54
1<?php
2/**
3 * Shared plumbing for mu-wpcom's wp-build admin pages.
4 *
5 * @package automattic/jetpack-mu-wpcom
6 */
7
8// @phan-file-suppress PhanAbstractStaticMethodCallInStatic -- static:: dispatches to the concrete page; the base is never booted itself.
9
10namespace Automattic\Jetpack\Jetpack_Mu_Wpcom;
11
12use Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills;
13
14require_once __DIR__ . '/wp-build-script-translations.php';
15
16/**
17 * Serves a wp-build page under a wp-admin menu slug of the subclass's choosing.
18 *
19 * Subclasses are static and boot with `add_action( 'init', array( Subclass::class, 'init' ) )`.
20 */
21abstract class WPCOM_WP_Build_Page {
22
23    /**
24     * Admin page slug, which stays in the page URL.
25     */
26    const MENU_SLUG = '';
27
28    /**
29     * Page name for wp-build, as declared in the page's routes/<route>/package.json.
30     *
31     * Must differ from the menu slug, or wp-build's generated standalone page takes the slug over.
32     */
33    const WP_BUILD_PAGE = '';
34
35    /**
36     * Menu the page sits under.
37     */
38    const PARENT_SLUG = 'jetpack';
39
40    /**
41     * Capability for the page, and for receiving its initial state.
42     */
43    const CAPABILITY = 'manage_options';
44
45    /**
46     * `admin_menu` priority for register_page().
47     *
48     * After Jetpack's Admin_Menu (1000), so a slug it claimed is visible, and before
49     * wpcom_add_jetpack_submenu() (999999), which reads whether this page owns its slug.
50     */
51    const REGISTER_PRIORITY = 999998;
52
53    /**
54     * Global the page's boot script reads get_initial_state() from.
55     */
56    const INITIAL_STATE_VAR = '';
57
58    /**
59     * Whether the page loads WordPress.com Tracks.
60     */
61    const ENQUEUE_TRACKS = false;
62
63    /**
64     * Whether each page registered its slug on this request, keyed by class.
65     *
66     * @var bool[]
67     */
68    private static $owns_page = array();
69
70    /**
71     * The screen ID displaced by alias_screen_id(), keyed by class.
72     *
73     * @var string[]
74     */
75    private static $displaced_screen_id = array();
76
77    /**
78     * The hook suffix add_submenu_page() returned, which is the page's screen ID, keyed by class.
79     *
80     * @var string[]
81     */
82    private static $screen_id = array();
83
84    /**
85     * The page title.
86     *
87     * @return string
88     */
89    abstract public static function get_page_title();
90
91    /**
92     * The menu entry's label.
93     *
94     * @return string
95     */
96    abstract public static function get_menu_title();
97
98    /**
99     * The state the page's boot script reads from INITIAL_STATE_VAR.
100     *
101     * @return array
102     */
103    abstract public static function get_initial_state();
104
105    /**
106     * Whether this page should claim its slug on this site.
107     *
108     * @return bool
109     */
110    public static function should_register() {
111        return true;
112    }
113
114    /**
115     * Boot the page, loading the wp-build assets only on its own request.
116     *
117     * @return void
118     */
119    public static function init() {
120        // Without a build the page would render blank, so leave the slug to whatever serves it otherwise.
121        if ( ! static::should_register() || ! static::has_build() ) {
122            return;
123        }
124
125        add_action( 'admin_menu', array( static::class, 'register_page' ), static::REGISTER_PRIORITY );
126
127        if ( ! static::is_page_request() ) {
128            return;
129        }
130
131        static::load_wp_build();
132
133        // wp-build's generated enqueue runs at the default priority 10; the alias is
134        // scoped to that one pass so nothing else sees the rewritten screen.
135        add_action( 'admin_enqueue_scripts', array( static::class, 'alias_screen_id' ), 9 );
136        add_action( 'admin_enqueue_scripts', array( static::class, 'restore_screen_id' ), 11 );
137        add_action( 'admin_enqueue_scripts', array( static::class, 'enqueue_page_data' ), 20 );
138    }
139
140    /**
141     * The wp-build output directory, produced at release time and absent from an unbuilt checkout.
142     *
143     * @return string
144     */
145    public static function build_dir() {
146        return dirname( __DIR__, 2 ) . '/build';
147    }
148
149    /**
150     * Whether the wp-build output the page renders from exists.
151     *
152     * @return bool
153     */
154    public static function has_build() {
155        return file_exists( static::build_dir() . '/build.php' );
156    }
157
158    /**
159     * The page's wp-build route directories, read from the build's route registry.
160     *
161     * @return string[]
162     */
163    public static function get_routes() {
164        $registry = static::build_dir() . '/routes/registry.php';
165
166        if ( ! file_exists( $registry ) ) {
167            return array();
168        }
169
170        $routes = array();
171        foreach ( (array) require $registry as $route ) {
172            if ( is_array( $route ) && static::WP_BUILD_PAGE === ( $route['page'] ?? null ) && isset( $route['name'] ) ) {
173                $routes[] = $route['name'];
174            }
175        }
176
177        return $routes;
178    }
179
180    /**
181     * Render callback wp-build generates in build/pages/<page>/page-wp-admin.php.
182     *
183     * @return string
184     */
185    public static function get_render_callback() {
186        return 'jetpack_mu_wpcom_' . str_replace( '-', '_', static::WP_BUILD_PAGE ) . '_wp_admin_render_page';
187    }
188
189    /**
190     * The page's own address.
191     *
192     * @return string
193     */
194    public static function get_page_url() {
195        return admin_url( 'admin.php?page=' . static::MENU_SLUG );
196    }
197
198    /**
199     * Whether the current request targets this page.
200     *
201     * @return bool
202     */
203    public static function is_page_request() {
204        // admin-ajax.php is also `is_admin()`, and has no menu, screen or enqueue pass to set up.
205        if ( ! is_admin() || 'admin.php' !== ( $GLOBALS['pagenow'] ?? '' ) ) {
206            return false;
207        }
208
209        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only routing check; no form data is processed.
210        return isset( $_GET['page'] ) && static::MENU_SLUG === sanitize_text_field( wp_unslash( $_GET['page'] ) );
211    }
212
213    /**
214     * Register the page, unless something else already serves the slug.
215     *
216     * @return void
217     */
218    public static function register_page() {
219        // A second entry under a slug someone else owns would leave two.
220        if ( static::slug_is_claimed() ) {
221            return;
222        }
223
224        self::$owns_page[ static::class ] = true;
225
226        // build/build.php defines the render callback but only loads on this page's
227        // own request; elsewhere the callback never runs, so any callable will do.
228        $callback = function_exists( static::get_render_callback() )
229            ? static::get_render_callback()
230            : '__return_empty_string';
231
232        $hook = add_submenu_page(
233            static::PARENT_SLUG,
234            static::get_page_title(),
235            static::get_menu_title(),
236            static::CAPABILITY,
237            static::MENU_SLUG,
238            $callback
239        );
240
241        if ( $hook ) {
242            self::$screen_id[ static::class ] = $hook;
243        }
244    }
245
246    /**
247     * Whether this page registered its slug on this request.
248     *
249     * @return bool
250     */
251    public static function owns_page() {
252        return ! empty( self::$owns_page[ static::class ] );
253    }
254
255    /**
256     * The page's screen ID, or null when it did not register on this request.
257     *
258     * @return string|null
259     */
260    public static function get_screen_id() {
261        return self::$screen_id[ static::class ] ?? null;
262    }
263
264    /**
265     * Reset the registration state. Test seam; production sets this once per request.
266     *
267     * @return void
268     */
269    public static function reset() {
270        unset( self::$owns_page[ static::class ], self::$displaced_screen_id[ static::class ], self::$screen_id[ static::class ] );
271    }
272
273    /**
274     * Whether something has already registered this page's slug.
275     *
276     * @return bool
277     */
278    public static function slug_is_claimed() {
279        global $submenu;
280
281        foreach ( $submenu[ static::PARENT_SLUG ] ?? array() as $item ) {
282            if ( is_array( $item ) && static::MENU_SLUG === ( $item[2] ?? null ) ) {
283                return true;
284            }
285        }
286
287        return false;
288    }
289
290    /**
291     * Load the wp-build generated asset registrations and the boot polyfills.
292     *
293     * @return void
294     */
295    public static function load_wp_build() {
296        $wp_build_index = static::build_dir() . '/build.php';
297        require_once $wp_build_index;
298
299        // Register polyfills for WP < 7.0 (must run before enqueue).
300        WP_Build_Polyfills::register(
301            'jetpack-mu-wpcom',
302            array_merge(
303                WP_Build_Polyfills::SCRIPT_HANDLES,
304                WP_Build_Polyfills::MODULE_IDS
305            )
306        );
307    }
308
309    /**
310     * Make wp-build's enqueue check, which wants a screen ID of WP_BUILD_PAGE, pass without changing the URL.
311     *
312     * Paired with restore_screen_id() so the body class and page-view tracker keep reading the real ID.
313     *
314     * @return void
315     */
316    public static function alias_screen_id() {
317        $screen = get_current_screen();
318
319        if ( ! static::owns_page() || ! is_object( $screen ) ) {
320            return;
321        }
322
323        self::$displaced_screen_id[ static::class ] = $screen->id;
324        $screen->id                                 = static::WP_BUILD_PAGE;
325    }
326
327    /**
328     * Put back the screen ID displaced by alias_screen_id().
329     *
330     * @return void
331     */
332    public static function restore_screen_id() {
333        $screen = get_current_screen();
334
335        if ( ! isset( self::$displaced_screen_id[ static::class ] ) || ! is_object( $screen ) ) {
336            return;
337        }
338
339        $screen->id = self::$displaced_screen_id[ static::class ];
340        unset( self::$displaced_screen_id[ static::class ] );
341    }
342
343    /**
344     * Hand the page its initial state, Tracks and translation catalogs.
345     *
346     * All of it lands on the prerequisites script, which runs before the boot module.
347     *
348     * @return void
349     */
350    public static function enqueue_page_data() {
351        $handle = static::WP_BUILD_PAGE . '-wp-admin-prerequisites';
352
353        // The menu's capability gate already keeps others off the page; restated because
354        // subclasses build the state from direct calls rather than gated endpoints.
355        if ( ! static::owns_page() || ! wp_script_is( $handle, 'registered' ) || ! current_user_can( static::CAPABILITY ) ) {
356            return;
357        }
358
359        if ( '' !== static::INITIAL_STATE_VAR ) {
360            // Inline JSON rather than wp_localize_script(), which stringifies top-level scalars.
361            $state = wp_json_encode( static::get_initial_state(), JSON_HEX_TAG | JSON_HEX_AMP | JSON_UNESCAPED_SLASHES );
362
363            // Every `before` script shares one tag, so a bare `= ;` would also break the translations.
364            if ( false !== $state ) {
365                wp_add_inline_script( $handle, 'window.' . static::INITIAL_STATE_VAR . ' = ' . $state . ';', 'before' );
366            }
367        }
368
369        if ( static::ENQUEUE_TRACKS ) {
370            Common\wpcom_enqueue_tracking_scripts( $handle );
371        }
372
373        $constants = static::build_dir() . '/constants.php';
374        if ( file_exists( $constants ) ) {
375            $build_constants = require $constants;
376            $translations    = wpcom_wp_build_script_translations( static::build_dir() . '/i18n-manifest.json', $build_constants['build_url'], static::get_routes() );
377
378            if ( null !== $translations ) {
379                wp_add_inline_script( $handle, $translations, 'before' );
380            }
381        }
382    }
383}