Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.09% covered (warning)
89.09%
49 / 55
60.00% covered (warning)
60.00%
6 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Initializer
89.09% covered (warning)
89.09%
49 / 55
60.00% covered (warning)
60.00%
6 / 10
25.81
0.00% covered (danger)
0.00%
0 / 1
 init
93.75% covered (success)
93.75%
30 / 32
0.00% covered (danger)
0.00%
0 / 1
8.02
 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
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 get_upsell_url
0.00% covered (danger)
0.00%
0 / 2
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
 activate_seo_tools_once
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 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;
18use Automattic\Jetpack\Status\Host;
19
20/**
21 * Boots the package and carries its cross-plugin contract: the feature flag,
22 * the script-data key, and the option names / visibility reads other plugins consume.
23 */
24class Initializer {
25
26    /**
27     * Jetpack SEO package version.
28     *
29     * @var string
30     */
31    const PACKAGE_VERSION = '0.10.1';
32
33    /**
34     * WordPress.com site feature that enables the Jetpack SEO surface.
35     *
36     * Kept separate from `advanced-seo`, which gates the paid parts of the
37     * dashboard after this product-level availability check has passed.
38     *
39     * @var string
40     */
41    const FEATURE_SLUG = 'seo-admin-ui';
42
43    /**
44     * Filter name that can enable the entire Jetpack SEO surface.
45     *
46     * The surface is available when this filter returns true or the current site's
47     * active features include {@see self::FEATURE_SLUG}. When neither is enabled,
48     * the package registers no admin menu or assets and changes nothing about the
49     * existing Jetpack UI.
50     *
51     * @var string
52     */
53    const FEATURE_FILTER = 'rsm_jetpack_seo';
54
55    /**
56     * Key under `window.JetpackScriptData` the React app reads its state from
57     * (`window.JetpackScriptData.seo`). Must match the JS-side reader in
58     * `_inc/data/get-overview.ts`.
59     */
60    const SCRIPT_DATA_KEY = 'seo';
61
62    /**
63     * Option recording that the user has deliberately turned the site's sitemap OFF,
64     * so WordPress core's own sitemap should be suppressed too ("off" means no sitemap
65     * at all, not a fallback to `/wp-sitemap.xml`).
66     *
67     * Set when the sitemaps module is switched off and cleared when it's switched on
68     * (see {@see self::flag_sitemap_user_disabled()} / {@see self::clear_sitemap_user_disabled()}),
69     * so it captures a deliberate off — a *transition* — rather than the ambient
70     * off-state. A site that simply never enabled the sitemap never fires the toggle,
71     * so the flag stays absent and its existing (e.g. WordPress-native) sitemap is left
72     * untouched.
73     *
74     * @var string
75     */
76    const SUPPRESS_WP_SITEMAP_OPTION = 'jetpack_seo_suppress_wp_sitemap';
77
78    /**
79     * Option recording whether the Jetpack SEO surface is discoverable on this site.
80     *
81     * Gates whether the SEO admin menu registers on self-hosted sites. Seeded once by the
82     * Jetpack plugin on install/upgrade: fresh installs default to visible, existing
83     * installs default to hidden and opt in via the legacy Traffic page or My Jetpack.
84     * WordPress.com (Simple + Atomic) bypasses this option entirely and is always visible.
85     * Absent until seeded, in which case self-hosted defaults to hidden (the non-disruptive
86     * default). See {@see Surface_Visibility::is_visible()}.
87     *
88     * Replaces `jetpack_seo_surface_visible`, which was seeded before the self-hosted rollout and
89     * marked every install since as fresh; a new key makes every site re-seed once on upgrade.
90     *
91     * @var string
92     */
93    const VISIBILITY_OPTION = 'jetpack_seo_surface_visible_v2';
94
95    /**
96     * Option recording that SEO tools was switched on once for a site with the SEO page.
97     *
98     * @var string
99     */
100    const SEO_TOOLS_AUTO_ACTIVATED_OPTION = 'jetpack_seo_tools_auto_activated';
101
102    /**
103     * Whether the package has been initialized.
104     *
105     * @var bool
106     */
107    private static $initialized = false;
108
109    /**
110     * Initialize the package.
111     *
112     * Called from the Jetpack plugin's `late_initialization()` hook.
113     *
114     * @return void
115     */
116    public static function init() {
117        if ( self::$initialized ) {
118            return;
119        }
120        self::$initialized = true;
121
122        // Gate the entire SEO surface behind its legacy filter or per-site feature.
123        if ( ! self::is_available() ) {
124            return;
125        }
126
127        // Before anything can read the AI crawler choices.
128        Ai_Crawlers::register_legacy_migration();
129
130        // The opt-in endpoint must be reachable even before the surface is visible, so
131        // existing self-hosted installs can switch to the new experience from the legacy
132        // Traffic page or My Jetpack (JETPACK-1700). Registered ahead of the cohort gate.
133        add_action( 'rest_api_init', array( Surface_Visibility::class, 'register_optin_route' ) );
134
135        // Expose opt-in availability to other admin surfaces (the legacy Traffic-page
136        // banner reads it via `@automattic/jetpack-script-data`). Hooked here — after the
137        // feature flag, before the cohort gate — so a still-hidden install gets the signal.
138        add_filter( 'jetpack_admin_js_script_data', array( Surface_Visibility::class, 'inject_optin_availability' ) );
139
140        // Sitemap output is a front-end concern tied to the SEO feature itself, not to
141        // whether the admin dashboard is visible — so register it here, ahead of the
142        // cohort gate. This keeps the deliberate-off behavior consistent in the two
143        // edges the surface gate would otherwise break: a site that turns the sitemap
144        // off while the dashboard is still hidden (an existing self-hosted install that
145        // hasn't opted in), and a flag set while the dashboard was visible that must
146        // stay honored if the dashboard is later hidden.
147        //
148        // Maintain the deliberate-off flag as the sitemap is toggled: these fire only on
149        // a genuine module toggle (not wpcomsh's private-site suppression, which is a
150        // filter, not a deactivation), and are registered before the toggle's REST write.
151        add_action( 'jetpack_deactivate_module_sitemaps', array( __CLASS__, 'flag_sitemap_user_disabled' ) );
152        add_action( 'jetpack_activate_module_sitemaps', array( __CLASS__, 'clear_sitemap_user_disabled' ) );
153
154        // When the user has deliberately turned the sitemap off, suppress WordPress
155        // core's own sitemap too — otherwise "off" silently falls back to core's
156        // `/wp-sitemap.xml` (and its `/sitemap.xml` → `/wp-sitemap.xml` redirect). Keyed
157        // on the deliberate-off flag, NOT the ambient off-state, so a site that never
158        // enabled the sitemap keeps whatever sitemap it already had. Runs on
159        // `plugins_loaded`, before core registers its sitemap server on `init`, so the
160        // filter is in place; with core sitemaps disabled, `/sitemap.xml` and
161        // `/wp-sitemap.xml` both return a proper 404. (When the sitemap is ON, the
162        // Jetpack sitemaps module already disables core's duplicate.)
163        if ( get_option( self::SUPPRESS_WP_SITEMAP_OPTION, false ) ) {
164            add_filter( 'wp_sitemaps_enabled', '__return_false' );
165        }
166
167        // Discoverability cohort gate: the SEO surface is auto-discoverable for fresh
168        // installs and all WordPress.com sites; existing self-hosted installs opt in via
169        // the legacy Traffic page or My Jetpack (JETPACK-1700). Until it's visible we
170        // register nothing else here and let those opt-in surfaces drive discovery.
171        if ( ! self::is_seo_surface_visible() ) {
172            return;
173        }
174
175        // Each SEO module's configure URL defaults to Jetpack › Settings filtered to the
176        // module. Once the surface is visible that view only points here, so link here.
177        foreach ( array( 'seo-tools', 'verification-tools', 'sitemaps' ) as $module ) {
178            add_filter( 'jetpack_module_configuration_url_' . $module, array( Admin_Page::class, 'get_url' ) );
179        }
180
181        // Priority 0 on `admin_menu`, ahead of Admin_Menu dropping items for inactive modules;
182        // `admin_init` fires after the menu is built, so the first visit would miss the item.
183        add_action( 'admin_menu', array( __CLASS__, 'activate_seo_tools_once' ), 0 );
184
185        // The page registers whenever the surface is visible, even with `seo-tools` off, so
186        // a saved link still offers "Enable SEO tools"; the menu item hides with the module.
187        //
188        // Priority 1: load the wp-build bundle (and define its render function)
189        // before `add_menu_item()` runs at the default priority and needs it.
190        add_action( 'admin_menu', array( Admin_Page::class, 'maybe_load_wp_build' ), 1 );
191        add_action( 'admin_menu', array( Admin_Page::class, 'add_menu_item' ), 10 );
192
193        // Read-only REST routes the dashboard hydrates its initial state from. Preloaded
194        // into the page (see Admin_Page::inject_script_data) so a normal load resolves
195        // them with no request, and fetched by the app when that preload is missing or
196        // stale — so the dashboard recovers its data instead of dead-ending. Registered
197        // whenever the surface is visible (independent of the seo-tools module, like the
198        // Overview).
199        add_action( 'rest_api_init', array( Dashboard_Data::class, 'register_rest_reads' ) );
200
201        // Keep the Overview's cached content-coverage counts honest. Hooked here rather than
202        // alongside the admin surface above because posts are written from everywhere — the
203        // block editor (REST), the classic editor, wp-cli, cron, other plugins — and the
204        // cache has to be dropped wherever that happens, not just where it's read.
205        Content_Coverage::register_invalidation();
206
207        // The settings surface only comes online once SEO tools are active — there's
208        // nothing to configure while the module is off, so we don't register its REST
209        // endpoints until then. Everything the dashboard saves is exposed to WordPress
210        // core's `/wp/v2/settings` endpoint, which exists on every platform the package
211        // runs on and capability-gates writes itself; module activation, the one thing
212        // `register_setting()` can't express, has its own package-owned route.
213        if ( self::is_seo_tools_module_active() ) {
214            // Front-end JSON-LD schema output and author profile schema fields.
215            // Intentionally NOT gated: every site keeps emitting its structured data —
216            // a plan-gated site loses the schema *settings* card (a paid control), but
217            // stripping the schema its pages already carry would hurt SEO it has today.
218            // (Finer per-type gating — e.g. sitewide LocalBusiness to paid plans on
219            // self-hosted — is a separate follow-up, tracked in the schema project.)
220            Schema_Builder::init();
221            Author_Schema_Node::init();
222
223            // Paid front-end output. A plan-gated WordPress.com site has these controls
224            // hidden from its dashboard, so it must not keep emitting their output either —
225            // otherwise it would still serve /llms.txt, AI-crawler robots.txt directives and
226            // Simple's archive canonical tags it doesn't qualify for. Self-hosted is never gated.
227            if ( ! self::is_gated() ) {
228                Canonical_Urls::init();
229                // The /llms.txt handler. Self-hooks a front-end action, so it no-ops off
230                // the front end and stays behind the same gates as the schema above.
231                Llms_Txt::init();
232                // robots.txt directives for blocked AI crawlers. Self-hooks the
233                // `robots_txt` filter, so it stays inert off the front end.
234                Ai_Crawlers::init();
235            }
236
237            // On `rest_api_init` rather than here: which option the front page description
238            // is stored in depends on `jetpack_disable_seo_tools`, which a conflicting SEO
239            // plugin can add from anywhere up to the end of `init`. Priority 5 is after all
240            // of those and before core builds the settings route at 99.
241            add_action( 'rest_api_init', array( Dashboard_Data::class, 'register_rest_settings' ), 5 );
242            // Package-owned route for the three settings whose write switches a Jetpack module.
243            add_action( 'rest_api_init', array( Dashboard_Data::class, 'register_module_routes' ) );
244            // Package-owned route for the site-level Schema settings (see the controller).
245            add_action( 'rest_api_init', array( Schema_Settings_Controller::class, 'register_routes' ) );
246        }
247
248        /**
249         * Fires after the Jetpack SEO package is initialized.
250         *
251         * @since 0.1.0
252         */
253        do_action( 'jetpack_seo_init' );
254    }
255
256    /**
257     * Whether the Jetpack SEO product is available on this site.
258     *
259     * Keep the existing filter as an override while allowing WordPress.com to
260     * enable the product for individual sites through its feature registry.
261     *
262     * @return bool
263     */
264    public static function is_available() {
265        if ( (bool) apply_filters( self::FEATURE_FILTER, false ) ) {
266            return true;
267        }
268
269        // Only active features are needed for this check.
270        $features = ( new Host() )->is_wpcom_simple()
271            ? Current_Plan::get_simple_site_specific_features( false )
272            : Current_Plan::get()['features'];
273
274        return in_array( self::FEATURE_SLUG, $features['active'] ?? array(), true );
275    }
276
277    /**
278     * Whether the Jetpack SEO surface should be discoverable (admin menu registered).
279     *
280     * @return bool
281     */
282    public static function is_seo_surface_visible() {
283        return Surface_Visibility::is_visible();
284    }
285
286    /**
287     * Whether to offer an existing install the chance to opt into the new SEO experience.
288     *
289     * @return bool
290     */
291    public static function is_optin_available() {
292        return Surface_Visibility::is_optin_available();
293    }
294
295    /**
296     * Whether the SEO dashboard is plan-gated for this site.
297     *
298     * Gating applies only on WordPress.com (Simple + Atomic): `advanced-seo` is in the
299     * FREE plan's supports list, so `Current_Plan::supports( 'advanced-seo' )` returns
300     * true on self-hosted (never gated) and hijacks to `wpcom_site_has_feature()` on
301     * WordPress.com, where it's false below the Premium plan. Mirrors the AI SEO
302     * Enhancer's plan check in {@see Dashboard_Data::get_geo_data()}.
303     *
304     * Public because {@see Admin_Page::inject_script_data()} reads it to build the
305     * dashboard's gating payload, and {@see self::init()} uses it to decide whether the
306     * paid front-end output registers at all.
307     *
308     * @return bool
309     */
310    public static function is_gated() {
311        return ( new Host() )->is_wpcom_platform()
312            && ! Current_Plan::supports( 'advanced-seo' );
313    }
314
315    /**
316     * The WordPress.com Premium checkout URL for this site, used by the upsell banner
317     * shown to gated sites.
318     *
319     * Built server-side because the client doesn't have the site slug. `value_bundle`
320     * is the wpcom Premium plan slug (see the `premium` entry in
321     * `Automattic\Jetpack\Current_Plan`), and `Status::get_site_suffix()` resolves the
322     * Calypso site slug (via `WPCOM_Masterbar::get_calypso_site_slug()` on wpcom).
323     *
324     * @return string
325     */
326    public static function get_upsell_url() {
327        $site_slug = ( new Status() )->get_site_suffix();
328
329        return sprintf( 'https://wordpress.com/checkout/%s/value_bundle', $site_slug );
330    }
331
332    /**
333     * Whether the `seo-tools` Jetpack module is currently active.
334     *
335     * @return bool
336     */
337    private static function is_seo_tools_module_active() {
338        if ( ! class_exists( 'Automattic\\Jetpack\\Modules' ) ) {
339            return false;
340        }
341        return ( new Modules() )->is_active( 'seo-tools' );
342    }
343
344    /**
345     * Turn SEO tools on the first time an admin opens wp-admin with the SEO page, since it can
346     * start off; only once, so turning it off sticks. Simple runs every module regardless.
347     *
348     * @return void
349     */
350    public static function activate_seo_tools_once() {
351        if (
352            ( new Host() )->is_wpcom_simple()
353            || get_option( self::SEO_TOOLS_AUTO_ACTIVATED_OPTION )
354            || ! current_user_can( 'manage_options' )
355        ) {
356            return;
357        }
358
359        // Activation fails before the site is connected; leave the option unset to retry.
360        if ( ( new Modules() )->activate( 'seo-tools', false, false ) ) {
361            update_option( self::SEO_TOOLS_AUTO_ACTIVATED_OPTION, true );
362        }
363    }
364
365    /**
366     * Record that the user has turned the sitemap off, so WordPress core's own sitemap
367     * is suppressed too. Hooked to the sitemaps module's deactivation, which fires only
368     * on a real toggle from a surface (the SEO Settings tab, the legacy Traffic page, or
369     * WP-CLI) — not wpcomsh's private-site suppression, which is a filter on the
370     * active-modules read rather than a deactivation.
371     *
372     * @return void
373     */
374    public static function flag_sitemap_user_disabled() {
375        update_option( self::SUPPRESS_WP_SITEMAP_OPTION, true );
376    }
377
378    /**
379     * Clear the deliberate-off flag when the sitemap is turned back on — the Jetpack
380     * sitemaps module then serves `/sitemap.xml` and suppresses core's duplicate itself.
381     *
382     * @return void
383     */
384    public static function clear_sitemap_user_disabled() {
385        delete_option( self::SUPPRESS_WP_SITEMAP_OPTION );
386    }
387}