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