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