Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.62% covered (success)
91.62%
448 / 489
80.00% covered (warning)
80.00%
32 / 40
CRAP
0.00% covered (danger)
0.00%
0 / 1
Dashboard_Data
91.62% covered (success)
91.62%
448 / 489
80.00% covered (warning)
80.00%
32 / 40
153.89
0.00% covered (danger)
0.00%
0 / 1
 rest_reads
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 rest_read_paths
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 register_rest_reads
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 permission_check
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 register_rest_settings
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
2
 force_setting_args
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 read_boolean_setting
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 option_names
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 settings_definitions
100.00% covered (success)
100.00%
79 / 79
100.00% covered (success)
100.00%
1 / 1
1
 title_format_schemas
100.00% covered (success)
100.00%
48 / 48
100.00% covered (success)
100.00%
1 / 1
2
 front_page_description_option
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 has_legacy_front_page_meta
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 get_front_page_meta_description
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 get_front_page_meta_description_without_helper
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 is_jetpack_seo_enabled
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 is_jetpack_seo_enabled_without_helper
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 after_setting_write
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 after_settings_request
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
8
 has_module
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 switch_module
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 is_site_private
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 is_site_coming_soon
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 block_publishing_a_private_site
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
6
 sanitize_front_page_description
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 sanitize_title_formats
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
8
 sanitize_verification_codes
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
7
 module_settings
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 register_module_routes
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
 update_modules
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
4
 apply_module_setting
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
6
 get_overview_data
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
2
 normalize_title_formats
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
10
 get_settings_data
