Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
87.50% covered (warning)
87.50%
42 / 48
50.00% covered (danger)
50.00%
5 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Initializer
87.50% covered (warning)
87.50%
42 / 48
50.00% covered (danger)
50.00%
5 / 10
19.71
0.00% covered (danger)
0.00%
0 / 1
 init
93.33% covered (success)
93.33%
28 / 30
0.00% covered (danger)
0.00%
0 / 1
7.01
 register_write_hooks
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 is_available
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 is_seo_surface_visible
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_optin_available
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_gated
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_upsell_url
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 is_seo_tools_module_active
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 flag_sitemap_user_disabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 clear_sitemap_user_disabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * Jetpack SEO — the visibility command center for WordPress sites.
4 *
5 * Gates the surface behind its feature flag and cohort, then wires the admin
6 * page ({@see Admin_Page}), the dashboard's REST reads ({@see Dashboard_Data}),
7 * the content-coverage cache invalidation ({@see Content_Coverage}), and the
8 * opt-in surface ({@see Surface_Visibility}).
9 *
10 * @package automattic/jetpack-seo-package
11 */
12
13namespace Automattic\Jetpack\SEO;
14
15use Automattic\Jetpack\Current_Plan;
16use Automattic\Jetpack\Modules;
17use Automattic\Jetpack\Status\Host;
18
19/**
20 * Boots the package and carries its cross-plugin contract: the feature flag,
21 * the script-data key, and the option names / visibility reads other plugins consume.
22 */
23class Initializer {
24
25    /**
26     * Jetpack SEO package version.
27     *
28     * @var string
29     */
30    const PACKAGE_VERSION = '0.11.0';
31
32    /**
33     * WordPress.com site feature that enables the Jetpack SEO surface.
34     *
35     * This availability switch is independent of subscription entitlements.
36     *
37     * @var string
38     */
39    const FEATURE_SLUG = 'seo-admin-ui';
40
41    /**
42     * Filter name that can enable the entire Jetpack SEO surface.
43     *
44     * The surface is available when this filter returns true or the current site's
45     * active features include {@see self::FEATURE_SLUG}. When neither is enabled,
46     * the package registers no admin menu or assets and changes nothing about the
47     * existing Jetpack UI.
48     *
49     * @var string
50     */
51    const FEATURE_FILTER = 'rsm_jetpack_seo';
52
53    /**
54     * Key under `window.JetpackScriptData` the React app reads its state from
55     * (`window.JetpackScriptData.seo`). Must match the JS-side reader in
56     * `_inc/data/get-overview.ts`.
57     */
58    const SCRIPT_DATA_KEY = 'seo';
59
60    /**
61     * Option recording that the user has deliberately turned the site's sitemap OFF,
62     * so WordPress core's own sitemap should be suppressed too ("off" means no sitemap
63     * at all, not a fallback to `/wp-sitemap.xml`).
64     *
65     * Set when the sitemaps module is switched off and cleared when it's switched on
66     * (see {@see self::flag_sitemap_user_disabled()} / {@see self::clear_sitemap_user_disabled()}),
67     * so it captures a deliberate off — a *transition* — rather than the ambient
68     * off-state. A site that simply never enabled the sitemap never fires the toggle,
69     * so the flag stays absent and its existing (e.g. WordPress-native) sitemap is left
70     * untouched.
71     *
72     * @var string
73     */
74    const SUPPRESS_WP_SITEMAP_OPTION = 'jetpack_seo_suppress_wp_sitemap';
75
76    /**
77     * Option recording whether the Jetpack SEO surface is discoverable on this site.
78     *
79     * Gates whether the SEO admin menu registers on self-hosted sites. Seeded once by the
80     * Jetpack plugin on install/upgrade: fresh installs default to visible, existing
81     * installs default to hidden and opt in via the legacy Traffic page or My Jetpack.
82     * WordPress.com (Simple + Atomic) bypasses this option entirely and is always visible.
83     * Absent until seeded, in which case self-hosted defaults to hidden (the non-disruptive
84     * default). See {@see Surface_Visibility::is_visible()}.
85     *
86     * Replaces `jetpack_seo_surface_visible`, which was seeded before the self-hosted rollout and
87     * marked every install since as fresh; a new key makes every site re-seed once on upgrade.
88     *
89     * @var string
90     */
91    const VISIBILITY_OPTION = 'jetpack_seo_surface_visible_v2';
92
93    /**
94     * Whether the package has been initialized.
95     *
96     * @var bool
97     */
98    private static $initialized = false;
99
100    /**
101     * Initialize the package.
102     *
103     * Called from the Jetpack plugin's `late_initialization()` hook.
104     *
105     * @return void
106     */
107    public static function init() {
108        if ( self::$initialized ) {
109            return;
110        }
111        self::$initialized = true;
112
113        // Gate the entire SEO surface behind its legacy filter or per-site feature.
114        if ( ! self::is_available() ) {
115            return;
116        }
117
118        // Before anything can read the AI crawler choices.
119        Ai_Crawlers::register_legacy_migration();
120
121        // The opt-in endpoint must be reachable even before the surface is visible, so
122        // existing self-hosted installs can switch to the new experience from the legacy
123        // Traffic page or My Jetpack (JETPACK-1700). Registered ahead of the cohort gate.
124        add_action( 'rest_api_init', array( Surface_Visibility::class, 'register_optin_route' ) );
125
126        // Expose opt-in availability to other admin surfaces (the legacy Traffic-page
127        // banner reads it via `@automattic/jetpack-script-data`). Hooked here — after the
128        // feature flag, before the cohort gate — so a still-hidden install gets the signal.
129        add_filter( 'jetpack_admin_js_script_data', array( Surface_Visibility::class, 'inject_optin_availability' ) );
130
131        // Sitemap output is a front-end concern tied to the SEO feature itself, not to
132        // whether the admin dashboard is visible — so register it here, ahead of the
133        // cohort gate. This keeps the deliberate-off behavior consistent in the two
134        // edges the surface gate would otherwise break: a site that turns the sitemap
135        // off while the dashboard is still hidden (an existing self-hosted install that
136        // hasn't opted in), and a flag set while the dashboard was visible that must
137        // stay honored if the dashboard is later hidden.
138        //
139        // Maintain the deliberate-off flag as the sitemap is toggled: these fire only on
140        // a genuine module toggle (not wpcomsh's private-site suppression, which is a
141        // filter, not a deactivation), and are registered before the toggle's REST write.
142        add_action( 'jetpack_deactivate_module_sitemaps', array( __CLASS__, 'flag_sitemap_user_disabled' ) );
143        add_action( 'jetpack_activate_module_sitemaps', array( __CLASS__, 'clear_sitemap_user_disabled' ) );
144
145        // When the user has deliberately turned the sitemap off, suppress WordPress
146        // core's own sitemap too — otherwise "off" silently falls back to core's
147        // `/wp-sitemap.xml` (and its `/sitemap.xml` → `/wp-sitemap.xml` redirect). Keyed
148        // on the deliberate-off flag, NOT the ambient off-state, so a site that never
149        // enabled the sitemap keeps whatever sitemap it already had. Runs on
150        // `plugins_loaded`, before core registers its sitemap server on `init`, so the
151        // filter is in place; with core sitemaps disabled, `/sitemap.xml` and
152        // `/wp-sitemap.xml` both return a proper 404. (When the sitemap is ON, the
153        // Jetpack sitemaps module already disables core's duplicate.)
154        if ( get_option( self::SUPPRESS_WP_SITEMAP_OPTION, false ) ) {
155            add_filter( 'wp_sitemaps_enabled', '__return_false' );
156        }
157
158        // Discoverability cohort gate: the SEO surface is auto-discoverable for fresh
159        // installs and all WordPress.com sites; existing self-hosted installs opt in via
160        // the legacy Traffic page or My Jetpack (JETPACK-1700). Until it's visible we
161        // register nothing else here and let those opt-in surfaces drive discovery.
162        if ( ! self::is_seo_surface_visible() ) {
163            return;
164        }
165
166        // Each SEO module's configure URL defaults to Jetpack › Settings filtered to the
167        // module. Once the surface is visible that view only points here, so link here.
168        foreach ( array( 'seo-tools', 'verification-tools', 'sitemaps' ) as $module ) {
169            add_filter( 'jetpack_module_configuration_url_' . $module, array( Admin_Page::class, 'get_url' ) );
170        }
171
172        // The page registers whenever the surface is visible, even with `seo-tools` off, so
173        // a saved link still offers "Enable SEO tools"; the menu item hides with the module.
174        //
175        // Priority 1: load the wp-build bundle (and define its render function)
176        // before `add_menu_item()` runs at the default priority and needs it.
177        add_action( 'admin_menu', array( Admin_Page::class, 'maybe_load_wp_build' ), 1 );
178        add_action( 'admin_menu', array( Admin_Page::class, 'add_menu_item' ), 10 );
179
180        // Read-only REST routes the dashboard hydrates its initial state from. Preloaded
181        // into the page (see Admin_Page::inject_script_data) so a normal load resolves
182        // them with no request, and fetched by the app when that preload is missing or
183        // stale — so the dashboard recovers its data instead of dead-ending. Registered
184        // whenever the surface is visible (independent of the seo-tools module, like the
185        // Overview).
186        add_action( 'rest_api_init', array( Dashboard_Data::class, 'register_rest_reads' ) );
187
188        self::register_write_hooks();
189
190        // The settings surface only comes online once SEO tools are active — there's
191        // nothing to configure while the module is off, so we don't register its REST
192        // endpoints until then. Everything the dashboard saves is exposed to WordPress
193        // core's `/wp/v2/settings` endpoint, which exists on every platform the package
194        // runs on and capability-gates writes itself; module activation, the one thing
195        // `register_setting()` can't express, has its own package-owned route.
196        if ( self::is_seo_tools_module_active() ) {
197            Schema_Builder::init();
198            Author_Schema_Node::init();
199            Canonical_Urls::init();
200            Llms_Txt::init();
201            Ai_Crawlers::init();
202
203            // On `rest_api_init` rather than here: which option the front page description
204            // is stored in depends on `jetpack_disable_seo_tools`, which a conflicting SEO
205            // plugin can add from anywhere up to the end of `init`. Priority 5 is after all
206            // of those and before core builds the settings route at 99.
207            add_action( 'rest_api_init', array( Dashboard_Data::class, 'register_rest_settings' ), 5 );
208            // Package-owned route for the three settings whose write switches a Jetpack module.
209            add_action( 'rest_api_init', array( Dashboard_Data::class, 'register_module_routes' ) );
210            // Package-owned route for the site-level Schema settings (see the controller).
211            add_action( 'rest_api_init', array( Schema_Settings_Controller::class, 'register_routes' ) );
212        }
213
214        /**
215         * Fires after the Jetpack SEO package is initialized.
216         *
217         * @since 0.1.0
218         */
219        do_action( 'jetpack_seo_init' );
220    }
221
222    /**
223     * Hook what any request that saves content needs: the page fields the SEO page saves, and
224     * clearing the Overview counts and the stored llms.txt. WordPress.com calls this on its API
225     * requests, which skip init().
226     *
227     * @return void
228     */
229    public static function register_write_hooks() {
230        Content_Coverage::register_invalidation();
231        Llms_Txt::register_always_include();
232        Llms_Txt::register_invalidation();
233    }
234
235    /**
236     * Whether the Jetpack SEO product is available on this site.
237     *
238     * Keep the existing filter as an override while allowing WordPress.com to
239     * enable the product for individual sites through its feature registry.
240     *
241     * @return bool
242     */
243    public static function is_available() {
244        if ( (bool) apply_filters( self::FEATURE_FILTER, false ) ) {
245            return true;
246        }
247
248        // Only active features are needed for this check.
249        $features = ( new Host() )->is_wpcom_simple()
250            ? Current_Plan::get_simple_site_specific_features( false )
251            : Current_Plan::get()['features'];
252
253        return in_array( self::FEATURE_SLUG, $features['active'] ?? array(), true );
254    }
255
256    /**
257     * Whether the Jetpack SEO surface should be discoverable (admin menu registered).
258     *
259     * @return bool
260     */
261    public static function is_seo_surface_visible() {
262        return Surface_Visibility::is_visible();
263    }
264
265    /**
266     * Whether to offer an existing install the chance to opt into the new SEO experience.
267     *
268     * @return bool
269     */
270    public static function is_optin_available() {
271        return Surface_Visibility::is_optin_available();
272    }
273
274    /**
275     * Compatibility accessor for integrations using the former plan gate.
276     *
277     * @return bool
278     */
279    public static function is_gated() {
280        return false;
281    }
282
283    /**
284     * Compatibility accessor for integrations using the former upgrade link.
285     *
286     * @return string
287     */
288    public static function get_upsell_url() {
289        return '';
290    }
291
292    /**
293     * Whether the `seo-tools` Jetpack module is currently active.
294     *
295     * @return bool
296     */
297    private static function is_seo_tools_module_active() {
298        if ( ! class_exists( 'Automattic\\Jetpack\\Modules' ) ) {
299            return false;
300        }
301        return ( new Modules() )->is_active( 'seo-tools' );
302    }
303
304    /**
305     * Record that the user has turned the sitemap off, so WordPress core's own sitemap
306     * is suppressed too. Hooked to the sitemaps module's deactivation, which fires only
307     * on a real toggle from a surface (the SEO Settings tab, the legacy Traffic page, or
308     * WP-CLI) — not wpcomsh's private-site suppression, which is a filter on the
309     * active-modules read rather than a deactivation.
310     *
311     * @return void
312     */
313    public static function flag_sitemap_user_disabled() {
314        update_option( self::SUPPRESS_WP_SITEMAP_OPTION, true );
315    }
316
317    /**
318     * Clear the deliberate-off flag when the sitemap is turned back on — the Jetpack
319     * sitemaps module then serves `/sitemap.xml` and suppresses core's duplicate itself.
320     *
321     * @return void
322     */
323    public static function clear_sitemap_user_disabled() {
324        delete_option( self::SUPPRESS_WP_SITEMAP_OPTION );
325    }
326}