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