97.56% covered (success)
97.56%
40 / 41
0.00% covered (danger)
0.00%
0 / 1
12
 get_google_verify_data
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
3.01
 get_geo_data
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
5
 get_content_data
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 get_output_data
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 get_site_data
62.96% covered (warning)
62.96%
17 / 27
0.00% covered (danger)
0.00%
0 / 1
17.15
 get_default_title_separator
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 get_reachable_sitemap_url
60.00% covered (warning)
60.00%
6 / 10
0.00% covered (danger)
0.00%
0 / 1
10.14
1<?php
2/**
3 * The SEO dashboard's REST surface: the read-only routes behind each data-backed
4 * tab (preloaded onto the page so a normal load resolves them with no request),
5 * the settings the dashboard writes, and the one write that isn't a setting.
6 *
7 * Writes go through WordPress core's `/wp/v2/settings` wherever the value is an
8 * option — see {@see self::register_rest_settings()} — so the dashboard depends
9 * on nothing but core REST. Module activation is the one thing `register_setting()`
10 * can't express, and has its own small route ({@see self::register_module_routes()}).
11 *
12 * @package automattic/jetpack-seo-package
13 */
14
15namespace Automattic\Jetpack\SEO;
16
17use Automattic\Jetpack\Modules;
18use Automattic\Jetpack\Status;
19use Automattic\Jetpack\Status\Host;
20use Jetpack_SEO_Utils;
21use WP_Error;
22use WP_REST_Request;
23
24/**
25 * Registers the dashboard's REST routes and settings, and builds their payloads.
26 */
27class Dashboard_Data {
28
29    /**
30     * REST namespace, shared with the package's other Jetpack routes.
31     *
32     * @var string
33     */
34    const REST_NAMESPACE = 'jetpack/v4';
35
36    /**
37     * Route, relative to the namespace, for the dashboard toggles that map to
38     * Jetpack module activation rather than to an option.
39     *
40     * @var string
41     */
42    const MODULES_REST_BASE = '/seo/modules';
43
44    /**
45     * Settings API group the package registers its options under.
46     *
47     * Only the Settings API's `options.php` form handler reads groups, and the
48     * dashboard doesn't go through it — `register_setting()` requires a group, and
49     * it's otherwise inert here.
50     *
51     * @var string
52     */
53    const SETTINGS_GROUP = 'jetpack_seo';
54
55    /**
56     * Option holding the site's verification service codes, one per service.
57     *
58     * @var string
59     */
60    const VERIFICATION_CODES_OPTION = 'verification_services_codes';
61
62    /**
63     * Option holding the SEO page title structures.
64     *
65     * @var string
66     */
67    const TITLE_FORMATS_OPTION = 'advanced_seo_title_formats';
68
69    /**
70     * Option holding the front page meta description.
71     *
72     * Mirrors `Jetpack_SEO_Utils::FRONT_PAGE_META_OPTION`, which the package can't
73     * rely on being loaded. Doubles as the REST key for the setting, which stays
74     * stable even when the value is stored in the legacy option instead
75     * ({@see self::front_page_description_option()}).
76     *
77     * @var string
78     */
79    const FRONT_PAGE_META_OPTION = 'advanced_seo_front_page_description';
80
81    /**
82     * Option holding the AI SEO Enhancer's on/off state.
83     *
84     * @var string
85     */
86    const AI_SEO_ENHANCER_OPTION = 'ai_seo_enhancer_enabled';
87
88    /**
89     * Legacy option the front page meta description is stored in on WordPress.com
90     * sites that set one while SEO tools were free for every Simple site. Mirrors
91     * `Jetpack_SEO_Utils::LEGACY_META_OPTION`.
92     *
93     * @var string
94     */
95    const LEGACY_FRONT_PAGE_META_OPTION = 'seo_meta_description';
96
97    /**
98     * Map of read-only dashboard routes: tab slug => data-builder callable. The
99     * single source of truth for both the registered routes and the paths
100     * preloaded onto the page, so the two can't drift.
101     *
102     * @return array<string, callable>
103     */
104    private static function rest_reads() {
105        return array(
106            'overview' => array( __CLASS__, 'get_overview_data' ),
107            'settings' => array( __CLASS__, 'get_settings_data' ),
108            'geo'      => array( __CLASS__, 'get_geo_data' ),
109            'content'  => array( __CLASS__, 'get_content_data' ),
110        );
111    }
112
113    /**
114     * REST paths the dashboard reads its initial state from, preloaded into the
115     * page (see {@see Admin_Page::inject_script_data()}) and fetched by the app.
116     *
117     * @return string[]
118     */
119    public static function rest_read_paths() {
120        return array_map(
121            static function ( $slug ) {
122                return '/' . self::REST_NAMESPACE . '/seo/' . $slug;
123            },
124            array_keys( self::rest_reads() )
125        );
126    }
127
128    /**
129     * Register the read-only REST routes the dashboard hydrates from — one per
130     * data-backed tab, each returning the same builder payload previously injected
131     * synchronously onto the page. Read-only and gated to the page's own
132     * `manage_options`; writes still go through their existing endpoints.
133     *
134     * @return void
135     */
136    public static function register_rest_reads() {
137        foreach ( self::rest_reads() as $slug => $builder ) {
138            register_rest_route(
139                self::REST_NAMESPACE,
140                '/seo/' . $slug,
141                array(
142                    'methods'             => \WP_REST_Server::READABLE,
143                    'callback'            => static function () use ( $builder ) {
144                        return rest_ensure_response( call_user_func( $builder ) );
145                    },
146                    'permission_callback' => array( __CLASS__, 'permission_check' ),
147                )
148            );
149        }
150    }
151
152    /**
153     * Capability gate for the dashboard's REST routes — the same `manage_options`
154     * the SEO admin page itself requires, and the same capability the core settings
155     * controller enforces on the settings the dashboard writes there.
156     *
157     * @return bool
158     */
159    public static function permission_check() {
160        return current_user_can( 'manage_options' );
161    }
162
163    /**
164     * Expose every option the SEO dashboard writes to WordPress core's
165     * `/wp/v2/settings` endpoint.
166     *
167     * The dashboard used to save these through the Jetpack plugin's
168     * `/jetpack/v4/settings` route, which doesn't exist everywhere the package
169     * runs — WordPress.com Simple sites boot Jetpack from a small file list that
170     * doesn't include it, so every write 404'd there. Core's settings endpoint is
171     * registered by WordPress itself, needs no new route, and enforces the same
172     * `manage_options` capability, so it works on every platform with one code path.
173     *
174     * Only settings registered with `show_in_rest` round-trip through that
175     * endpoint, and it silently ignores keys that aren't registered — which is why
176     * this runs behind the same `seo-tools` gate as the rest of the settings
177     * surface, and why the dashboard hides its controls when that module is off.
178     *
179     * Hooked on `rest_api_init`, not earlier: which option the front page description is
180     * stored in depends on `jetpack_disable_seo_tools`, and a conflicting SEO plugin can
181     * add that filter from anywhere up to the end of `init`. Registering here settles the
182     * question after every one of those has had its say, and still before core builds the
183     * settings route from the registry at priority 99.
184     *
185     * @return void
186     */
187    public static function register_rest_settings() {
188        add_filter( 'rest_request_before_callbacks', array( Share_Image::class, 'validate_request' ), 10, 3 );
189
190        // Search-engine visibility is a WordPress core option, not a Jetpack one.
191        register_setting(
192            'reading',
193            'blog_public',
194            array(
195                'show_in_rest' => true,
196                'type'         => 'integer',
197                'default'      => 1,
198            )
199        );
200
201        // Refuse an indexing write that would publish a private site. Scoped to core's
202        // settings route rather than attached as `blog_public`'s sanitizer, because a
203        // `register_setting()` sanitizer applies to every writer of the option for the
204        // rest of the request — including the site's own visibility control, which must
205        // stay able to change it.
206        add_filter( 'rest_pre_update_setting', array( __CLASS__, 'block_publishing_a_private_site' ), 10, 3 );
207
208        foreach ( self::settings_definitions() as $option => $args ) {
209            register_setting( self::SETTINGS_GROUP, $option, $args );
210        }
211
212        // `verification_services_codes` is also registered by the verification-tools
213        // module, and the last `register_setting()` for an option name replaces the
214        // registry entry — which would drop the `show_in_rest` exposure the dashboard
215        // saves through, leaving core to ignore the write and still answer 200. Rather
216        // than depend on hook order, force our own arguments back on for the options we
217        // own, whoever registers them and whenever. Added after the loop above so our
218        // own registrations don't recurse through it.
219        add_filter( 'register_setting_args', array( __CLASS__, 'force_setting_args' ), 10, 4 );
220        add_filter( 'rest_pre_get_setting', array( __CLASS__, 'read_boolean_setting' ), 10, 3 );
221
222        // The one thing that has to happen after the write rather than during it:
223        // dropping the superseded legacy front-page option. Both actions pass the
224        // option name first, and `added_option` covers a first write, which
225        // `update_option()` routes through `add_option()`.
226        add_action( 'add_option_' . self::FRONT_PAGE_META_OPTION, array( __CLASS__, 'after_setting_write' ) );
227        add_action( 'update_option_' . self::FRONT_PAGE_META_OPTION, array( __CLASS__, 'after_setting_write' ) );
228        add_filter( 'rest_request_after_callbacks', array( __CLASS__, 'after_settings_request' ), 10, 3 );
229    }
230
231    /**
232     * Keep the REST contract of the options this package owns authoritative, no matter
233     * who else registers them or in what order.
234     *
235     * Only the three arguments that decide whether and how the option round-trips
236     * through `/wp/v2/settings` are forced. Everything else is the caller's: its label
237     * and description still describe its own surface, and leaving its `sanitize_callback`
238     * in place is what keeps the verification-tools module's `jetpack_verification_validate`
239     * attached — and with it the `jetpack_site_verification_validate` action its consumers
240     * listen for. Our own sanitizer is attached by our own `register_setting()` call and
241     * stays attached alongside it, since sanitize filters accumulate.
242     *
243     * @param array  $args     Arguments the caller passed to `register_setting()`.
244     * @param array  $defaults Default arguments.
245     * @param string $group    Setting group (unused).
246     * @param string $option   Option name.
247     * @return array
248     */
249    public static function force_setting_args( $args, $defaults, $group, $option ) {
250        // Cheap membership test first. This filter fires for every `register_setting()`
251        // call in the request — core alone registers ~20 — and building the definitions
252        // rebuilds the title-format schemas and re-decides which option the front page
253        // description lives in, which on WordPress.com Simple is a plan lookup.
254        if ( ! in_array( $option, self::option_names(), true ) ) {
255            return $args;
256        }
257
258        $ours = self::settings_definitions();
259        if ( ! isset( $ours[ $option ] ) ) {
260            return $args;
261        }
262
263        return array_merge(
264            $args,
265            array(
266                'type'         => $ours[ $option ]['type'],
267                // `filter_default_option()` reads this key unconditionally once the option
268                // is registered, so it has to survive a caller that didn't set one.
269                'default'      => $ours[ $option ]['default'],
270                'show_in_rest' => $ours[ $option ]['show_in_rest'],
271            )
272        );
273    }
274
275    /**
276     * Normalize false options stored as empty strings so core REST returns a boolean, not null.
277     *
278     * @param mixed  $value Existing filtered setting value.
279     * @param string $name REST setting name.
280     * @param array  $args REST setting registration arguments.
281     * @return mixed Filtered setting value.
282     */
283    public static function read_boolean_setting( $value, $name, $args ) {
284        if ( null !== $value || ! in_array( $name, array( self::AI_SEO_ENHANCER_OPTION, Llms_Txt::OPTION ), true ) ) {
285            return $value;
286        }
287
288        return (bool) get_option( $args['option_name'], $args['schema']['default'] );
289    }
290
291    /**
292     * Every option name this package can own, including both homes the front page
293     * description can have. A flat list so {@see self::force_setting_args()} can rule
294     * an option out without building the definitions.
295     *
296     * @return string[]
297     */
298    private static function option_names() {
299        return array(
300            Share_Image::OPTION,
301            self::AI_SEO_ENHANCER_OPTION,
302            Llms_Txt::OPTION,
303            Ai_Crawlers::OPTION,
304            self::TITLE_FORMATS_OPTION,
305            self::VERIFICATION_CODES_OPTION,
306            self::FRONT_PAGE_META_OPTION,
307            self::LEGACY_FRONT_PAGE_META_OPTION,
308        );
309    }
310
311    /**
312     * The SEO options exposed to `/wp/v2/settings`, keyed by option name.
313     *
314     * Types, defaults and sanitizers mirror what `/jetpack/v4/settings` enforces for
315     * the keys it also exposes, so a save behaves the same through either route.
316     *
317     * @return array<string, array>
318     */
319    private static function settings_definitions() {
320        return array(
321            Share_Image::OPTION                   => array(
322                'type'         => 'integer',
323                'default'      => 0,
324                'show_in_rest' => array(
325                    'schema' => array(
326                        'type'    => 'integer',
327                        'minimum' => 0,
328                    ),
329                ),
330            ),
331            self::AI_SEO_ENHANCER_OPTION          => array(
332                'type'         => 'boolean',
333                'default'      => false,
334                'show_in_rest' => true,
335            ),
336            Llms_Txt::OPTION                      => array(
337                'type'         => 'boolean',
338                'default'      => false,
339                'show_in_rest' => true,
340            ),
341            Ai_Crawlers::OPTION                   => array(
342                'type'              => 'object',
343                'default'           => array(),
344                'sanitize_callback' => array( Ai_Crawlers::class, 'sanitize_blocked' ),
345                'show_in_rest'      => array(
346                    'schema' => array(
347                        'type'       => 'object',
348                        'properties' => array_fill_keys(
349                            Ai_Crawlers::GROUPS,
350                            array(
351                                // Core needs `type` here as well as in `anyOf` (rest_default_additional_properties_to_false()).
352                                'type'  => array( 'string', 'array' ),
353                                'anyOf' => array(
354                                    array(
355                                        'type' => 'string',
356                                        'enum' => array( 'all' ),
357                                    ),
358                                    array(
359                                        'type'  => 'array',
360                                        'items' => array( 'type' => 'string' ),
361                                    ),
362                                ),
363                            )
364                        ),
365                    ),
366                ),
367            ),
368            self::TITLE_FORMATS_OPTION            => array(
369                'type'              => 'object',
370                'default'           => array(),
371                'sanitize_callback' => array( __CLASS__, 'sanitize_title_formats' ),
372                'show_in_rest'      => array(
373                    'schema' => array(
374                        'type'       => 'object',
375                        'properties' => self::title_format_schemas(),
376                    ),
377                ),
378            ),
379            self::VERIFICATION_CODES_OPTION       => array(
380                'type'              => 'object',
381                'default'           => array(),
382                'sanitize_callback' => array( __CLASS__, 'sanitize_verification_codes' ),
383                'show_in_rest'      => array(
384                    'schema' => array(
385                        'type'       => 'object',
386                        'properties' => array_fill_keys(
387                            array( 'google', 'bing', 'pinterest', 'yandex', 'facebook' ),
388                            array( 'type' => 'string' )
389                        ),
390                    ),
391                ),
392            ),
393            self::front_page_description_option() => array(
394                'type'              => 'string',
395                'default'           => '',
396                'sanitize_callback' => array( __CLASS__, 'sanitize_front_page_description' ),
397                // The REST key stays the modern option name even when the value lives in
398                // the legacy one, so the client never has to know which is in play.
399                'show_in_rest'      => array( 'name' => self::FRONT_PAGE_META_OPTION ),
400            ),
401        );
402    }
403
404    /**
405     * Per-page-type schema for a title structure, restoring the contract
406     * `Jetpack_SEO_Titles::are_valid_title_formats()` enforced on the Jetpack settings
407     * endpoint: both keys are required (the renderer reads `type` on every public
408     * request), and a token has to be one this page type actually offers.
409     *
410     * @return array<string, array>
411     */
412    private static function title_format_schemas() {
413        // Mirrors `Jetpack_SEO_Titles::get_allowed_tokens()`. Duplicated rather than
414        // called: that class ships with the Jetpack plugin, and the schema has to hold
415        // wherever the package runs.
416        $allowed_tokens = array(
417            'front_page' => array( 'site_name', 'tagline' ),
418            'posts'      => array( 'site_name', 'tagline', 'post_title' ),
419            'pages'      => array( 'site_name', 'tagline', 'page_title' ),
420            'groups'     => array( 'site_name', 'tagline', 'group_title' ),
421            'archives'   => array( 'site_name', 'tagline', 'date', 'archive_title' ),
422        );
423
424        $schemas = array();
425        foreach ( $allowed_tokens as $page_type => $tokens ) {
426            $schemas[ $page_type ] = array(
427                'type'  => 'array',
428                'items' => array(
429                    'type'       => 'object',
430                    // Both keys required: the front-end renderer reads `type` on every
431                    // public request, so a token missing it must never reach storage.
432                    'required'   => array( 'type', 'value' ),
433                    'properties' => array(
434                        'type'  => array(
435                            'type' => 'string',
436                            'enum' => array( 'string', 'token' ),
437                        ),
438                        'value' => array( 'type' => 'string' ),
439                    ),
440                    // Which values `value` may take depends on `type`, which one flat
441                    // object can't say. Core applies `oneOf` first and then the shape
442                    // above, so the two together are the old helper's contract.
443                    'oneOf'      => array(
444                        array(
445                            'type'       => 'object',
446                            'properties' => array(
447                                'type' => array(
448                                    'type' => 'string',
449                                    'enum' => array( 'string' ),
450                                ),
451                            ),
452                        ),
453                        array(
454                            'type'       => 'object',
455                            'properties' => array(
456                                'type'  => array(
457                                    'type' => 'string',
458                                    'enum' => array( 'token' ),
459                                ),
460                                'value' => array(
461                                    'type' => 'string',
462                                    'enum' => $tokens,
463                                ),
464                            ),
465                        ),
466                    ),
467                ),
468            );
469        }
470
471        return $schemas;
472    }
473
474    /**
475     * The option the front page meta description is stored in for this site.
476     *
477     * A WordPress.com site that set a description while SEO tools were free for every
478     * Simple site keeps editing that legacy option while it's plan-gated, because the
479     * front end still reads the description from there. Decided from the site's own
480     * state — the gate and the stored value — rather than from `Jetpack_SEO_Utils`,
481     * which ships with the Jetpack plugin: reading it through `class_exists()` would
482     * quietly answer "modern" wherever the plugin's SEO files aren't loaded, and the
483     * save would then overwrite the wrong option and drop a live legacy description.
484     *
485     * @return string
486     */
487    private static function front_page_description_option() {
488        return self::has_legacy_front_page_meta()
489            ? self::LEGACY_FRONT_PAGE_META_OPTION
490            : self::FRONT_PAGE_META_OPTION;
491    }
492
493    /**
494     * Whether the front page description still lives in the legacy option — Jetpack's
495     * SEO output is off for this site and a description is stored there, so that's what
496     * the front end reads and what an edit has to write back to.
497     *
498     * The rule `Jetpack_SEO_Utils::has_legacy_front_page_meta()` applies, evaluated from
499     * the site's own state so that the read path, the write path and the front end can't
500     * disagree on a site where that class isn't loaded.
501     *
502     * @return bool
503     */
504    private static function has_legacy_front_page_meta() {
505        return ! self::is_jetpack_seo_enabled() && (bool) get_option( self::LEGACY_FRONT_PAGE_META_OPTION );
506    }
507
508    /**
509     * The front page meta description this site's front end actually emits.
510     *
511     * `Jetpack_SEO_Utils::get_front_page_meta_description()` reproduced for sites that
512     * don't load it — which includes WordPress.com Simple, where reading it through
513     * `class_exists()` returned `''` for every site and left the Settings field blank
514     * over a live description. Same shape as {@see self::is_jetpack_seo_enabled()}:
515     * defer to the helper wherever it's loaded, reproduce it exactly everywhere else,
516     * so the field the user edits is the value the front end reads.
517     *
518     * @return string
519     */
520    private static function get_front_page_meta_description() {
521        if ( class_exists( 'Jetpack_SEO_Utils' ) ) {
522            // @phan-suppress-next-line PhanUndeclaredClassMethod -- Jetpack_SEO_Utils lives in plugins/jetpack and is guarded by class_exists.
523            return (string) Jetpack_SEO_Utils::get_front_page_meta_description();
524        }
525
526        return self::get_front_page_meta_description_without_helper();
527    }
528
529    /**
530     * `Jetpack_SEO_Utils::get_front_page_meta_description()` reproduced for sites that
531     * don't load it — which includes WordPress.com Simple.
532     *
533     * Split out from {@see self::get_front_page_meta_description()} so this branch is
534     * reachable from a test even where the helper class is loaded, the same way
535     * {@see self::is_jetpack_seo_enabled_without_helper()} is.
536     *
537     * @return string
538     */
539    private static function get_front_page_meta_description_without_helper() {
540        if ( self::is_jetpack_seo_enabled_without_helper() ) {
541            $modern = get_option( self::FRONT_PAGE_META_OPTION );
542            return (string) ( $modern ? $modern : get_option( self::LEGACY_FRONT_PAGE_META_OPTION, '' ) );
543        }
544
545        return (string) get_option( self::LEGACY_FRONT_PAGE_META_OPTION, '' );
546    }
547
548    /**
549     * Whether Jetpack's SEO output is live for this site, which is what decides
550     * where the front page description is stored and read from.
551     *
552     * Defers to `Jetpack_SEO_Utils` wherever it's loaded, because that helper is what the
553     * front end reads the description by and the two must not disagree. Everywhere else
554     * {@see self::is_jetpack_seo_enabled_without_helper()} reproduces it.
555     *
556     * @return bool
557     */
558    private static function is_jetpack_seo_enabled() {
559        if ( class_exists( 'Jetpack_SEO_Utils' ) ) {
560            // @phan-suppress-next-line PhanUndeclaredClassMethod -- Jetpack_SEO_Utils lives in plugins/jetpack and is guarded by class_exists.
561            return (bool) Jetpack_SEO_Utils::is_enabled_jetpack_seo();
562        }
563
564        return self::is_jetpack_seo_enabled_without_helper();
565    }
566
567    /**
568     * `Jetpack_SEO_Utils::is_enabled_jetpack_seo()` reproduced for sites that don't load
569     * it — which includes WordPress.com Simple, where this decides which option a save
570     * targets.
571     *
572     * Exactly, not approximately: the conflicting-plugin filter, then the plan gate on
573     * WordPress.com **Simple only**. The helper plan-gates behind `IS_WPCOM`, so an
574     * Atomic site is never gated by it even without `advanced-seo`, and a wider copy
575     * would send that site's edits to the legacy option while its front end kept reading
576     * the modern one.
577     *
578     * Split out from {@see self::is_jetpack_seo_enabled()} so this branch is reachable
579     * from a test even where the helper class is loaded.
580     *
581     * @return bool
582     */
583    private static function is_jetpack_seo_enabled_without_helper() {
584        if ( Seo_Tools::is_disabled() ) {
585            return false;
586        }
587
588        // `Initializer::is_gated()` on Simple reduces to the helper's own
589        // `wpcom_site_has_feature( 'advanced-seo' )` check.
590        return ( new Host() )->is_wpcom_simple() ? ! Initializer::is_gated() : true;
591    }
592
593    /**
594     * Drop the superseded legacy front page description once a modern one is saved.
595     *
596     * The front end falls back to the legacy option whenever the modern value is
597     * empty, so leaving it in place would let a cleared description resurrect text the
598     * site set years ago. Matches what the Jetpack settings endpoint did on the same
599     * write, including its condition: only where Jetpack's SEO output is actually live,
600     * so a gated site's legacy description is never touched.
601     *
602     * Hooked to the option-scoped `add_option_*` / `update_option_*` actions rather
603     * than the site-wide ones, so it fires on a real change to this one option instead
604     * of being consulted on every option write in the request.
605     *
606     * @return void
607     */
608    public static function after_setting_write() {
609        if ( self::is_jetpack_seo_enabled() ) {
610            delete_option( self::LEGACY_FRONT_PAGE_META_OPTION );
611        }
612    }
613
614    /**
615     * Clear legacy text after a successful empty save, including an unchanged modern option.
616     *
617     * An unchanged option does not fire the add/update hooks, so an explicit REST clear
618     * must also remove legacy text that the front end would otherwise use as a fallback.
619     *
620     * @param mixed           $response REST response.
621     * @param array           $handler  Route handler (unused).
622     * @param WP_REST_Request $request  REST request.
623     * @return mixed
624     */
625    public static function after_settings_request( $response, $handler, WP_REST_Request $request ) {
626        if ( is_wp_error( $response ) || rest_ensure_response( $response )->get_status() >= 400
627            || ! preg_match( '#^/wp/v2/(?:sites/[^/]+/)?settings$#', $request->get_route() )
628            || ! in_array( $request->get_method(), array( 'POST', 'PUT', 'PATCH' ), true )
629            || '' !== $request->get_param( self::FRONT_PAGE_META_OPTION ) ) {
630            return $response;
631        }
632
633        $settings = get_registered_settings();
634        if ( isset( $settings[ self::FRONT_PAGE_META_OPTION ] ) && '' === get_option( self::FRONT_PAGE_META_OPTION, '' ) ) {
635            self::after_setting_write();
636        }
637
638        return $response;
639    }
640
641    /**
642     * Whether this site has the given Jetpack module at all.
643     *
644     * Deliberately not `Modules::is_module()`: that's a path-traversal check, and it
645     * passes every slug on a site whose module list is empty — which is the case this
646     * has to detect. WordPress.com Simple ships no Jetpack modules and provides the
647     * behavior itself.
648     *
649     * @param string $module Module slug.
650     * @return bool
651     */
652    private static function has_module( $module ) {
653        // Simple's platform-managed generator is not controlled by module activation.
654        if ( 'sitemaps' === $module && ( new Host() )->is_wpcom_simple() ) {
655            return false;
656        }
657
658        return in_array( $module, ( new Modules() )->get_available(), true );
659    }
660
661    /**
662     * Switch a module without persisting wpcomsh's temporary private-site suppression.
663     *
664     * `Modules::is_active()` reads through `jetpack_active_modules`, where wpcomsh
665     * hides `sitemaps` and `verification-tools` on private and coming-soon sites.
666     * That filtered state cannot tell whether a saved preference is already off:
667     * it reports false even when the module remains in the site's stored list.
668     * Deactivation also reads that list through the filter, so lifting the callback
669     * only for the initial check or final read-back would still leave the switch broken.
670     *
671     * Lift only wpcomsh's callback for the entire read-modify-write. Other filters
672     * still count, so a module genuinely held off remains an error. An isolated hook
673     * copy and the finally block restore the original suppression even after a failure;
674     * saving a module preference does not change the site's visibility.
675     *
676     * @param string $module  Module slug.
677     * @param bool   $enabled Whether the setting is on.
678     * @return bool Whether the module now matches the setting.
679     */
680    private static function switch_module( $module, $enabled ) {
681        global $wp_filter;
682
683        $modules  = new Modules();
684        $hook     = 'jetpack_active_modules';
685        $callback = '\Private_Site\filter_jetpack_active_modules';
686        $priority = has_filter( $hook, $callback );
687        $original = null;
688
689        if ( false !== $priority ) {
690            $original = $wp_filter[ $hook ];
691
692            // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restore the original hook after the switch.
693            $wp_filter[ $hook ] = clone $original;
694            remove_filter( $hook, $callback, $priority );
695        }
696
697        try {
698            if ( $modules->is_active( $module ) === $enabled ) {
699                return true;
700            }
701
702            $modules->update_status( $module, $enabled, false, false );
703
704            return $modules->is_active( $module ) === $enabled;
705        } finally {
706            if ( null !== $original ) {
707                // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restore the untouched original hook.
708                $wp_filter[ $hook ] = $original;
709            }
710        }
711    }
712
713    /**
714     * Whether a WordPress.com site is unpublished: private (a negative `blog_public`)
715     * or Coming Soon. Always false on self-hosted, where the option is a plain 0/1.
716     *
717     * @return bool
718     */
719    private static function is_site_private() {
720        return (int) get_option( 'blog_public', 1 ) < 0 || self::is_site_coming_soon();
721    }
722
723    /**
724     * Whether the site is in WordPress.com's Coming Soon mode, which normally keeps
725     * `blog_public` at 0, so only the platform's own flag tells it from a discouraged site.
726     *
727     * @return bool
728     */
729    private static function is_site_coming_soon() {
730        return ( new Host() )->is_wpcom_platform() && (bool) ( new Status() )->is_coming_soon();
731    }
732
733    /**
734     * Refuse an indexing write that would publish a private WordPress.com site or launch a Coming Soon one.
735     *
736     * Needed because jetpack-mu-wpcom ends Coming Soon on any `blog_public` move away from 0. Hooked on
737     * core's settings route, not the option's sanitizer, so the site's own visibility control still works.
738     *
739     * @param bool   $handled Whether another handler already wrote the setting.
740     * @param string $name    Setting name.
741     * @param mixed  $value   Submitted value.
742     * @return bool True to report the write as handled, which skips it.
743     */
744    public static function block_publishing_a_private_site( $handled, $name, $value ) {
745        if ( $handled || 'blog_public' !== $name ) {
746            return $handled;
747        }
748
749        if ( (int) get_option( 'blog_public', 1 ) < 0 ) {
750            return (int) $value >= 0;
751        }
752
753        // A null write deletes the option, which reads back as the registered default of 1.
754        return self::is_site_coming_soon() && ( null === $value || (int) $value > 0 );
755    }
756
757    /**
758     * Clamp the front page meta description to plain text of a bounded length.
759     *
760     * @param string $value Submitted description.
761     * @return string
762     */
763    public static function sanitize_front_page_description( $value ) {
764        $value = wp_strip_all_tags( (string) $value );
765
766        /** This filter is documented in projects/plugins/jetpack/modules/seo-tools/class-jetpack-seo-utils.php */
767        $max_length = (int) apply_filters( 'jetpack_seo_front_page_description_max_length', 300 );
768
769        return function_exists( 'mb_substr' )
770            ? mb_substr( $value, 0, $max_length )
771            : substr( $value, 0, $max_length );
772    }
773
774    /**
775     * Strip markup out of the literal text a title structure joins its tokens with.
776     *
777     * Deliberately not `wp_strip_all_tags()`: that trims, and the spacing around a
778     * separator ("Post Title | Site Name") is exactly what the user typed and has to
779     * survive. Token values are left alone — the schema already limits a token to a
780     * string, and an unrecognized one simply renders as itself.
781     *
782     * @param array $value Submitted title structures, keyed by page type.
783     * @return array
784     */
785    public static function sanitize_title_formats( $value ) {
786        if ( ! is_array( $value ) ) {
787            return array();
788        }
789
790        foreach ( $value as $page_type => $tokens ) {
791            if ( ! is_array( $tokens ) ) {
792                unset( $value[ $page_type ] );
793                continue;
794            }
795
796            foreach ( $tokens as $index => $token ) {
797                if ( ! isset( $token['value'] ) || ! isset( $token['type'] ) || 'string' !== $token['type'] ) {
798                    continue;
799                }
800
801                $text = preg_replace( '@<(script|style)[^>]*?>.*?</\\1>@si', '', (string) $token['value'] );
802                $text = strip_tags( $text ); // phpcs:ignore WordPress.WP.AlternativeFunctions.strip_tags_strip_tags -- wp_strip_all_tags() trims, and leading/trailing spacing is meaningful here.
803
804                $value[ $page_type ][ $index ]['value'] = preg_replace( '/[\r\n\t ]+/', ' ', $text );
805            }
806        }
807
808        return $value;
809    }
810
811    /**
812     * Reduce each submitted verification entry to the bare code, merged over the
813     * codes already stored.
814     *
815     * Services hand out a whole `<meta name="…" content="…">` tag and site owners
816     * paste it verbatim, so keep only the content attribute — the same thing the
817     * verification-tools module does when it renders the tag. Merging rather than
818     * replacing keeps any service the dashboard doesn't offer intact.
819     *
820     * @param array $value Submitted codes, keyed by service.
821     * @return array
822     */
823    public static function sanitize_verification_codes( $value ) {
824        $stored = get_option( self::VERIFICATION_CODES_OPTION, array() );
825        if ( ! is_array( $stored ) ) {
826            $stored = array();
827        }
828
829        if ( ! is_array( $value ) ) {
830            return $stored;
831        }
832
833        foreach ( $value as $service => $code ) {
834            $service = sanitize_key( $service );
835            $code    = (string) $code;
836
837            // Anything that isn't already a bare code has to yield one, by unwrapping the
838            // whole `<meta …>` tag a service hands out. A value that yields no code at
839            // all is not a verification code, and the stored one is kept rather than
840            // overwritten: the old `/jetpack/v4/settings` route rejected that input with
841            // a 400 and left the saved code intact, and silently destroying it is the
842            // worse of the two failures. (Keeping it also survives the verification-tools
843            // module's own sanitizer running after ours, which sees a valid bare code.)
844            // An empty string is a deliberate clear, not a failed parse, so it passes.
845            if ( '' !== $code && ! preg_match( '/^[a-z0-9_-]+$/i', $code ) ) {
846                if ( ! preg_match( '/content=["\']?([^"\' ]*)["\' ]/i', $code, $matches ) ) {
847                    continue;
848                }
849                $code = urldecode( $matches[1] );
850            }
851
852            // 100 chars is the cap the verification-tools module stores by.
853            $stored[ $service ] = substr( sanitize_text_field( trim( $code ) ), 0, 100 );
854        }
855
856        return $stored;
857    }
858
859    /**
860     * Map the dashboard's module-backed fields to module slugs.
861     *
862     * These are the settings core's `/wp/v2/settings` cannot own. Switching a module
863     * can fail, and that failure must reach the user rather than return a successful
864     * response with an unchanged stored value. A `register_setting()` sanitizer is
865     * also the wrong place for the switch: it runs before the write and can run on
866     * paths where nothing is persisted. The module route switches the module, checks
867     * the result, and returns a real error when the requested state was not reached.
868     *
869     * @return array<string, string>
870     */
871    private static function module_settings() {
872        return array(
873            'sitemap_active'            => 'sitemaps',
874            'canonical_active'          => 'canonical-urls',
875            'verification_tools_active' => 'verification-tools',
876        );
877    }
878
879    /**
880     * Register the write route for the dashboard settings that switch a Jetpack module.
881     *
882     * Write-only: the current state is already served by `/jetpack/v4/seo/settings`,
883     * which the dashboard reads on load.
884     *
885     * @return void
886     */
887    public static function register_module_routes() {
888        $args = array();
889        foreach ( array_keys( self::module_settings() ) as $field ) {
890            // All optional: the dashboard sends only what changed.
891            $args[ $field ] = array( 'type' => 'boolean' );
892        }
893
894        register_rest_route(
895            self::REST_NAMESPACE,
896            self::MODULES_REST_BASE,
897            array(
898                'methods'             => \WP_REST_Server::EDITABLE,
899                'callback'            => array( __CLASS__, 'update_modules' ),
900                'permission_callback' => array( __CLASS__, 'permission_check' ),
901                'args'                => $args,
902            )
903        );
904    }
905
906    /**
907     * Apply the submitted module-backed settings, and report what was applied.
908     *
909     * @param WP_REST_Request $request The REST request.
910     * @return \WP_REST_Response|WP_Error
911     */
912    public static function update_modules( WP_REST_Request $request ) {
913        $applied = array();
914
915        foreach ( self::module_settings() as $field => $module ) {
916            if ( null === $request[ $field ] ) {
917                continue;
918            }
919
920            $enabled = (bool) $request[ $field ];
921
922            $error = self::apply_module_setting( $module, $enabled );
923            if ( is_wp_error( $error ) ) {
924                // No rollback across fields — module activation fires hooks that other
925                // code has already reacted to, and undoing those is riskier than the
926                // inconsistency. Report which field failed and what did land instead, so
927                // the client can be specific about it.
928                $error->add_data(
929                    array_merge(
930                        (array) $error->get_error_data(),
931                        array(
932                            'field'   => $field,
933                            'applied' => $applied,
934                        )
935                    )
936                );
937                return $error;
938            }
939
940            $applied[ $field ] = $enabled;
941        }
942
943        return rest_ensure_response( $applied );
944    }
945
946    /**
947     * Switch a module and report a failure instead of saving a duplicate option.
948     *
949     * Module state is the source of truth; there is no second option to write or roll
950     * back. Simple has no modules, so its canonical switch is {@see Canonical_Urls::OPTION}.
951     *
952     * @param string $module  Module slug.
953     * @param bool   $enabled Requested state.
954     * @return true|WP_Error
955     */
956    private static function apply_module_setting( $module, $enabled ) {
957        if ( 'canonical-urls' === $module && ( new Host() )->is_wpcom_simple() ) {
958            update_option( Canonical_Urls::OPTION, $enabled ? 1 : 0 );
959            return true;
960        }
961
962        if ( ! self::has_module( $module ) ) {
963            return new WP_Error(
964                'jetpack_seo_module_unavailable',
965                /* translators: %s: name of a Jetpack module, e.g. "sitemaps". */
966                sprintf( __( 'This site has no %s module to switch.', 'jetpack-seo' ), $module ),
967                array( 'status' => 400 )
968            );
969        }
970
971        if ( self::switch_module( $module, $enabled ) ) {
972            return true;
973        }
974
975        return new WP_Error(
976            'jetpack_seo_module_toggle_failed',
977            /* translators: %s: name of a Jetpack module, e.g. "sitemaps". */
978            sprintf( __( 'The %s module could not be switched.', 'jetpack-seo' ), $module ),
979            array( 'status' => 500 )
980        );
981    }
982
983    /**
984     * Build the aggregated Overview state the dashboard renders.
985     *
986     * @return array
987     */
988    public static function get_overview_data() {
989        $modules = new Modules();
990
991        $codes = get_option( 'verification_services_codes', array() );
992        if ( ! is_array( $codes ) ) {
993            $codes = array();
994        }
995
996        return array(
997            'site_visibility'         => array(
998                'search_engines_visible' => (int) get_option( 'blog_public', 1 ) === 1,
999                'site_is_private'        => self::is_site_private(),
1000                'sitemap_active'         => $modules->is_active( 'sitemaps' ),
1001                'seo_tools_active'       => $modules->is_active( 'seo-tools' ),
1002            ),
1003            // Per-service booleans (a code is set or not) for the Overview's
1004            // Site verification card.
1005            'site_verification'       => array(
1006                'google'    => ! empty( $codes['google'] ),
1007                'bing'      => ! empty( $codes['bing'] ),
1008                'pinterest' => ! empty( $codes['pinterest'] ),
1009                'yandex'    => ! empty( $codes['yandex'] ),
1010                'facebook'  => ! empty( $codes['facebook'] ),
1011            ),
1012            'content_coverage'        => Content_Coverage::get(),
1013            // Whether the site-verification toggle can do anything here. False where
1014            // that module isn't present — WordPress.com Simple ships no Jetpack modules
1015            // and reports every one of them active — so the dashboard hides the control
1016            // rather than offering a toggle the route would refuse.
1017            'verification_switchable' => self::has_module( 'verification-tools' ),
1018        );
1019    }
1020
1021    /**
1022     * Coerce the stored title formats into one `{ type, value }` token list per page type.
1023     *
1024     * The site-settings API stores a cleared page type as `''` (see JETPACK-2284), so the
1025     * option is looser than the Settings tab's type; anything not a token list becomes `array()`.
1026     *
1027     * @since 0.9.5
1028     *
1029     * @param mixed $stored Raw option value.
1030     * @return array<string, array<int, array{type: string, value: string}>>
1031     */
1032    public static function normalize_title_formats( $stored ) {
1033        if ( ! is_array( $stored ) ) {
1034            return array();
1035        }
1036
1037        $normalized = array();
1038        foreach ( $stored as $page_type => $format ) {
1039            $tokens = array();
1040            if ( is_array( $format ) ) {
1041                foreach ( $format as $item ) {
1042                    if ( is_array( $item ) && isset( $item['type'] ) && isset( $item['value'] ) && is_string( $item['type'] ) && is_string( $item['value'] ) ) {
1043                        $tokens[] = array(
1044                            'type'  => $item['type'],
1045                            'value' => $item['value'],
1046                        );
1047                    }
1048                }
1049            }
1050            $normalized[ (string) $page_type ] = $tokens;
1051        }
1052
1053        return $normalized;
1054    }
1055
1056    /**
1057     * Build the editable Settings state the Settings tab hydrates from.
1058     *
1059     * Read-only bootstrap only. Writes go to core's `/wp/v2/settings` for every field
1060     * backed by an option alone ({@see self::register_rest_settings()}), and to the
1061     * package's own routes for the three settings whose write switches a Jetpack module
1062     * — sitemap, canonical URLs and site verification ({@see self::module_settings()})
1063     * — and for the nested Schema container; bootstrapping them all here keeps the
1064     * Settings UI hydrated without a second request.
1065     *
1066     * @return array
1067     */
1068    public static function get_settings_data() {
1069        $modules = new Modules();
1070
1071        // Read the stored values directly: Jetpack_SEO_Titles::get_custom_title_formats()
1072        // intentionally hides them while another SEO plugin controls output, but the
1073        // dashboard must still show the saved values without allowing edits.
1074        $title_formats          = self::normalize_title_formats( get_option( 'advanced_seo_title_formats', array() ) );
1075        $title_formats_editable = self::is_jetpack_seo_enabled();
1076        $front_page_desc        = self::get_front_page_meta_description();
1077
1078        if ( Seo_Tools::is_disabled() ) {
1079            // Same for the description, which the helper swaps for the legacy fallback while
1080            // another plugin owns the output. Show the one Jetpack resumes with; it's locked.
1081            $modern          = get_option( self::FRONT_PAGE_META_OPTION );
1082            $front_page_desc = $modern ? $modern : get_option( self::LEGACY_FRONT_PAGE_META_OPTION, '' );
1083        }
1084
1085        // A site that set a front-page description back when it was free for all
1086        // WordPress.com Simple sites keeps editing it, even when otherwise plan-gated:
1087        // the value stays live in the legacy option. The gated Settings uses this to keep
1088        // that one field editable, and it's the same rule the save writes by.
1089        $has_legacy_front_page_meta = self::has_legacy_front_page_meta();
1090
1091        $codes = get_option( 'verification_services_codes', array() );
1092        if ( ! is_array( $codes ) ) {
1093            $codes = array();
1094        }
1095
1096        $sitemap_active = $modules->is_active( 'sitemaps' );
1097        $simple         = ( new Host() )->is_wpcom_simple();
1098
1099        // Ask the conflict check, not the disable filter: anyone can hook that filter just to turn the tags off.
1100        // @phan-suppress-next-line PhanUndeclaredFunction -- guarded by function_exists; lives in plugins/jetpack.
1101        $canonical_conflicted = function_exists( 'jetpack_canonical_urls_check_conflicts' ) && jetpack_canonical_urls_check_conflicts( false );
1102
1103        return array(
1104            'search_engines_visible'     => (int) get_option( 'blog_public', 1 ) === 1,
1105            // A private or Coming Soon WordPress.com site isn't hidden from search by an
1106            // SEO setting and can't be unhidden by one — see {@see self::block_publishing_a_private_site()}.
1107            'site_is_private'            => self::is_site_private(),
1108            'site_is_coming_soon'        => self::is_site_coming_soon(),
1109            'site_visibility_url'        => Ai_Crawlers::privacy_settings_url(),
1110            'sitemap_active'             => $sitemap_active,
1111            'sitemap_switchable'         => self::has_module( 'sitemaps' ),
1112            // The reachable sitemap URL (Jetpack serves a valid sitemap here as soon as
1113            // it's on + the site is public), or '' when sitemaps are off, so the Settings
1114            // tab shows the "View sitemap" link exactly when there's a sitemap to view.
1115            'sitemap_url'                => self::get_reachable_sitemap_url( $sitemap_active ),
1116            // Simple reports every module active, so read its own switch ({@see Canonical_Urls}).
1117            'canonical_active'           => $simple ? Canonical_Urls::is_enabled() : $modules->is_active( 'canonical-urls' ),
1118            'canonical_switchable'       => $simple || self::has_module( 'canonical-urls' ),
1119            'canonical_conflicted'       => $canonical_conflicted,
1120            // Cast to object so an empty format set serializes as `{}`, not `[]`.
1121            'title_formats'              => (object) $title_formats,
1122            // Separator WordPress joins default document-title parts with. A page type
1123            // with no stored format keeps the default title: `get_custom_title()` returns
1124            // the incoming value untouched, so core composes the title itself and the
1125            // Settings tab replays that composition to preview it.
1126            'title_separator'            => self::get_default_title_separator(),
1127            'title_formats_editable'     => $title_formats_editable,
1128            'default_share_image'        => Share_Image::image( (int) get_option( Share_Image::OPTION, 0 ) ),
1129            'front_page_description'     => (string) $front_page_desc,
1130            'has_legacy_front_page_meta' => $has_legacy_front_page_meta,
1131            'verification_tools_active'  => $modules->is_active( 'verification-tools' ),
1132            'verification'               => array(
1133                'google'    => isset( $codes['google'] ) ? (string) $codes['google'] : '',
1134                'bing'      => isset( $codes['bing'] ) ? (string) $codes['bing'] : '',
1135                'pinterest' => isset( $codes['pinterest'] ) ? (string) $codes['pinterest'] : '',
1136                'yandex'    => isset( $codes['yandex'] ) ? (string) $codes['yandex'] : '',
1137                'facebook'  => isset( $codes['facebook'] ) ? (string) $codes['facebook'] : '',
1138            ),
1139            'schema'                     => Schema_Settings::get_editable(),
1140        );
1141    }
1142
1143    /**
1144     * Build the Google site-verification state for the Settings tab.
1145     *
1146     * The Settings verification card lets a connected user verify with Google via a
1147     * WordPress.com keyring OAuth popup (in addition to pasting a meta-tag code). This
1148     * bootstraps the keyring connect URL and whether the current user is connected —
1149     * the live verified status is fetched client-side from `/jetpack/v4/verify-site/google`
1150     * (a wpcom round-trip we don't want to make on every page load).
1151     *
1152     * Both `Keyring_Helper` (Publicize package) and the connection `Manager` are provided
1153     * by the host Jetpack plugin, so they're guarded with `class_exists` like the
1154     * `Jetpack_SEO_*` helpers. On a disconnected self-hosted site `is_connected` is false
1155     * and the UI falls back to manual code entry only.
1156     *
1157     * @return array
1158     */
1159    public static function get_google_verify_data() {
1160        $connect_url = '';
1161        if ( class_exists( 'Automattic\\Jetpack\\Publicize\\Keyring_Helper' ) ) {
1162            // @phan-suppress-next-line PhanUndeclaredClassMethod -- guarded; Publicize package is provided by the host plugin.
1163            $connect_url = (string) \Automattic\Jetpack\Publicize\Keyring_Helper::connect_url( 'google_site_verification', 'other' );
1164        }
1165
1166        $is_connected = false;
1167        if ( class_exists( 'Automattic\\Jetpack\\Connection\\Manager' ) ) {
1168            $is_connected = ( new \Automattic\Jetpack\Connection\Manager() )->is_user_connected();
1169        }
1170
1171        return array(
1172            'connect_url'  => $connect_url,
1173            'is_connected' => (bool) $is_connected,
1174        );
1175    }
1176
1177    /**
1178     * Build the GEO tab's initial state.
1179     *
1180     * The AI SEO Enhancer auto-generates SEO titles/descriptions/alt-text in the
1181     * editor (the generation itself is wpcom/AI-Assistant side); this exposes only
1182     * its persisted on/off toggle, whether it's available, and whether the AI SEO
1183     * control it sits under is on. Availability mirrors the legacy Traffic page:
1184     * the `ai_seo_enhancer_enabled` feature filter must be on (it still depends on
1185     * AI being available) AND the site's plan must support the `ai-seo-enhancer`
1186     * feature. The toggle writes the `ai_seo_enhancer_enabled` option through core's
1187     * `/wp/v2/settings` endpoint.
1188     *
1189     * `aiSeoEnabled` is reported separately rather than folded into availability:
1190     * with the control off the card is disabled, not hidden, so the saved choice
1191     * stays visible — the same treatment the Traffic page gives it. `aiEnabled`
1192     * reports the site-wide Jetpack AI switch so the tab can name which one is off.
1193     *
1194     * @return array
1195     */
1196    public static function get_geo_data() {
1197        $filter_on = (bool) apply_filters( 'ai_seo_enhancer_enabled', true );
1198
1199        // Jetpack_AI_Settings lives in plugins/jetpack, which bundles this package; guarded
1200        // like the other host-plugin classes here. Without the method the AI SEO control
1201        // does not exist, so the enhancer keeps its pre-control behavior.
1202        // @phan-suppress-next-line PhanUndeclaredClassMethod -- Jetpack_AI_Settings lives in plugins/jetpack and is guarded by is_callable.
1203        $ai_seo_on = ! is_callable( array( 'Jetpack_AI_Settings', 'is_ai_seo_enabled' ) ) || \Jetpack_AI_Settings::is_ai_seo_enabled();
1204        // @phan-suppress-next-line PhanUndeclaredClassMethod -- Jetpack_AI_Settings lives in plugins/jetpack and is guarded by is_callable.
1205        $ai_on = ! is_callable( array( 'Jetpack_AI_Settings', 'is_ai_enabled' ) ) || \Jetpack_AI_Settings::is_ai_enabled();
1206
1207        // Current_Plan comes from the jetpack-plans package (a dependency of this
1208        // package since the plan-gating work), so it's always available here; the
1209        // class_exists guard is kept as belt-and-suspenders for older bundled snapshots.
1210        $plan_supports = class_exists( 'Automattic\\Jetpack\\Current_Plan' )
1211            && \Automattic\Jetpack\Current_Plan::supports( 'ai-seo-enhancer' );
1212
1213        return array(
1214            'enhancer' => array(
1215                'available'    => $filter_on && $plan_supports,
1216                'enabled'      => (bool) get_option( 'ai_seo_enhancer_enabled', false ),
1217                'aiSeoEnabled' => $ai_seo_on,
1218                'aiEnabled'    => $ai_on,
1219            ),
1220            'llmsTxt'  => array(
1221                'enabled'  => Llms_Txt::is_enabled(),
1222                'url'      => home_url( '/llms.txt' ),
1223                'canServe' => Llms_Txt::can_serve(),
1224            ),
1225            'crawlers' => Ai_Crawlers::get_bootstrap_data(),
1226        );
1227    }
1228
1229    /**
1230     * Build the supported post type options for the Content tab.
1231     *
1232     * @return array{post_types:array<int,array{slug:string,label:string}>}
1233     */
1234    public static function get_content_data() {
1235        return array(
1236            'post_types' => Post_Types::get_supported_content_type_options(),
1237        );
1238    }
1239
1240    /**
1241     * Whether Jetpack's SEO output is paused, and the plugin that owns it when one does.
1242     * Every tab reads this one bootstrap for its banner and locked cards.
1243     *
1244     * @since 0.11.0
1245     *
1246     * @return array{paused: bool, conflicting_plugin: string}
1247     */
1248    public static function get_output_data() {
1249        return array(
1250            'paused'             => Seo_Tools::is_disabled(),
1251            'conflicting_plugin' => Seo_Tools::get_conflicting_plugin() ?? '',
1252        );
1253    }
1254
1255    /**
1256     * Site identity used to render the homepage search/social previews on the
1257     * Settings tab: title, tagline, URL, and representative images. The front-page
1258     * description that completes the preview is read from the Settings form
1259     * (it's editable there), not bootstrapped here.
1260     *
1261     * @return array
1262     */
1263    public static function get_site_data() {
1264        $icon_url = (string) get_site_icon_url();
1265
1266        $logo_id  = (int) get_theme_mod( 'custom_logo' );
1267        $logo_url = $logo_id ? (string) wp_get_attachment_image_url( $logo_id, 'full' ) : '';
1268        if ( class_exists( 'Jetpack_Redux_State_Helper' ) ) {
1269            // @phan-suppress-next-line PhanUndeclaredClassMethod -- Jetpack_Redux_State_Helper lives in plugins/jetpack and is guarded by class_exists.
1270            $image_url = (string) \Jetpack_Redux_State_Helper::get_site_image();
1271        } else {
1272            $image_url = $logo_url ? $logo_url : $icon_url;
1273        }
1274
1275        if ( preg_match( '/\.svg(?:[?#]|$)/i', $image_url ) || 'https://s0.wp.com/i/blank.jpg' === $image_url ) {
1276            $image_url = '';
1277        }
1278        $content_image = '';
1279        $front_page_id = 'page' === get_option( 'show_on_front' ) ? (int) get_option( 'page_on_front' ) : 0;
1280        if ( $front_page_id && ! post_password_required( $front_page_id ) && class_exists( '\Automattic\Jetpack\Post_Media\Images' ) ) {
1281            $args = array(
1282                'width'  => 200,
1283                'height' => 200,
1284            );
1285            if ( Content_Gate::is_gated( get_post( $front_page_id ) ) ) {
1286                $args += array_fill_keys( array( 'from_slideshow', 'from_gallery', 'from_attachment', 'from_blocks', 'from_html' ), false );
1287            }
1288            // @phan-suppress-next-line PhanUndeclaredClassMethod -- Optional host package, guarded above.
1289            $image         = \Automattic\Jetpack\Post_Media\Images::get_image( $front_page_id, $args );
1290            $content_image = $image['src'] ?? '';
1291        }
1292
1293        return array(
1294            'content_image' => $content_image,
1295            'title'         => (string) get_bloginfo( 'name' ),
1296            'tagline'       => (string) get_bloginfo( 'description' ),
1297            'url'           => (string) home_url(),
1298            'icon'          => $icon_url,
1299            'image'         => $image_url,
1300        );
1301    }
1302
1303    /**
1304     * The separator, as rendered, that WordPress joins default document-title parts
1305     * with — for previewing the title a page type with no stored format produces.
1306     *
1307     * `document_title_separator` alone is not what a visitor sees. `wp_get_document_title()`
1308     * composes the parts, then passes the whole title through the `document_title`
1309     * filter, which WordPress texturizes by default — turning the default spaced
1310     * hyphen into an en dash. Previewing the raw filter value would show `-` on a site
1311     * that renders `–`, which is every site running core's defaults.
1312     *
1313     * This applies only to the default title. A stored format short-circuits
1314     * `pre_get_document_title`, which returns before the `document_title` filter, so a
1315     * custom format keeps the separator the user typed verbatim — the front end renders
1316     * `Site - MARKER - Page` for a custom format while producing `Page – Site` for the
1317     * default one.
1318     *
1319     * @return string The rendered separator.
1320     */
1321    private static function get_default_title_separator() {
1322        $separator = (string) apply_filters( 'document_title_separator', '-' );
1323
1324        // Only texturize when the title itself would be: a site that unhooks
1325        // `wptexturize` renders the raw separator, and the preview should match.
1326        if ( has_filter( 'document_title', 'wptexturize' ) ) {
1327            $separator = trim(
1328                html_entity_decode( wptexturize( ' ' . $separator . ' ' ), ENT_QUOTES, 'UTF-8' )
1329            );
1330        }
1331
1332        return $separator;
1333    }
1334
1335    /**
1336     * The public URL of the XML sitemap, or an empty string when none is reachable.
1337     *
1338     * A sitemap is reachable as soon as generation is enabled and the site is public:
1339     * Jetpack serves a valid (empty-until-built) sitemap at a stable URL — never a 404 —
1340     * so the link is safe to surface immediately, without waiting on (or gating against)
1341     * the cron build. A prior gate looked the master sitemap up by a mis-built filename
1342     * and so never matched, which is what left the Settings tab stuck on "Generating…".
1343     *
1344     * `jetpack_sitemap_uri()` / `jp_sitemap_filename()` and the JP_MASTER_SITEMAP_TYPE
1345     * constant live in the Jetpack plugin's Sitemaps module (loaded only for an active
1346     * module on a public site), so they are guarded; in the package-only context they
1347     * are absent and the sitemap is reported as not reachable.
1348     *
1349     * @param bool $sitemap_active Whether sitemap generation is enabled.
1350     * @return string The sitemap URL, or '' when not reachable.
1351     */
1352    private static function get_reachable_sitemap_url( $sitemap_active ) {
1353        // Jetpack only serves sitemaps when generation is on and the site is public.
1354        if ( ! $sitemap_active || (int) get_option( 'blog_public', 1 ) !== 1 ) {
1355            return '';
1356        }
1357
1358        // The `JP_MASTER_SITEMAP_TYPE` constant and the `jp_sitemap_filename()` /
1359        // `jetpack_sitemap_uri()` helpers all live together in plugins/jetpack and load
1360        // as a unit, so this single guard covers every symbol used below.
1361        if (
1362            ! defined( 'JP_MASTER_SITEMAP_TYPE' )
1363            || ! function_exists( 'jp_sitemap_filename' )
1364            || ! function_exists( 'jetpack_sitemap_uri' )
1365        ) {
1366            return '';
1367        }
1368
1369        // `jp_sitemap_filename()` returns an error string ("error-not-int-…") unless a
1370        // non-null number is passed; the master ignores the number, so pass 0 (matching
1371        // Jetpack's own call sites). Fail safe: the master file is always 'sitemap.xml',
1372        // so if a bundled Jetpack ever returns something else, report not-reachable
1373        // rather than surface a broken URL — the exact failure this method shipped with
1374        // before (the missing number silently produced an "error-not-int-…" link).
1375        // @phan-suppress-next-line PhanUndeclaredFunction -- guarded above; symbols live in plugins/jetpack.
1376        $filename = (string) jp_sitemap_filename( JP_MASTER_SITEMAP_TYPE, 0 );
1377        if ( 'sitemap.xml' !== $filename ) {
1378            return '';
1379        }
1380
1381        // esc_url_raw (not esc_url): transported via script data and rendered by React,
1382        // so it must not be HTML-entity-encoded (e.g. the plain-permalink
1383        // `?jetpack-sitemap=` form keeps its raw `&`).
1384        // @phan-suppress-next-line PhanUndeclaredFunction -- guarded above; symbols live in plugins/jetpack.
1385        return esc_url_raw( (string) jetpack_sitemap_uri( $filename ) );
1386    }
1387}