Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
80.65% covered (warning)
80.65%
25 / 31
50.00% covered (danger)
50.00%
3 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
Initializer
80.65% covered (warning)
80.65%
25 / 31
50.00% covered (danger)
50.00%
3 / 6
14.23
0.00% covered (danger)
0.00%
0 / 1
 init
86.36% covered (warning)
86.36%
19 / 22
0.00% covered (danger)
0.00%
0 / 1
6.09
 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
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.6.0';
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     * Key under `window.JetpackScriptData` the React app reads its state from
47     * (`window.JetpackScriptData.seo`). Must match the JS-side reader in
48     * `_inc/data/get-overview.ts`.
49     */
50    const SCRIPT_DATA_KEY = 'seo';
51
52    /**
53     * Option recording whether sitemap generation is enabled.
54     *
55     * Read in place of the standalone `sitemaps` module's active state. Module-active
56     * state is filtered against the modules present on disk, so once that module is
57     * removed it would read as inactive even for sites that had it on. A one-time
58     * migration in the Jetpack plugin seeds this option from the site's existing module
59     * state and keeps it in sync while the legacy module still exists. See
60     * `Jetpack::migrate_sitemaps_module_to_seo_option()`.
61     *
62     * @var string
63     */
64    const SITEMAP_ENABLED_OPTION = 'jetpack_seo_sitemap_enabled';
65
66    /**
67     * Option recording whether canonical URLs are enabled.
68     *
69     * Read in place of the standalone `canonical-urls` module's active state. Module-active
70     * state is filtered against the modules present on disk, so once that module is
71     * removed it would read as inactive even for sites that had it on. A one-time
72     * migration in the Jetpack plugin seeds this option from the site's existing module
73     * state and keeps it in sync while the legacy module still exists. See
74     * `Jetpack::migrate_canonical_urls_module_to_seo_option()`.
75     *
76     * @var string
77     */
78    const CANONICAL_ENABLED_OPTION = 'jetpack_seo_canonical_urls_enabled';
79
80    /**
81     * Option recording whether the Jetpack SEO surface is discoverable on this site.
82     *
83     * Gates whether the SEO admin menu registers on self-hosted sites. Seeded once by the
84     * Jetpack plugin on install/upgrade: fresh installs default to visible, existing
85     * installs default to hidden and opt in via the legacy Traffic page or My Jetpack.
86     * WordPress.com (Simple + Atomic) bypasses this option entirely and is always visible.
87     * Absent until seeded, in which case self-hosted defaults to hidden (the non-disruptive
88     * default). See {@see Surface_Visibility::is_visible()}.
89     *
90     * @var string
91     */
92    const VISIBILITY_OPTION = 'jetpack_seo_surface_visible';
93
94    /**
95     * Whether the package has been initialized.
96     *
97     * @var bool
98     */
99    private static $initialized = false;
100
101    /**
102     * Initialize the package.
103     *
104     * Called from the Jetpack plugin's `late_initialization()` hook.
105     *
106     * @return void
107     */
108    public static function init() {
109        if ( self::$initialized ) {
110            return;
111        }
112        self::$initialized = true;
113
114        // Gate the entire SEO surface behind the feature flag.
115        if ( ! (bool) apply_filters( self::FEATURE_FILTER, false ) ) {
116            return;
117        }
118
119        // The opt-in endpoint must be reachable even before the surface is visible, so
120        // existing self-hosted installs can switch to the new experience from the legacy
121        // Traffic page or My Jetpack (JETPACK-1700). Registered ahead of the cohort gate.
122        add_action( 'rest_api_init', array( Surface_Visibility::class, 'register_optin_route' ) );
123
124        // Expose opt-in availability to other admin surfaces (the legacy Traffic-page
125        // banner reads it via `@automattic/jetpack-script-data`). Hooked here — after the
126        // feature flag, before the cohort gate — so a still-hidden install gets the signal.
127        add_filter( 'jetpack_admin_js_script_data', array( Surface_Visibility::class, 'inject_optin_availability' ) );
128
129        // Discoverability cohort gate: the SEO surface is auto-discoverable for fresh
130        // installs and all WordPress.com sites; existing self-hosted installs opt in via
131        // the legacy Traffic page or My Jetpack (JETPACK-1700). Until it's visible we
132        // register nothing else here and let those opt-in surfaces drive discovery.
133        if ( ! self::is_seo_surface_visible() ) {
134            return;
135        }
136
137        // The admin menu and app shell register whenever the surface is visible, even
138        // when the `seo-tools` module is inactive, so SEO stays discoverable and can be
139        // turned on from within the page itself (JETPACK-1700). When the module is off,
140        // the Overview renders only its "enable SEO tools" affordance.
141        //
142        // Priority 1: load the wp-build bundle (and define its render function)
143        // before `add_menu_item()` runs at the default priority and needs it.
144        add_action( 'admin_menu', array( Admin_Page::class, 'maybe_load_wp_build' ), 1 );
145        add_action( 'admin_menu', array( Admin_Page::class, 'add_menu_item' ), 10 );
146
147        // Read-only REST routes the dashboard hydrates its initial state from. Preloaded
148        // into the page (see Admin_Page::inject_script_data) so a normal load resolves
149        // them with no request, and fetched by the app when that preload is missing or
150        // stale — so the dashboard recovers its data instead of dead-ending. Registered
151        // whenever the surface is visible (independent of the seo-tools module, like the
152        // Overview).
153        add_action( 'rest_api_init', array( Dashboard_Data::class, 'register_rest_reads' ) );
154
155        // Keep the Overview's cached content-coverage counts honest. Hooked here rather than
156        // alongside the admin surface above because posts are written from everywhere — the
157        // block editor (REST), the classic editor, wp-cli, cron, other plugins — and the
158        // cache has to be dropped wherever that happens, not just where it's read.
159        Content_Coverage::register_invalidation();
160
161        // The settings surface only comes online once SEO tools are active — there's
162        // nothing to configure while the module is off, so we don't register its REST
163        // endpoints until then. Expose the core `blog_public` option to the REST settings
164        // endpoint so the Settings tab can save search-engine visibility via
165        // `/wp/v2/settings` (the Jetpack settings endpoint only accepts Jetpack options).
166        // Writes are still capability-gated by the core settings controller.
167        if ( self::is_seo_tools_module_active() ) {
168            // Front-end JSON-LD schema output and author profile schema fields.
169            // Intentionally NOT gated: every site keeps emitting its structured data —
170            // a plan-gated site loses the schema *settings* card (a paid control), but
171            // stripping the schema its pages already carry would hurt SEO it has today.
172            // (Finer per-type gating — e.g. sitewide LocalBusiness to paid plans on
173            // self-hosted — is a separate follow-up, tracked in the schema project.)
174            Schema_Builder::init();
175            Author_Schema_Node::init();
176
177            // GEO-tab front-end services. These are paid surfaces on WordPress.com: a
178            // plan-gated site has the GEO tab hidden from its dashboard, so it must not
179            // keep emitting their front-end output either — otherwise it would still
180            // serve /llms.txt and AI-crawler robots.txt directives it doesn't qualify
181            // for. Self-hosted is never gated, so it always registers both.
182            if ( ! self::is_gated() ) {
183                // The /llms.txt handler. Self-hooks a front-end action, so it no-ops off
184                // the front end and stays behind the same gates as the schema above.
185                Llms_Txt::init();
186                // robots.txt directives for blocked AI crawlers. Self-hooks the
187                // `robots_txt` filter, so it stays inert off the front end.
188                Ai_Crawlers::init();
189            }
190
191            add_action( 'rest_api_init', array( Dashboard_Data::class, 'register_rest_settings' ) );
192            // Package-owned route for the site-level Schema settings (see the controller).
193            add_action( 'rest_api_init', array( Schema_Settings_Controller::class, 'register_routes' ) );
194        }
195
196        /**
197         * Fires after the Jetpack SEO package is initialized.
198         *
199         * @since 0.1.0
200         */
201        do_action( 'jetpack_seo_init' );
202    }
203
204    /**
205     * Whether the Jetpack SEO surface should be discoverable (admin menu registered).
206     *
207     * @return bool
208     */
209    public static function is_seo_surface_visible() {
210        return Surface_Visibility::is_visible();
211    }
212
213    /**
214     * Whether to offer an existing install the chance to opt into the new SEO experience.
215     *
216     * @return bool
217     */
218    public static function is_optin_available() {
219        return Surface_Visibility::is_optin_available();
220    }
221
222    /**
223     * Whether the SEO dashboard is plan-gated for this site.
224     *
225     * Gating applies only on WordPress.com (Simple + Atomic): `advanced-seo` is in the
226     * FREE plan's supports list, so `Current_Plan::supports( 'advanced-seo' )` returns
227     * true on self-hosted (never gated) and hijacks to `wpcom_site_has_feature()` on
228     * WordPress.com, where it's false below the Premium plan. Mirrors the AI SEO
229     * Enhancer's plan check in {@see Dashboard_Data::get_ai_data()}.
230     *
231     * Public because {@see Admin_Page::inject_script_data()} reads it to build the
232     * dashboard's gating payload, and {@see self::init()} uses it to decide whether the
233     * GEO-tab front-end services register at all.
234     *
235     * @return bool
236     */
237    public static function is_gated() {
238        return ( new Host() )->is_wpcom_platform()
239            && ! Current_Plan::supports( 'advanced-seo' );
240    }
241
242    /**
243     * The WordPress.com Premium checkout URL for this site, used by the upsell banner
244     * shown to gated sites.
245     *
246     * Built server-side because the client doesn't have the site slug. `value_bundle`
247     * is the wpcom Premium plan slug (see the `premium` entry in
248     * `Automattic\Jetpack\Current_Plan`), and `Status::get_site_suffix()` resolves the
249     * Calypso site slug (via `WPCOM_Masterbar::get_calypso_site_slug()` on wpcom).
250     *
251     * @return string
252     */
253    public static function get_upsell_url() {
254        $site_slug = ( new Status() )->get_site_suffix();
255
256        return sprintf( 'https://wordpress.com/checkout/%s/value_bundle', $site_slug );
257    }
258
259    /**
260     * Whether the `seo-tools` Jetpack module is currently active.
261     *
262     * @return bool
263     */
264    private static function is_seo_tools_module_active() {
265        if ( ! class_exists( 'Automattic\\Jetpack\\Modules' ) ) {
266            return false;
267        }
268        return ( new Modules() )->is_active( 'seo-tools' );
269    }
270}