Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
31.11% covered (danger)
31.11%
14 / 45
0.00% covered (danger)
0.00%
0 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
Admin_Page
31.11% covered (danger)
31.11%
14 / 45
0.00% covered (danger)
0.00%
0 / 7
88.56
0.00% covered (danger)
0.00%
0 / 1
 add_menu_item
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 maybe_load_wp_build
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 load_wp_build
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 alias_screen_id_for_wp_build
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 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
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
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;
17
18/**
19 * Registers the SEO admin menu and loads the wp-build dashboard bundle.
20 */
21class Admin_Page {
22
23    /**
24     * URL-facing menu slug (`admin.php?page=jetpack-seo`).
25     */
26    const MENU_SLUG = 'jetpack-seo';
27
28    /**
29     * Slug emitted by `@wordpress/build` (`wpPlugin.pages[0]`). wp-build's
30     * auto-generated enqueue callback only fires when `$screen->id` matches
31     * this value, so we alias the screen id to it via `current_screen` without
32     * changing the user-facing URL.
33     */
34    const WP_BUILD_SLUG = 'jetpack-seo-dashboard';
35
36    /**
37     * Render function generated by `@wordpress/build` into
38     * `build/pages/jetpack-seo-dashboard/page-wp-admin.php`. Naming convention:
39     * `{wpPlugin.name}_{page-with-underscores}_wp_admin_render_page`.
40     */
41    const WP_BUILD_RENDER_FN = 'jetpack_seo_jetpack_seo_dashboard_wp_admin_render_page';
42
43    /**
44     * Register the admin menu item.
45     *
46     * Uses Admin_Menu so the page is reachable on wp-admin across all site
47     * types. The render callback is wp-build's generated render function when
48     * the bundle is loaded (i.e. on the SEO page itself, after
49     * `maybe_load_wp_build()` ran at priority 1); otherwise it falls back to a
50     * bare mount node so the page never fatals on an unbuilt checkout.
51     *
52     * @return void
53     */
54    public static function add_menu_item() {
55        $callback = function_exists( self::WP_BUILD_RENDER_FN )
56            ? self::WP_BUILD_RENDER_FN
57            : array( __CLASS__, 'render_fallback' );
58
59        Admin_Menu::add_menu(
60            'SEO',
61            'SEO',
62            'manage_options',
63            self::MENU_SLUG,
64            $callback
65        );
66    }
67
68    /**
69     * On the SEO admin page, load the wp-build bundle, alias the screen id so
70     * wp-build enqueues its assets, and bootstrap the app's initial state.
71     *
72     * Hooked at `admin_menu` priority 1 so polyfills register and the render
73     * function is defined before `add_menu_item()` runs at priority 10.
74     *
75     * @return void
76     */
77    public static function maybe_load_wp_build() {
78        if ( ! self::is_seo_admin_request() ) {
79            return;
80        }
81
82        self::load_wp_build();
83        add_action( 'current_screen', array( __CLASS__, 'alias_screen_id_for_wp_build' ) );
84        add_filter( 'jetpack_admin_js_script_data', array( __CLASS__, 'inject_script_data' ) );
85    }
86
87    /**
88     * Load wp-build's generated registration file and register the polyfills
89     * the bundle depends on. No-op on a fresh checkout before `pnpm build`, in
90     * which case `add_menu_item()` falls back to {@see self::render_fallback()}.
91     *
92     * @return void
93     */
94    private static function load_wp_build() {
95        $build_index = dirname( __DIR__ ) . '/build/build.php';
96
97        if ( ! file_exists( $build_index ) ) {
98            return;
99        }
100
101        require_once $build_index;
102
103        WP_Build_Polyfills::register(
104            'jetpack-seo',
105            array_merge( WP_Build_Polyfills::SCRIPT_HANDLES, WP_Build_Polyfills::MODULE_IDS )
106        );
107    }
108
109    /**
110     * Alias the current screen id to wp-build's expected slug so its
111     * auto-generated enqueue callback fires for our user-facing page.
112     *
113     * @param \WP_Screen|null $screen The current screen object (passed by WP).
114     * @return void
115     */
116    public static function alias_screen_id_for_wp_build( $screen ) {
117        if ( ! is_object( $screen ) ) {
118            return;
119        }
120
121        $screen->id = self::WP_BUILD_SLUG;
122    }
123
124    /**
125     * Bootstrap the React app's initial state onto `window.JetpackScriptData.seo`.
126     *
127     * Because wp-build pages load as ES modules, `wp_localize_script` can't
128     * attach data to them; the shared `jetpack_admin_js_script_data` filter
129     * (printed by the Script_Data package onto the `jetpack-script-data` handle
130     * the bundle already depends on) is the supported channel. The per-tab state
131     * is provided as an apiFetch *preload* (mirrors Podcast) so the app resolves
132     * it with no request on a normal load yet can re-fetch when the preload is
133     * missing or stale, rather than dead-ending on a one-shot read.
134     *
135     * @param array $data Script data being injected onto the page.
136     * @return array
137     */
138    public static function inject_script_data( $data ) {
139        if ( ! is_array( $data ) ) {
140            $data = array();
141        }
142
143        // Preload the dashboard's REST reads into the page so the app resolves them from
144        // cache on first paint with no network request — while still being able to
145        // re-fetch if that preload is ever missing or stale. This replaces injecting the
146        // raw payloads, which the app read synchronously once and couldn't recover from
147        // when momentarily absent (the load-error dead-end). See Dashboard_Data::register_rest_reads()
148        // and the client readers `_inc/data/get-preloaded.ts` + `_inc/data/use-ensure-tab-data.ts`.
149        $data[ Initializer::SCRIPT_DATA_KEY ]['preload'] = array_reduce(
150            Dashboard_Data::rest_read_paths(),
151            'rest_preload_api_request',
152            array()
153        );
154
155        // Small synchronous reads used outside the per-tab data stores, and not part of
156        // the load-error path.
157        $data[ Initializer::SCRIPT_DATA_KEY ]['google_verify'] = Dashboard_Data::get_google_verify_data();
158        $data[ Initializer::SCRIPT_DATA_KEY ]['site']          = Dashboard_Data::get_site_data();
159
160        // Plan-gating signal for below-Premium WordPress.com sites: when gated, the
161        // dashboard reduces to a free subset and surfaces the upsell banner. Self-hosted
162        // is never gated (see Initializer::is_gated()). The upsell URL is only meaningful
163        // when gated, so it's built only then — every ungated and self-hosted admin load
164        // otherwise pays for a site-suffix lookup it never uses.
165        $is_gated                                       = Initializer::is_gated();
166        $data[ Initializer::SCRIPT_DATA_KEY ]['gating'] = array(
167            'is_gated'   => $is_gated,
168            'upsell_url' => $is_gated ? Initializer::get_upsell_url() : '',
169        );
170
171        return $data;
172    }
173
174    /**
175     * Fallback render used when the wp-build artifact is missing (unbuilt
176     * checkout). Renders a bare wrapper so the page loads without the app.
177     *
178     * @return void
179     */
180    public static function render_fallback() {
181        echo '<div class="wrap"><h1>SEO</h1></div>';
182    }
183
184    /**
185     * Whether the current request targets the SEO admin page.
186     *
187     * @return bool
188     */
189    private static function is_seo_admin_request() {
190        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
191        if ( ! is_admin() || ! isset( $_GET['page'] ) ) {
192            return false;
193        }
194
195        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
196        return self::MENU_SLUG === sanitize_text_field( wp_unslash( $_GET['page'] ) );
197    }
198}