Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
82.67% covered (warning)
82.67%
62 / 75
36.36% covered (danger)
36.36%
4 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
Admin_Page
82.67% covered (warning)
82.67%
62 / 75
36.36% covered (danger)
36.36%
4 / 11
28.25
0.00% covered (danger)
0.00%
0 / 1
 add_menu_item
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
3.00
 maybe_load_wp_build
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 load_wp_build_with_screen_alias
75.00% covered (warning)
75.00%
9 / 12
0.00% covered (danger)
0.00%
0 / 1
2.06
 load_wp_build
37.50% covered (danger)
37.50%
3 / 8
0.00% covered (danger)
0.00%
0 / 1
2.98
 alias_screen_id_for_wp_build
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 restore_screen_id_after_wp_build
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 opt_out_of_jitms
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 hide_jitms_on_wp_build_dashboard
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 inject_script_data
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
3.00
 render_fallback
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 is_seo_admin_request
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
3.33
1<?php
2/**
3 * The Jetpack SEO wp-admin page shell.
4 *
5 * Registers the `admin.php?page=jetpack-seo` screen via Admin_Menu so it is
6 * reachable on self-hosted, Atomic/WoA, and Simple sites alike, loads the
7 * `@wordpress/build` (wp-build) dashboard bundle that renders it, and
8 * bootstraps the React app's initial state onto the page.
9 *
10 * @package automattic/jetpack-seo-package
11 */
12
13namespace Automattic\Jetpack\SEO;
14
15use Automattic\Jetpack\Admin_UI\Admin_Menu;
16use Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills;
17use Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Screen_Id;
18
19/**
20 * Registers the SEO admin menu and loads the wp-build dashboard bundle.
21 */
22class Admin_Page {
23
24    /**
25     * URL-facing menu slug (`admin.php?page=jetpack-seo`).
26     */
27    const MENU_SLUG = 'jetpack-seo';
28
29    /**
30     * Slug emitted by `@wordpress/build` (`wpPlugin.pages[0]`). wp-build's
31     * auto-generated enqueue callback only fires when `$screen->id` matches
32     * this value, so we alias the screen id to it around that check without
33     * changing the user-facing URL.
34     */
35    const WP_BUILD_SLUG = 'jetpack-seo-dashboard';
36
37    /**
38     * Render function generated by `@wordpress/build` into
39     * `build/pages/jetpack-seo-dashboard/page-wp-admin.php`. Naming convention:
40     * `{wpPlugin.name}_{page-with-underscores}_wp_admin_render_page`.
41     */
42    const WP_BUILD_RENDER_FN = 'jetpack_seo_jetpack_seo_dashboard_wp_admin_render_page';
43
44    /**
45     * The screen ID alias_screen_id_for_wp_build() replaced, until it is restored.
46     *
47     * @var string|null
48     */
49    private static $wp_build_original_screen_id = null;
50
51    /**
52     * The dashboard screen hide_jitms_on_wp_build_dashboard() opts out of JITMs.
53     *
54     * @var string|null
55     */
56    private static $jitm_opt_out_screen_id = null;
57
58    /**
59     * Register the admin menu item.
60     *
61     * Uses Admin_Menu so the page is reachable on wp-admin across all site
62     * types. The render callback is wp-build's generated render function when
63     * the bundle is loaded (i.e. on the SEO page itself, after
64     * `maybe_load_wp_build()` ran at priority 1); otherwise it falls back to a
65     * bare mount node so the page never fatals on an unbuilt checkout.
66     *
67     * @return void
68     */
69    public static function add_menu_item() {
70        $callback = function_exists( self::WP_BUILD_RENDER_FN )
71            ? self::WP_BUILD_RENDER_FN
72            : array( __CLASS__, 'render_fallback' );
73
74        $page_suffix = Admin_Menu::add_menu(
75            'SEO',
76            'SEO',
77            'manage_options',
78            self::MENU_SLUG,
79            $callback,
80            null,
81            // SEO has no My Jetpack product class, so the module is the only gate available.
82            array(
83                'module' => 'seo-tools',
84                'key'    => 'jetpack-seo',
85            )
86        );
87
88        if ( $page_suffix ) {
89            self::opt_out_of_jitms( $page_suffix );
90        }
91    }
92
93    /**
94     * On the SEO admin page, load the wp-build bundle, alias the screen id so
95     * wp-build enqueues its assets, and bootstrap the app's initial state.
96     *
97     * Hooked at `admin_menu` priority 1 so polyfills register and the render
98     * function is defined before `add_menu_item()` runs at priority 10.
99     *
100     * @return void
101     */
102    public static function maybe_load_wp_build() {
103        if ( ! self::is_seo_admin_request() ) {
104            return;
105        }
106
107        self::load_wp_build_with_screen_alias();
108        add_filter( 'jetpack_admin_js_script_data', array( __CLASS__, 'inject_script_data' ) );
109    }
110
111    /**
112     * Load wp-build with the screen ID aliased across its generated enqueue check.
113     *
114     * @see WP_Build_Screen_Id::load_with_alias()
115     * @return void
116     */
117    private static function load_wp_build_with_screen_alias() {
118        // Fallback: an older wp-build-polyfills under the jetpack-autoloader may predate load_with_alias().
119        if ( method_exists( WP_Build_Screen_Id::class, 'load_with_alias' ) ) {
120            WP_Build_Screen_Id::load_with_alias(
121                array( __CLASS__, 'alias_screen_id_for_wp_build' ),
122                array( __CLASS__, 'restore_screen_id_after_wp_build' ),
123                function () {
124                    self::load_wp_build();
125                }
126            );
127            return;
128        }
129
130        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'alias_screen_id_for_wp_build' ) );
131        self::load_wp_build();
132        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'restore_screen_id_after_wp_build' ) );
133    }
134
135    /**
136     * Load wp-build's generated registration file and register the polyfills
137     * the bundle depends on. No-op on a fresh checkout before `pnpm build`, in
138     * which case `add_menu_item()` falls back to {@see self::render_fallback()}.
139     *
140     * @return void
141     */
142    private static function load_wp_build() {
143        $build_index = dirname( __DIR__ ) . '/build/build.php';
144
145        if ( ! file_exists( $build_index ) ) {
146            return;
147        }
148
149        require_once $build_index;
150
151        WP_Build_Polyfills::register(
152            'jetpack-seo',
153            array_merge( WP_Build_Polyfills::SCRIPT_HANDLES, WP_Build_Polyfills::MODULE_IDS )
154        );
155    }
156
157    /**
158     * Alias the current screen id to wp-build's expected slug so its
159     * auto-generated enqueue callback fires for our user-facing page.
160     *
161     * @since 0.9.5 Takes no argument; hooked on `admin_enqueue_scripts`.
162     *
163     * @return void
164     */
165    public static function alias_screen_id_for_wp_build() {
166        $screen = get_current_screen();
167        if ( ! $screen ) {
168            return;
169        }
170
171        self::$wp_build_original_screen_id = $screen->id;
172        $screen->id                        = self::WP_BUILD_SLUG;
173    }
174
175    /**
176     * Undo alias_screen_id_for_wp_build(), so code after the generated check sees the real screen ID.
177     *
178     * @since 0.9.5
179     *
180     * @return void
181     */
182    public static function restore_screen_id_after_wp_build() {
183        $screen = get_current_screen();
184        if ( ! $screen || null === self::$wp_build_original_screen_id ) {
185            return;
186        }
187
188        $screen->id                        = self::$wp_build_original_screen_id;
189        self::$wp_build_original_screen_id = null;
190    }
191
192    /**
193     * Opt the dashboard's screen out of JITMs.
194     *
195     * @param string $screen_id The hook suffix the page was registered under, which is its screen ID.
196     * @return void
197     */
198    private static function opt_out_of_jitms( $screen_id ) {
199        self::$jitm_opt_out_screen_id = $screen_id;
200        add_filter( 'jetpack_display_jitms_on_screen', array( __CLASS__, 'hide_jitms_on_wp_build_dashboard' ), 10, 2 );
201    }
202
203    /**
204     * Keep JITMs off the wp-build dashboard, which has no `#jp-admin-notices` to show them in.
205     *
206     * Fetching a JITM records a view, so one the page hides would still be counted.
207     *
208     * @since 0.9.5
209     *
210     * @param bool   $show      Whether to show JITMs on the screen.
211     * @param string $screen_id The screen ID.
212     * @return bool
213     */
214    public static function hide_jitms_on_wp_build_dashboard( $show, $screen_id ) {
215        if ( null !== self::$jitm_opt_out_screen_id && self::$jitm_opt_out_screen_id === $screen_id ) {
216            return false;
217        }
218
219        return $show;
220    }
221
222    /**
223     * Bootstrap the React app's initial state onto `window.JetpackScriptData.seo`.
224     *
225     * Because wp-build pages load as ES modules, `wp_localize_script` can't
226     * attach data to them; the shared `jetpack_admin_js_script_data` filter
227     * (printed by the Script_Data package onto the `jetpack-script-data` handle
228     * the bundle already depends on) is the supported channel. The per-tab state
229     * is provided as an apiFetch *preload* (mirrors Podcast) so the app resolves
230     * it with no request on a normal load yet can re-fetch when the preload is
231     * missing or stale, rather than dead-ending on a one-shot read.
232     *
233     * @param array $data Script data being injected onto the page.
234     * @return array
235     */
236    public static function inject_script_data( $data ) {
237        if ( ! is_array( $data ) ) {
238            $data = array();
239        }
240
241        // Preload the dashboard's REST reads into the page so the app resolves them from
242        // cache on first paint with no network request — while still being able to
243        // re-fetch if that preload is ever missing or stale. This replaces injecting the
244        // raw payloads, which the app read synchronously once and couldn't recover from
245        // when momentarily absent (the load-error dead-end). See Dashboard_Data::register_rest_reads()
246        // and the client readers `_inc/data/get-preloaded.ts` + `_inc/data/use-ensure-tab-data.ts`.
247        $data[ Initializer::SCRIPT_DATA_KEY ]['preload'] = array_reduce(
248            Dashboard_Data::rest_read_paths(),
249            'rest_preload_api_request',
250            array()
251        );
252
253        // Small synchronous reads used outside the per-tab data stores, and not part of
254        // the load-error path.
255        $data[ Initializer::SCRIPT_DATA_KEY ]['google_verify'] = Dashboard_Data::get_google_verify_data();
256        $data[ Initializer::SCRIPT_DATA_KEY ]['site']          = Dashboard_Data::get_site_data();
257
258        // Plan-gating signal for below-Premium WordPress.com sites: when gated, the
259        // dashboard reduces to a free subset and surfaces the upsell banner. Self-hosted
260        // is never gated (see Initializer::is_gated()). The upsell URL is only meaningful
261        // when gated, so it's built only then — every ungated and self-hosted admin load
262        // otherwise pays for a site-suffix lookup it never uses.
263        $is_gated                                       = Initializer::is_gated();
264        $data[ Initializer::SCRIPT_DATA_KEY ]['gating'] = array(
265            'is_gated'   => $is_gated,
266            'upsell_url' => $is_gated ? Initializer::get_upsell_url() : '',
267        );
268
269        return $data;
270    }
271
272    /**
273     * Fallback render used when the wp-build artifact is missing (unbuilt
274     * checkout). Renders a bare wrapper so the page loads without the app.
275     *
276     * @return void
277     */
278    public static function render_fallback() {
279        echo '<div class="wrap"><h1>SEO</h1></div>';
280    }
281
282    /**
283     * Whether the current request targets the SEO admin page.
284     *
285     * @return bool
286     */
287    private static function is_seo_admin_request() {
288        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
289        if ( ! is_admin() || ! isset( $_GET['page'] ) ) {
290            return false;
291        }
292
293        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
294        return self::MENU_SLUG === sanitize_text_field( wp_unslash( $_GET['page'] ) );
295    }
296}