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