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