Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
87.85% covered (warning)
87.85%
94 / 107
53.33% covered (warning)
53.33%
8 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
Admin_Page
87.85% covered (warning)
87.85%
94 / 107
53.33% covered (warning)
53.33%
8 / 15
41.73
0.00% covered (danger)
0.00%
0 / 1
 add_menu_item
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
3.00
 maybe_load_wp_build
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 enqueue_tracks_transport
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 can_use_analytics
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 get_tracks_context
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
7
 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
 inject_tracks_context
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 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\Connection\Manager as Connection_Manager;
17use Automattic\Jetpack\Constants;
18use Automattic\Jetpack\Status;
19use Automattic\Jetpack\Status\Host;
20use Automattic\Jetpack\Terms_Of_Service;
21use Automattic\Jetpack\Tracking;
22use Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills;
23use Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Screen_Id;
24
25/**
26 * Registers the SEO admin menu and loads the wp-build dashboard bundle.
27 */
28class Admin_Page {
29
30    /**
31     * URL-facing menu slug (`admin.php?page=jetpack-seo`).
32     */
33    const MENU_SLUG = 'jetpack-seo';
34
35    /**
36     * Slug emitted by `@wordpress/build` (`wpPlugin.pages[0]`). wp-build's
37     * auto-generated enqueue callback only fires when `$screen->id` matches
38     * this value, so we alias the screen id to it around that check without
39     * changing the user-facing URL.
40     */
41    const WP_BUILD_SLUG = 'jetpack-seo-dashboard';
42
43    /**
44     * Render function generated by `@wordpress/build` into
45     * `build/pages/jetpack-seo-dashboard/page-wp-admin.php`. Naming convention:
46     * `{wpPlugin.name}_{page-with-underscores}_wp_admin_render_page`.
47     */
48    const WP_BUILD_RENDER_FN = 'jetpack_seo_jetpack_seo_dashboard_wp_admin_render_page';
49
50    /**
51     * The screen ID alias_screen_id_for_wp_build() replaced, until it is restored.
52     *
53     * @var string|null
54     */
55    private static $wp_build_original_screen_id = null;
56
57    /**
58     * The dashboard screen hide_jitms_on_wp_build_dashboard() opts out of JITMs.
59     *
60     * @var string|null
61     */
62    private static $jitm_opt_out_screen_id = null;
63
64    /**
65     * Register the admin menu item.
66     *
67     * Uses Admin_Menu so the page is reachable on wp-admin across all site
68     * types. The render callback is wp-build's generated render function when
69     * the bundle is loaded (i.e. on the SEO page itself, after
70     * `maybe_load_wp_build()` ran at priority 1); otherwise it falls back to a
71     * bare mount node so the page never fatals on an unbuilt checkout.
72     *
73     * @return void
74     */
75    public static function add_menu_item() {
76        $callback = function_exists( self::WP_BUILD_RENDER_FN )
77            ? self::WP_BUILD_RENDER_FN
78            : array( __CLASS__, 'render_fallback' );
79
80        $page_suffix = Admin_Menu::add_menu(
81            'SEO',
82            'SEO',
83            'manage_options',
84            self::MENU_SLUG,
85            $callback,
86            null,
87            // No module gate: the menu must stay visible even when seo-tools is off so the
88            // user can turn it back on from the page. Hosts may still use the
89            // jetpack_admin_menu_visibility filter to hide it via the 'jetpack-seo' key.
90            array(
91                'key' => 'jetpack-seo',
92            )
93        );
94
95        if ( $page_suffix ) {
96            self::opt_out_of_jitms( $page_suffix );
97        }
98    }
99
100    /**
101     * On the SEO admin page, load the wp-build bundle, alias the screen id so
102     * wp-build enqueues its assets, and bootstrap the app's initial state.
103     *
104     * Hooked at `admin_menu` priority 1 so polyfills register and the render
105     * function is defined before `add_menu_item()` runs at priority 10.
106     *
107     * @return void
108     */
109    public static function maybe_load_wp_build() {
110        if ( ! self::is_seo_admin_request() ) {
111            return;
112        }
113
114        self::load_wp_build_with_screen_alias();
115        add_filter( 'jetpack_admin_js_script_data', array( __CLASS__, 'inject_script_data' ) );
116
117        // Without the context the client records nothing, so another script's Tracks transport can't send our events.
118        if ( self::can_use_analytics() ) {
119            add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_tracks_transport' ) );
120            add_filter( 'jetpack_admin_js_script_data', array( __CLASS__, 'inject_tracks_context' ) );
121        }
122    }
123
124    /**
125     * Load the Tracks transport, without which `recordSeoEvent()` only queues into `window._tkq`.
126     *
127     * Skipped on Simple, where stats.php already prints w.js; a second copy re-sends the queue.
128     *
129     * @return void
130     */
131    public static function enqueue_tracks_transport() {
132        if ( ( new Host() )->is_wpcom_simple() ) {
133            return;
134        }
135
136        wp_enqueue_script( 'jp-tracks', '//stats.wp.com/w.js', array(), gmdate( 'YW' ), true );
137    }
138
139    /**
140     * Whether the site has opted into Tracks, as `Jetpack::initialize_tracking()` checks.
141     *
142     * WordPress.com's own terms cover Simple and Atomic, which `should_enable_tracking()` misreads.
143     *
144     * @return bool
145     */
146    public static function can_use_analytics() {
147        if ( ( new Host() )->is_wpcom_platform() ) {
148            return true;
149        }
150
151        $tracking = new Tracking( 'jetpack', new Connection_Manager() );
152
153        return $tracking->should_enable_tracking( new Terms_Of_Service(), new Status() );
154    }
155
156    /**
157     * Identifiers the client attaches to every SEO Tracks event.
158     *
159     * `blog_id` is the WordPress.com id, or 0 before connecting, so a local id never collides with one.
160     *
161     * @return array{blog_id:int, site_type:string, is_a11n:bool, is_test:bool, product_version:string}
162     */
163    public static function get_tracks_context() {
164        $host = new Host();
165        if ( $host->is_wpcom_simple() ) {
166            $site_type = 'simple';
167        } elseif ( $host->is_woa_site() ) {
168            $site_type = 'atomic';
169        } else {
170            $site_type = 'jetpack';
171        }
172
173        $blog_id = (int) $host->get_wpcom_site_id();
174
175        // `is_automattician()` exists on Simple only; on Atomic, the a8c proxy is what identifies us.
176        $is_a11n = ( function_exists( 'is_automattician' ) && (bool) is_automattician() )
177            || Constants::is_true( 'AT_PROXIED_REQUEST' );
178
179        // `Status::is_staging_site()` would cover this, but it is deprecated.
180        $is_test = (bool) get_option( 'wpcom_is_staging_site' )
181            || 'production' !== wp_get_environment_type()
182            || ( new Status() )->is_local_site();
183
184        return array(
185            'blog_id'         => $blog_id,
186            'site_type'       => $site_type,
187            'is_a11n'         => $is_a11n,
188            'is_test'         => $is_test,
189            'product_version' => Initializer::PACKAGE_VERSION,
190        );
191    }
192
193    /**
194     * Load wp-build with the screen ID aliased across its generated enqueue check.
195     *
196     * @see WP_Build_Screen_Id::load_with_alias()
197     * @return void
198     */
199    private static function load_wp_build_with_screen_alias() {
200        // Fallback: an older wp-build-polyfills under the jetpack-autoloader may predate load_with_alias().
201        if ( method_exists( WP_Build_Screen_Id::class, 'load_with_alias' ) ) {
202            WP_Build_Screen_Id::load_with_alias(
203                array( __CLASS__, 'alias_screen_id_for_wp_build' ),
204                array( __CLASS__, 'restore_screen_id_after_wp_build' ),
205                function () {
206                    self::load_wp_build();
207                }
208            );
209            return;
210        }
211
212        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'alias_screen_id_for_wp_build' ) );
213        self::load_wp_build();
214        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'restore_screen_id_after_wp_build' ) );
215    }
216
217    /**
218     * Load wp-build's generated registration file and register the polyfills
219     * the bundle depends on. No-op on a fresh checkout before `pnpm build`, in
220     * which case `add_menu_item()` falls back to {@see self::render_fallback()}.
221     *
222     * @return void
223     */
224    private static function load_wp_build() {
225        $build_index = dirname( __DIR__ ) . '/build/build.php';
226
227        if ( ! file_exists( $build_index ) ) {
228            return;
229        }
230
231        require_once $build_index;
232
233        WP_Build_Polyfills::register(
234            'jetpack-seo',
235            array_merge( WP_Build_Polyfills::SCRIPT_HANDLES, WP_Build_Polyfills::MODULE_IDS )
236        );
237    }
238
239    /**
240     * Alias the current screen id to wp-build's expected slug so its
241     * auto-generated enqueue callback fires for our user-facing page.
242     *
243     * @since 0.9.5 Takes no argument; hooked on `admin_enqueue_scripts`.
244     *
245     * @return void
246     */
247    public static function alias_screen_id_for_wp_build() {
248        $screen = get_current_screen();
249        if ( ! $screen ) {
250            return;
251        }
252
253        self::$wp_build_original_screen_id = $screen->id;
254        $screen->id                        = self::WP_BUILD_SLUG;
255    }
256
257    /**
258     * Undo alias_screen_id_for_wp_build(), so code after the generated check sees the real screen ID.
259     *
260     * @since 0.9.5
261     *
262     * @return void
263     */
264    public static function restore_screen_id_after_wp_build() {
265        $screen = get_current_screen();
266        if ( ! $screen || null === self::$wp_build_original_screen_id ) {
267            return;
268        }
269
270        $screen->id                        = self::$wp_build_original_screen_id;
271        self::$wp_build_original_screen_id = null;
272    }
273
274    /**
275     * Opt the dashboard's screen out of JITMs.
276     *
277     * @param string $screen_id The hook suffix the page was registered under, which is its screen ID.
278     * @return void
279     */
280    private static function opt_out_of_jitms( $screen_id ) {
281        self::$jitm_opt_out_screen_id = $screen_id;
282        add_filter( 'jetpack_display_jitms_on_screen', array( __CLASS__, 'hide_jitms_on_wp_build_dashboard' ), 10, 2 );
283    }
284
285    /**
286     * Keep JITMs off the wp-build dashboard, which has no `#jp-admin-notices` to show them in.
287     *
288     * Fetching a JITM records a view, so one the page hides would still be counted.
289     *
290     * @since 0.9.5
291     *
292     * @param bool   $show      Whether to show JITMs on the screen.
293     * @param string $screen_id The screen ID.
294     * @return bool
295     */
296    public static function hide_jitms_on_wp_build_dashboard( $show, $screen_id ) {
297        if ( null !== self::$jitm_opt_out_screen_id && self::$jitm_opt_out_screen_id === $screen_id ) {
298            return false;
299        }
300
301        return $show;
302    }
303
304    /**
305     * Bootstrap the React app's initial state onto `window.JetpackScriptData.seo`.
306     *
307     * Because wp-build pages load as ES modules, `wp_localize_script` can't
308     * attach data to them; the shared `jetpack_admin_js_script_data` filter
309     * (printed by the Script_Data package onto the `jetpack-script-data` handle
310     * the bundle already depends on) is the supported channel. The per-tab state
311     * is provided as an apiFetch *preload* (mirrors Podcast) so the app resolves
312     * it with no request on a normal load yet can re-fetch when the preload is
313     * missing or stale, rather than dead-ending on a one-shot read.
314     *
315     * @param array $data Script data being injected onto the page.
316     * @return array
317     */
318    public static function inject_script_data( $data ) {
319        if ( ! is_array( $data ) ) {
320            $data = array();
321        }
322
323        // Preload the dashboard's REST reads into the page so the app resolves them from
324        // cache on first paint with no network request — while still being able to
325        // re-fetch if that preload is ever missing or stale. This replaces injecting the
326        // raw payloads, which the app read synchronously once and couldn't recover from
327        // when momentarily absent (the load-error dead-end). See Dashboard_Data::register_rest_reads()
328        // and the client readers `_inc/data/get-preloaded.ts` + `_inc/data/use-ensure-tab-data.ts`.
329        $data[ Initializer::SCRIPT_DATA_KEY ]['preload'] = array_reduce(
330            Dashboard_Data::rest_read_paths(),
331            'rest_preload_api_request',
332            array()
333        );
334
335        // Small synchronous reads used outside the per-tab data stores, and not part of
336        // the load-error path.
337        $data[ Initializer::SCRIPT_DATA_KEY ]['google_verify'] = Dashboard_Data::get_google_verify_data();
338        $data[ Initializer::SCRIPT_DATA_KEY ]['site']          = Dashboard_Data::get_site_data();
339
340        // Plan-gating signal for below-Premium WordPress.com sites: when gated, the
341        // dashboard reduces to a free subset and surfaces the upsell banner. Self-hosted
342        // is never gated (see Initializer::is_gated()). The upsell URL is only meaningful
343        // when gated, so it's built only then — every ungated and self-hosted admin load
344        // otherwise pays for a site-suffix lookup it never uses.
345        $is_gated                                       = Initializer::is_gated();
346        $data[ Initializer::SCRIPT_DATA_KEY ]['gating'] = array(
347            'is_gated'   => $is_gated,
348            'upsell_url' => $is_gated ? Initializer::get_upsell_url() : '',
349        );
350
351        return $data;
352    }
353
354    /**
355     * Add the Tracks context to the SEO script data.
356     *
357     * @param array $data Script data being injected onto the page.
358     * @return array
359     */
360    public static function inject_tracks_context( $data ) {
361        if ( ! is_array( $data ) ) {
362            $data = array();
363        }
364
365        $data[ Initializer::SCRIPT_DATA_KEY ]['tracks'] = self::get_tracks_context();
366
367        return $data;
368    }
369
370    /**
371     * Fallback render used when the wp-build artifact is missing (unbuilt
372     * checkout). Renders a bare wrapper so the page loads without the app.
373     *
374     * @return void
375     */
376    public static function render_fallback() {
377        echo '<div class="wrap"><h1>SEO</h1></div>';
378    }
379
380    /**
381     * Whether the current request targets the SEO admin page.
382     *
383     * @return bool
384     */
385    private static function is_seo_admin_request() {
386        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
387        if ( ! is_admin() || ! isset( $_GET['page'] ) ) {
388            return false;
389        }
390
391        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
392        return self::MENU_SLUG === sanitize_text_field( wp_unslash( $_GET['page'] ) );
393    }
394}