Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
84.62% covered (warning)
84.62%
22 / 26
50.00% covered (danger)
50.00%
2 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
Initializer
84.62% covered (warning)
84.62%
22 / 26
50.00% covered (danger)
50.00%
2 / 4
9.29
0.00% covered (danger)
0.00%
0 / 1
 init
85.71% covered (warning)
85.71%
18 / 21
0.00% covered (danger)
0.00%
0 / 1
5.07
 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_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\Modules;
16
17/**
18 * Boots the package and carries its cross-plugin contract: the feature flag,
19 * the script-data key, and the option names / visibility reads other plugins consume.
20 */
21class Initializer {
22
23    /**
24     * Jetpack SEO package version.
25     *
26     * @var string
27     */
28    const PACKAGE_VERSION = '0.6.0';
29
30    /**
31     * Filter name that gates the entire Jetpack SEO surface.
32     *
33     * When this filter returns true, the package registers its admin menu and
34     * loads the wp-build dashboard. Default false before release - when the
35     * filter is off the package registers no admin menu and no assets, and
36     * changes nothing about the existing Jetpack UI.
37     *
38     * @var string
39     */
40    const FEATURE_FILTER = 'rsm_jetpack_seo';
41
42    /**
43     * Key under `window.JetpackScriptData` the React app reads its state from
44     * (`window.JetpackScriptData.seo`). Must match the JS-side reader in
45     * `_inc/data/get-overview.ts`.
46     */
47    const SCRIPT_DATA_KEY = 'seo';
48
49    /**
50     * Option recording whether sitemap generation is enabled.
51     *
52     * Read in place of the standalone `sitemaps` module's active state. Module-active
53     * state is filtered against the modules present on disk, so once that module is
54     * removed it would read as inactive even for sites that had it on. A one-time
55     * migration in the Jetpack plugin seeds this option from the site's existing module
56     * state and keeps it in sync while the legacy module still exists. See
57     * `Jetpack::migrate_sitemaps_module_to_seo_option()`.
58     *
59     * @var string
60     */
61    const SITEMAP_ENABLED_OPTION = 'jetpack_seo_sitemap_enabled';
62
63    /**
64     * Option recording whether canonical URLs are enabled.
65     *
66     * Read in place of the standalone `canonical-urls` module's active state. Module-active
67     * state is filtered against the modules present on disk, so once that module is
68     * removed it would read as inactive even for sites that had it on. A one-time
69     * migration in the Jetpack plugin seeds this option from the site's existing module
70     * state and keeps it in sync while the legacy module still exists. See
71     * `Jetpack::migrate_canonical_urls_module_to_seo_option()`.
72     *
73     * @var string
74     */
75    const CANONICAL_ENABLED_OPTION = 'jetpack_seo_canonical_urls_enabled';
76
77    /**
78     * Option recording whether the Jetpack SEO surface is discoverable on this site.
79     *
80     * Gates whether the SEO admin menu registers on self-hosted sites. Seeded once by the
81     * Jetpack plugin on install/upgrade: fresh installs default to visible, existing
82     * installs default to hidden and opt in via the legacy Traffic page or My Jetpack.
83     * WordPress.com (Simple + Atomic) bypasses this option entirely and is always visible.
84     * Absent until seeded, in which case self-hosted defaults to hidden (the non-disruptive
85     * default). See {@see Surface_Visibility::is_visible()}.
86     *
87     * @var string
88     */
89    const VISIBILITY_OPTION = 'jetpack_seo_surface_visible';
90
91    /**
92     * Whether the package has been initialized.
93     *
94     * @var bool
95     */
96    private static $initialized = false;
97
98    /**
99     * Initialize the package.
100     *
101     * Called from the Jetpack plugin's `late_initialization()` hook.
102     *
103     * @return void
104     */
105    public static function init() {
106        if ( self::$initialized ) {
107            return;
108        }
109        self::$initialized = true;
110
111        // Gate the entire SEO surface behind the feature flag.
112        if ( ! (bool) apply_filters( self::FEATURE_FILTER, false ) ) {
113            return;
114        }
115
116        // The opt-in endpoint must be reachable even before the surface is visible, so
117        // existing self-hosted installs can switch to the new experience from the legacy
118        // Traffic page or My Jetpack (JETPACK-1700). Registered ahead of the cohort gate.
119        add_action( 'rest_api_init', array( Surface_Visibility::class, 'register_optin_route' ) );
120
121        // Expose opt-in availability to other admin surfaces (the legacy Traffic-page
122        // banner reads it via `@automattic/jetpack-script-data`). Hooked here — after the
123        // feature flag, before the cohort gate — so a still-hidden install gets the signal.
124        add_filter( 'jetpack_admin_js_script_data', array( Surface_Visibility::class, 'inject_optin_availability' ) );
125
126        // Discoverability cohort gate: the SEO surface is auto-discoverable for fresh
127        // installs and all WordPress.com sites; existing self-hosted installs opt in via
128        // the legacy Traffic page or My Jetpack (JETPACK-1700). Until it's visible we
129        // register nothing else here and let those opt-in surfaces drive discovery.
130        if ( ! self::is_seo_surface_visible() ) {
131            return;
132        }
133
134        // The admin menu and app shell register whenever the surface is visible, even
135        // when the `seo-tools` module is inactive, so SEO stays discoverable and can be
136        // turned on from within the page itself (JETPACK-1700). When the module is off,
137        // the Overview renders only its "enable SEO tools" affordance.
138        //
139        // Priority 1: load the wp-build bundle (and define its render function)
140        // before `add_menu_item()` runs at the default priority and needs it.
141        add_action( 'admin_menu', array( Admin_Page::class, 'maybe_load_wp_build' ), 1 );
142        add_action( 'admin_menu', array( Admin_Page::class, 'add_menu_item' ), 10 );
143
144        // Read-only REST routes the dashboard hydrates its initial state from. Preloaded
145        // into the page (see Admin_Page::inject_script_data) so a normal load resolves
146        // them with no request, and fetched by the app when that preload is missing or
147        // stale — so the dashboard recovers its data instead of dead-ending. Registered
148        // whenever the surface is visible (independent of the seo-tools module, like the
149        // Overview).
150        add_action( 'rest_api_init', array( Dashboard_Data::class, 'register_rest_reads' ) );
151
152        // Keep the Overview's cached content-coverage counts honest. Hooked here rather than
153        // alongside the admin surface above because posts are written from everywhere — the
154        // block editor (REST), the classic editor, wp-cli, cron, other plugins — and the
155        // cache has to be dropped wherever that happens, not just where it's read.
156        Content_Coverage::register_invalidation();
157
158        // The settings surface only comes online once SEO tools are active — there's
159        // nothing to configure while the module is off, so we don't register its REST
160        // endpoints until then. Expose the core `blog_public` option to the REST settings
161        // endpoint so the Settings tab can save search-engine visibility via
162        // `/wp/v2/settings` (the Jetpack settings endpoint only accepts Jetpack options).
163        // Writes are still capability-gated by the core settings controller.
164        if ( self::is_seo_tools_module_active() ) {
165            // Front-end JSON-LD schema output and author profile schema fields.
166            Schema_Builder::init();
167            Author_Schema_Node::init();
168            // GEO tab front-end behavior: the /llms.txt handler. Self-hooks a front-end
169            // action, so it no-ops off the front end and stays behind the same gates as
170            // the schema above.
171            Llms_Txt::init();
172            // GEO tab front-end behavior: robots.txt directives for blocked AI crawlers.
173            // Self-hooks the `robots_txt` filter, so it stays inert off the front end.
174            Ai_Crawlers::init();
175            add_action( 'rest_api_init', array( Dashboard_Data::class, 'register_rest_settings' ) );
176            // Package-owned route for the site-level Schema settings (see the controller).
177            add_action( 'rest_api_init', array( Schema_Settings_Controller::class, 'register_routes' ) );
178        }
179
180        /**
181         * Fires after the Jetpack SEO package is initialized.
182         *
183         * @since 0.1.0
184         */
185        do_action( 'jetpack_seo_init' );
186    }
187
188    /**
189     * Whether the Jetpack SEO surface should be discoverable (admin menu registered).
190     *
191     * @return bool
192     */
193    public static function is_seo_surface_visible() {
194        return Surface_Visibility::is_visible();
195    }
196
197    /**
198     * Whether to offer an existing install the chance to opt into the new SEO experience.
199     *
200     * @return bool
201     */
202    public static function is_optin_available() {
203        return Surface_Visibility::is_optin_available();
204    }
205
206    /**
207     * Whether the `seo-tools` Jetpack module is currently active.
208     *
209     * @return bool
210     */
211    private static function is_seo_tools_module_active() {
212        if ( ! class_exists( 'Automattic\\Jetpack\\Modules' ) ) {
213            return false;
214        }
215        return ( new Modules() )->is_active( 'seo-tools' );
216    }
217}