Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 101
0.00% covered (danger)
0.00%
0 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
Initializer
0.00% covered (danger)
0.00%
0 / 101
0.00% covered (danger)
0.00%
0 / 12
1122
0.00% covered (danger)
0.00%
0 / 1
 init
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 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
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 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
 is_seo_tools_module_active
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 get_overview_data
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
12
 register_rest_settings
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 get_settings_data
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
90
1<?php
2/**
3 * Jetpack SEO — the visibility command center for WordPress sites.
4 *
5 * Registers the `admin.php?page=jetpack-seo` screen via Admin_Menu so it is
6 * reachable on self-hosted, Atomic/WoW, and Simple sites alike, and loads the
7 * `@wordpress/build` (wp-build) dashboard bundle that renders it.
8 *
9 * @package automattic/jetpack-seo-package
10 */
11
12namespace Automattic\Jetpack\SEO;
13
14use Automattic\Jetpack\Admin_UI\Admin_Menu;
15use Automattic\Jetpack\Modules;
16use Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills;
17use Jetpack_SEO_Titles;
18use Jetpack_SEO_Utils;
19
20/**
21 * The main Initializer class. Registers the admin menu and loads the wp-build
22 * dashboard, bootstrapping the React app's initial state onto the page.
23 */
24class Initializer {
25
26    /**
27     * Jetpack SEO package version.
28     *
29     * @var string
30     */
31    const PACKAGE_VERSION = '0.1.0-alpha';
32
33    /**
34     * Filter name that gates the entire Jetpack SEO surface.
35     *
36     * When this filter returns true, the package registers its admin menu and
37     * loads the wp-build dashboard. Default false before release - when the
38     * filter is off the package registers no admin menu and no assets, and
39     * changes nothing about the existing Jetpack UI.
40     *
41     * @var string
42     */
43    const FEATURE_FILTER = 'rsm_jetpack_seo';
44
45    /**
46     * URL-facing menu slug (`admin.php?page=jetpack-seo`).
47     */
48    const MENU_SLUG = 'jetpack-seo';
49
50    /**
51     * Slug emitted by `@wordpress/build` (`wpPlugin.pages[0]`). wp-build's
52     * auto-generated enqueue callback only fires when `$screen->id` matches
53     * this value, so we alias the screen id to it via `current_screen` without
54     * changing the user-facing URL.
55     */
56    const WP_BUILD_SLUG = 'jetpack-seo-dashboard';
57
58    /**
59     * Render function generated by `@wordpress/build` into
60     * `build/pages/jetpack-seo-dashboard/page-wp-admin.php`. Naming convention:
61     * `{wpPlugin.name}_{page-with-underscores}_wp_admin_render_page`.
62     */
63    const WP_BUILD_RENDER_FN = 'jetpack_seo_jetpack_seo_dashboard_wp_admin_render_page';
64
65    /**
66     * Key under `window.JetpackScriptData` the React app reads its state from
67     * (`window.JetpackScriptData.seo`). Must match the JS-side reader in
68     * `_inc/data/get-overview.ts`.
69     */
70    const SCRIPT_DATA_KEY = 'seo';
71
72    /**
73     * Whether the package has been initialized.
74     *
75     * @var bool
76     */
77    private static $initialized = false;
78
79    /**
80     * Initialize the package.
81     *
82     * Called from the Jetpack plugin's `late_initialization()` hook.
83     *
84     * @return void
85     */
86    public static function init() {
87        if ( self::$initialized ) {
88            return;
89        }
90        self::$initialized = true;
91
92        // Gate the entire SEO surface behind the feature flag.
93        if ( ! (bool) apply_filters( self::FEATURE_FILTER, false ) ) {
94            return;
95        }
96
97        // Gate the entire SEO surface on the `seo-tools` module being active,
98        // the same way other Jetpack modules do. When the module is off we
99        // register nothing — no admin menu, no assets — rather than registering
100        // everything and hiding the menu downstream.
101        if ( ! self::is_seo_tools_module_active() ) {
102            return;
103        }
104
105        // Priority 1: load the wp-build bundle (and define its render function)
106        // before `add_menu_item()` runs at the default priority and needs it.
107        add_action( 'admin_menu', array( __CLASS__, 'maybe_load_wp_build' ), 1 );
108        add_action( 'admin_menu', array( __CLASS__, 'add_menu_item' ), 10 );
109
110        // Expose the core `blog_public` option to the REST settings endpoint so
111        // the Settings tab can save search-engine visibility via `/wp/v2/settings`.
112        // (The Jetpack settings endpoint only accepts Jetpack options.) Writes
113        // are still capability-gated by the core settings controller.
114        add_action( 'rest_api_init', array( __CLASS__, 'register_rest_settings' ) );
115
116        /**
117         * Fires after the Jetpack SEO package is initialized.
118         *
119         * @since 0.1.0
120         */
121        do_action( 'jetpack_seo_init' );
122    }
123
124    /**
125     * Register the admin menu item.
126     *
127     * Uses Admin_Menu so the page is reachable on wp-admin across all site
128     * types. The render callback is wp-build's generated render function when
129     * the bundle is loaded (i.e. on the SEO page itself, after
130     * `maybe_load_wp_build()` ran at priority 1); otherwise it falls back to a
131     * bare mount node so the page never fatals on an unbuilt checkout.
132     *
133     * @return void
134     */
135    public static function add_menu_item() {
136        $callback = function_exists( self::WP_BUILD_RENDER_FN )
137            ? self::WP_BUILD_RENDER_FN
138            : array( __CLASS__, 'render_fallback' );
139
140        Admin_Menu::add_menu(
141            'SEO',
142            'SEO',
143            'manage_options',
144            self::MENU_SLUG,
145            $callback,
146            2
147        );
148    }
149
150    /**
151     * On the SEO admin page, load the wp-build bundle, alias the screen id so
152     * wp-build enqueues its assets, and bootstrap the app's initial state.
153     *
154     * Hooked at `admin_menu` priority 1 so polyfills register and the render
155     * function is defined before `add_menu_item()` runs at priority 10.
156     *
157     * @return void
158     */
159    public static function maybe_load_wp_build() {
160        if ( ! self::is_seo_admin_request() ) {
161            return;
162        }
163
164        self::load_wp_build();
165        add_action( 'current_screen', array( __CLASS__, 'alias_screen_id_for_wp_build' ) );
166        add_filter( 'jetpack_admin_js_script_data', array( __CLASS__, 'inject_script_data' ) );
167    }
168
169    /**
170     * Load wp-build's generated registration file and register the polyfills
171     * the bundle depends on. No-op on a fresh checkout before `pnpm build`, in
172     * which case `add_menu_item()` falls back to {@see self::render_fallback()}.
173     *
174     * @return void
175     */
176    private static function load_wp_build() {
177        $build_index = dirname( __DIR__ ) . '/build/build.php';
178
179        if ( ! file_exists( $build_index ) ) {
180            return;
181        }
182
183        require_once $build_index;
184
185        WP_Build_Polyfills::register(
186            'jetpack-seo',
187            array_merge( WP_Build_Polyfills::SCRIPT_HANDLES, WP_Build_Polyfills::MODULE_IDS )
188        );
189    }
190
191    /**
192     * Alias the current screen id to wp-build's expected slug so its
193     * auto-generated enqueue callback fires for our user-facing page.
194     *
195     * @param \WP_Screen|null $screen The current screen object (passed by WP).
196     * @return void
197     */
198    public static function alias_screen_id_for_wp_build( $screen ) {
199        if ( ! is_object( $screen ) ) {
200            return;
201        }
202
203        $screen->id = self::WP_BUILD_SLUG;
204    }
205
206    /**
207     * Bootstrap the React app's initial state onto `window.JetpackScriptData.seo`.
208     *
209     * Because wp-build pages load as ES modules, `wp_localize_script` can't
210     * attach data to them; the shared `jetpack_admin_js_script_data` filter
211     * (printed by the Script_Data package onto the `jetpack-script-data` handle
212     * the bundle already depends on) is the supported channel. Mirrors Podcast
213     * and Newsletter.
214     *
215     * @param array $data Script data being injected onto the page.
216     * @return array
217     */
218    public static function inject_script_data( $data ) {
219        if ( ! is_array( $data ) ) {
220            $data = array();
221        }
222
223        $data[ self::SCRIPT_DATA_KEY ]['overview'] = self::get_overview_data();
224        $data[ self::SCRIPT_DATA_KEY ]['settings'] = self::get_settings_data();
225
226        return $data;
227    }
228
229    /**
230     * Fallback render used when the wp-build artifact is missing (unbuilt
231     * checkout). Renders a bare wrapper so the page loads without the app.
232     *
233     * @return void
234     */
235    public static function render_fallback() {
236        echo '<div class="wrap"><h1>SEO</h1></div>';
237    }
238
239    /**
240     * Whether the current request targets the SEO admin page.
241     *
242     * @return bool
243     */
244    private static function is_seo_admin_request() {
245        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
246        if ( ! is_admin() || ! isset( $_GET['page'] ) ) {
247            return false;
248        }
249
250        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
251        return self::MENU_SLUG === sanitize_text_field( wp_unslash( $_GET['page'] ) );
252    }
253
254    /**
255     * Whether the `seo-tools` Jetpack module is currently active.
256     *
257     * @return bool
258     */
259    private static function is_seo_tools_module_active() {
260        if ( ! class_exists( 'Automattic\\Jetpack\\Modules' ) ) {
261            return false;
262        }
263        return ( new Modules() )->is_active( 'seo-tools' );
264    }
265
266    /**
267     * Build the aggregated Overview state the dashboard renders.
268     *
269     * @return array
270     */
271    public static function get_overview_data() {
272        $modules = new Modules();
273        // @phan-suppress-next-line PhanUndeclaredClassMethod -- Jetpack_SEO_Utils lives in plugins/jetpack and is guarded by class_exists.
274        $seo_enabled = class_exists( 'Jetpack_SEO_Utils' ) && Jetpack_SEO_Utils::is_enabled_jetpack_seo();
275
276        $codes = get_option( 'verification_services_codes', array() );
277        if ( ! is_array( $codes ) ) {
278            $codes = array();
279        }
280
281        return array(
282            'site_visibility'   => array(
283                'search_engines_visible' => (int) get_option( 'blog_public', 1 ) === 1,
284                // The real `sitemaps` module is the source of truth (the Settings
285                // toggle drives it via `/jetpack/v4/settings`).
286                'sitemap_active'         => $modules->is_active( 'sitemaps' ),
287                'sitemap_url'            => home_url( '/sitemap.xml' ),
288                'seo_tools_active'       => $modules->is_active( 'seo-tools' ),
289            ),
290            // Per-service booleans (a code is set or not) for the Overview's
291            // Site verification card.
292            'site_verification' => array(
293                'google'    => ! empty( $codes['google'] ),
294                'bing'      => ! empty( $codes['bing'] ),
295                'pinterest' => ! empty( $codes['pinterest'] ),
296                'yandex'    => ! empty( $codes['yandex'] ),
297                'facebook'  => ! empty( $codes['facebook'] ),
298            ),
299            'plan'              => array(
300                'seo_enabled_for_site' => $seo_enabled,
301            ),
302        );
303    }
304
305    /**
306     * Expose the core `blog_public` option to the REST settings endpoint.
307     *
308     * Search-engine visibility is a WordPress core option, not a Jetpack one,
309     * so the Settings tab saves it through `/wp/v2/settings` — which only
310     * round-trips settings registered with `show_in_rest`. The core settings
311     * controller enforces the `manage_options` capability on writes.
312     *
313     * @return void
314     */
315    public static function register_rest_settings() {
316        register_setting(
317            'reading',
318            'blog_public',
319            array(
320                'show_in_rest' => true,
321                'type'         => 'integer',
322                'default'      => 1,
323            )
324        );
325    }
326
327    /**
328     * Build the editable Settings state the Settings tab hydrates from.
329     *
330     * Read-only bootstrap only. Writes go through the existing
331     * `/jetpack/v4/settings` REST endpoint, which already validates and
332     * sanitizes each of these fields — this package registers no settings
333     * endpoint of its own. The reads here mirror the options/helpers that
334     * endpoint round-trips so the form hydrates without a request.
335     *
336     * @return array
337     */
338    public static function get_settings_data() {
339        $modules = new Modules();
340
341        // @phan-suppress-next-line PhanUndeclaredClassMethod -- Jetpack_SEO_Titles lives in plugins/jetpack and is guarded by class_exists.
342        $title_formats = class_exists( 'Jetpack_SEO_Titles' ) ? Jetpack_SEO_Titles::get_custom_title_formats() : array();
343        // @phan-suppress-next-line PhanUndeclaredClassMethod -- Jetpack_SEO_Utils lives in plugins/jetpack and is guarded by class_exists.
344        $front_page_desc = class_exists( 'Jetpack_SEO_Utils' ) ? Jetpack_SEO_Utils::get_front_page_meta_description() : '';
345
346        $codes = get_option( 'verification_services_codes', array() );
347        if ( ! is_array( $codes ) ) {
348            $codes = array();
349        }
350
351        return array(
352            'search_engines_visible' => (int) get_option( 'blog_public', 1 ) === 1,
353            // The real `sitemaps` module is the source of truth (not a bespoke
354            // option). The Settings toggle drives it via `/jetpack/v4/settings`.
355            'sitemap_active'         => $modules->is_active( 'sitemaps' ),
356            // Cast to object so an empty format set serializes as `{}`, not `[]`.
357            'title_formats'          => (object) $title_formats,
358            'front_page_description' => (string) $front_page_desc,
359            'verification'           => array(
360                'google'    => isset( $codes['google'] ) ? (string) $codes['google'] : '',
361                'bing'      => isset( $codes['bing'] ) ? (string) $codes['bing'] : '',
362                'pinterest' => isset( $codes['pinterest'] ) ? (string) $codes['pinterest'] : '',
363                'yandex'    => isset( $codes['yandex'] ) ? (string) $codes['yandex'] : '',
364                'facebook'  => isset( $codes['facebook'] ) ? (string) $codes['facebook'] : '',
365            ),
366        );
367    }
368}