Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
0.00% |
0 / 101 |
|
0.00% |
0 / 12 |
CRAP | |
0.00% |
0 / 1 |
| Initializer | |
0.00% |
0 / 101 |
|
0.00% |
0 / 12 |
1122 | |
0.00% |
0 / 1 |
| init | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
20 | |||
| add_menu_item | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
6 | |||
| maybe_load_wp_build | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
| load_wp_build | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
6 | |||
| alias_screen_id_for_wp_build | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
| inject_script_data | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
| render_fallback | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| is_seo_admin_request | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
12 | |||
| is_seo_tools_module_active | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
| get_overview_data | |
0.00% |
0 / 23 |
|
0.00% |
0 / 1 |
12 | |||
| register_rest_settings | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
2 | |||
| get_settings_data | |
0.00% |
0 / 19 |
|
0.00% |
0 / 1 |
90 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * Jetpack SEO — the visibility command center for WordPress sites. |
| 4 | * |
| 5 | * Registers the `admin.php?page=jetpack-seo` screen via Admin_Menu so it is |
| 6 | * reachable on self-hosted, Atomic/WoW, and Simple sites alike, and loads the |
| 7 | * `@wordpress/build` (wp-build) dashboard bundle that renders it. |
| 8 | * |
| 9 | * @package automattic/jetpack-seo-package |
| 10 | */ |
| 11 | |
| 12 | namespace Automattic\Jetpack\SEO; |
| 13 | |
| 14 | use Automattic\Jetpack\Admin_UI\Admin_Menu; |
| 15 | use Automattic\Jetpack\Modules; |
| 16 | use Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills; |
| 17 | use Jetpack_SEO_Titles; |
| 18 | use Jetpack_SEO_Utils; |
| 19 | |
| 20 | /** |
| 21 | * The main Initializer class. Registers the admin menu and loads the wp-build |
| 22 | * dashboard, bootstrapping the React app's initial state onto the page. |
| 23 | */ |
| 24 | class Initializer { |
| 25 | |
| 26 | /** |
| 27 | * Jetpack SEO package version. |
| 28 | * |
| 29 | * @var string |
| 30 | */ |
| 31 | const PACKAGE_VERSION = '0.1.0-alpha'; |
| 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 | * URL-facing menu slug (`admin.php?page=jetpack-seo`). |
| 47 | */ |
| 48 | const MENU_SLUG = 'jetpack-seo'; |
| 49 | |
| 50 | /** |
| 51 | * Slug emitted by `@wordpress/build` (`wpPlugin.pages[0]`). wp-build's |
| 52 | * auto-generated enqueue callback only fires when `$screen->id` matches |
| 53 | * this value, so we alias the screen id to it via `current_screen` without |
| 54 | * changing the user-facing URL. |
| 55 | */ |
| 56 | const WP_BUILD_SLUG = 'jetpack-seo-dashboard'; |
| 57 | |
| 58 | /** |
| 59 | * Render function generated by `@wordpress/build` into |
| 60 | * `build/pages/jetpack-seo-dashboard/page-wp-admin.php`. Naming convention: |
| 61 | * `{wpPlugin.name}_{page-with-underscores}_wp_admin_render_page`. |
| 62 | */ |
| 63 | const WP_BUILD_RENDER_FN = 'jetpack_seo_jetpack_seo_dashboard_wp_admin_render_page'; |
| 64 | |
| 65 | /** |
| 66 | * Key under `window.JetpackScriptData` the React app reads its state from |
| 67 | * (`window.JetpackScriptData.seo`). Must match the JS-side reader in |
| 68 | * `_inc/data/get-overview.ts`. |
| 69 | */ |
| 70 | const SCRIPT_DATA_KEY = 'seo'; |
| 71 | |
| 72 | /** |
| 73 | * Whether the package has been initialized. |
| 74 | * |
| 75 | * @var bool |
| 76 | */ |
| 77 | private static $initialized = false; |
| 78 | |
| 79 | /** |
| 80 | * Initialize the package. |
| 81 | * |
| 82 | * Called from the Jetpack plugin's `late_initialization()` hook. |
| 83 | * |
| 84 | * @return void |
| 85 | */ |
| 86 | public static function init() { |
| 87 | if ( self::$initialized ) { |
| 88 | return; |
| 89 | } |
| 90 | self::$initialized = true; |
| 91 | |
| 92 | // Gate the entire SEO surface behind the feature flag. |
| 93 | if ( ! (bool) apply_filters( self::FEATURE_FILTER, false ) ) { |
| 94 | return; |
| 95 | } |
| 96 | |
| 97 | // Gate the entire SEO surface on the `seo-tools` module being active, |
| 98 | // the same way other Jetpack modules do. When the module is off we |
| 99 | // register nothing — no admin menu, no assets — rather than registering |
| 100 | // everything and hiding the menu downstream. |
| 101 | if ( ! self::is_seo_tools_module_active() ) { |
| 102 | return; |
| 103 | } |
| 104 | |
| 105 | // Priority 1: load the wp-build bundle (and define its render function) |
| 106 | // before `add_menu_item()` runs at the default priority and needs it. |
| 107 | add_action( 'admin_menu', array( __CLASS__, 'maybe_load_wp_build' ), 1 ); |
| 108 | add_action( 'admin_menu', array( __CLASS__, 'add_menu_item' ), 10 ); |
| 109 | |
| 110 | // Expose the core `blog_public` option to the REST settings endpoint so |
| 111 | // the Settings tab can save search-engine visibility via `/wp/v2/settings`. |
| 112 | // (The Jetpack settings endpoint only accepts Jetpack options.) Writes |
| 113 | // are still capability-gated by the core settings controller. |
| 114 | add_action( 'rest_api_init', array( __CLASS__, 'register_rest_settings' ) ); |
| 115 | |
| 116 | /** |
| 117 | * Fires after the Jetpack SEO package is initialized. |
| 118 | * |
| 119 | * @since 0.1.0 |
| 120 | */ |
| 121 | do_action( 'jetpack_seo_init' ); |
| 122 | } |
| 123 | |
| 124 | /** |
| 125 | * Register the admin menu item. |
| 126 | * |
| 127 | * Uses Admin_Menu so the page is reachable on wp-admin across all site |
| 128 | * types. The render callback is wp-build's generated render function when |
| 129 | * the bundle is loaded (i.e. on the SEO page itself, after |
| 130 | * `maybe_load_wp_build()` ran at priority 1); otherwise it falls back to a |
| 131 | * bare mount node so the page never fatals on an unbuilt checkout. |
| 132 | * |
| 133 | * @return void |
| 134 | */ |
| 135 | public static function add_menu_item() { |
| 136 | $callback = function_exists( self::WP_BUILD_RENDER_FN ) |
| 137 | ? self::WP_BUILD_RENDER_FN |
| 138 | : array( __CLASS__, 'render_fallback' ); |
| 139 | |
| 140 | Admin_Menu::add_menu( |
| 141 | 'SEO', |
| 142 | 'SEO', |
| 143 | 'manage_options', |
| 144 | self::MENU_SLUG, |
| 145 | $callback, |
| 146 | 2 |
| 147 | ); |
| 148 | } |
| 149 | |
| 150 | /** |
| 151 | * On the SEO admin page, load the wp-build bundle, alias the screen id so |
| 152 | * wp-build enqueues its assets, and bootstrap the app's initial state. |
| 153 | * |
| 154 | * Hooked at `admin_menu` priority 1 so polyfills register and the render |
| 155 | * function is defined before `add_menu_item()` runs at priority 10. |
| 156 | * |
| 157 | * @return void |
| 158 | */ |
| 159 | public static function maybe_load_wp_build() { |
| 160 | if ( ! self::is_seo_admin_request() ) { |
| 161 | return; |
| 162 | } |
| 163 | |
| 164 | self::load_wp_build(); |
| 165 | add_action( 'current_screen', array( __CLASS__, 'alias_screen_id_for_wp_build' ) ); |
| 166 | add_filter( 'jetpack_admin_js_script_data', array( __CLASS__, 'inject_script_data' ) ); |
| 167 | } |
| 168 | |
| 169 | /** |
| 170 | * Load wp-build's generated registration file and register the polyfills |
| 171 | * the bundle depends on. No-op on a fresh checkout before `pnpm build`, in |
| 172 | * which case `add_menu_item()` falls back to {@see self::render_fallback()}. |
| 173 | * |
| 174 | * @return void |
| 175 | */ |
| 176 | private static function load_wp_build() { |
| 177 | $build_index = dirname( __DIR__ ) . '/build/build.php'; |
| 178 | |
| 179 | if ( ! file_exists( $build_index ) ) { |
| 180 | return; |
| 181 | } |
| 182 | |
| 183 | require_once $build_index; |
| 184 | |
| 185 | WP_Build_Polyfills::register( |
| 186 | 'jetpack-seo', |
| 187 | array_merge( WP_Build_Polyfills::SCRIPT_HANDLES, WP_Build_Polyfills::MODULE_IDS ) |
| 188 | ); |
| 189 | } |
| 190 | |
| 191 | /** |
| 192 | * Alias the current screen id to wp-build's expected slug so its |
| 193 | * auto-generated enqueue callback fires for our user-facing page. |
| 194 | * |
| 195 | * @param \WP_Screen|null $screen The current screen object (passed by WP). |
| 196 | * @return void |
| 197 | */ |
| 198 | public static function alias_screen_id_for_wp_build( $screen ) { |
| 199 | if ( ! is_object( $screen ) ) { |
| 200 | return; |
| 201 | } |
| 202 | |
| 203 | $screen->id = self::WP_BUILD_SLUG; |
| 204 | } |
| 205 | |
| 206 | /** |
| 207 | * Bootstrap the React app's initial state onto `window.JetpackScriptData.seo`. |
| 208 | * |
| 209 | * Because wp-build pages load as ES modules, `wp_localize_script` can't |
| 210 | * attach data to them; the shared `jetpack_admin_js_script_data` filter |
| 211 | * (printed by the Script_Data package onto the `jetpack-script-data` handle |
| 212 | * the bundle already depends on) is the supported channel. Mirrors Podcast |
| 213 | * and Newsletter. |
| 214 | * |
| 215 | * @param array $data Script data being injected onto the page. |
| 216 | * @return array |
| 217 | */ |
| 218 | public static function inject_script_data( $data ) { |
| 219 | if ( ! is_array( $data ) ) { |
| 220 | $data = array(); |
| 221 | } |
| 222 | |
| 223 | $data[ self::SCRIPT_DATA_KEY ]['overview'] = self::get_overview_data(); |
| 224 | $data[ self::SCRIPT_DATA_KEY ]['settings'] = self::get_settings_data(); |
| 225 | |
| 226 | return $data; |
| 227 | } |
| 228 | |
| 229 | /** |
| 230 | * Fallback render used when the wp-build artifact is missing (unbuilt |
| 231 | * checkout). Renders a bare wrapper so the page loads without the app. |
| 232 | * |
| 233 | * @return void |
| 234 | */ |
| 235 | public static function render_fallback() { |
| 236 | echo '<div class="wrap"><h1>SEO</h1></div>'; |
| 237 | } |
| 238 | |
| 239 | /** |
| 240 | * Whether the current request targets the SEO admin page. |
| 241 | * |
| 242 | * @return bool |
| 243 | */ |
| 244 | private static function is_seo_admin_request() { |
| 245 | // phpcs:ignore WordPress.Security.NonceVerification.Recommended |
| 246 | if ( ! is_admin() || ! isset( $_GET['page'] ) ) { |
| 247 | return false; |
| 248 | } |
| 249 | |
| 250 | // phpcs:ignore WordPress.Security.NonceVerification.Recommended |
| 251 | return self::MENU_SLUG === sanitize_text_field( wp_unslash( $_GET['page'] ) ); |
| 252 | } |
| 253 | |
| 254 | /** |
| 255 | * Whether the `seo-tools` Jetpack module is currently active. |
| 256 | * |
| 257 | * @return bool |
| 258 | */ |
| 259 | private static function is_seo_tools_module_active() { |
| 260 | if ( ! class_exists( 'Automattic\\Jetpack\\Modules' ) ) { |
| 261 | return false; |
| 262 | } |
| 263 | return ( new Modules() )->is_active( 'seo-tools' ); |
| 264 | } |
| 265 | |
| 266 | /** |
| 267 | * Build the aggregated Overview state the dashboard renders. |
| 268 | * |
| 269 | * @return array |
| 270 | */ |
| 271 | public static function get_overview_data() { |
| 272 | $modules = new Modules(); |
| 273 | // @phan-suppress-next-line PhanUndeclaredClassMethod -- Jetpack_SEO_Utils lives in plugins/jetpack and is guarded by class_exists. |
| 274 | $seo_enabled = class_exists( 'Jetpack_SEO_Utils' ) && Jetpack_SEO_Utils::is_enabled_jetpack_seo(); |
| 275 | |
| 276 | $codes = get_option( 'verification_services_codes', array() ); |
| 277 | if ( ! is_array( $codes ) ) { |
| 278 | $codes = array(); |
| 279 | } |
| 280 | |
| 281 | return array( |
| 282 | 'site_visibility' => array( |
| 283 | 'search_engines_visible' => (int) get_option( 'blog_public', 1 ) === 1, |
| 284 | // The real `sitemaps` module is the source of truth (the Settings |
| 285 | // toggle drives it via `/jetpack/v4/settings`). |
| 286 | 'sitemap_active' => $modules->is_active( 'sitemaps' ), |
| 287 | 'sitemap_url' => home_url( '/sitemap.xml' ), |
| 288 | 'seo_tools_active' => $modules->is_active( 'seo-tools' ), |
| 289 | ), |
| 290 | // Per-service booleans (a code is set or not) for the Overview's |
| 291 | // Site verification card. |
| 292 | 'site_verification' => array( |
| 293 | 'google' => ! empty( $codes['google'] ), |
| 294 | 'bing' => ! empty( $codes['bing'] ), |
| 295 | 'pinterest' => ! empty( $codes['pinterest'] ), |
| 296 | 'yandex' => ! empty( $codes['yandex'] ), |
| 297 | 'facebook' => ! empty( $codes['facebook'] ), |
| 298 | ), |
| 299 | 'plan' => array( |
| 300 | 'seo_enabled_for_site' => $seo_enabled, |
| 301 | ), |
| 302 | ); |
| 303 | } |
| 304 | |
| 305 | /** |
| 306 | * Expose the core `blog_public` option to the REST settings endpoint. |
| 307 | * |
| 308 | * Search-engine visibility is a WordPress core option, not a Jetpack one, |
| 309 | * so the Settings tab saves it through `/wp/v2/settings` — which only |
| 310 | * round-trips settings registered with `show_in_rest`. The core settings |
| 311 | * controller enforces the `manage_options` capability on writes. |
| 312 | * |
| 313 | * @return void |
| 314 | */ |
| 315 | public static function register_rest_settings() { |
| 316 | register_setting( |
| 317 | 'reading', |
| 318 | 'blog_public', |
| 319 | array( |
| 320 | 'show_in_rest' => true, |
| 321 | 'type' => 'integer', |
| 322 | 'default' => 1, |
| 323 | ) |
| 324 | ); |
| 325 | } |
| 326 | |
| 327 | /** |
| 328 | * Build the editable Settings state the Settings tab hydrates from. |
| 329 | * |
| 330 | * Read-only bootstrap only. Writes go through the existing |
| 331 | * `/jetpack/v4/settings` REST endpoint, which already validates and |
| 332 | * sanitizes each of these fields — this package registers no settings |
| 333 | * endpoint of its own. The reads here mirror the options/helpers that |
| 334 | * endpoint round-trips so the form hydrates without a request. |
| 335 | * |
| 336 | * @return array |
| 337 | */ |
| 338 | public static function get_settings_data() { |
| 339 | $modules = new Modules(); |
| 340 | |
| 341 | // @phan-suppress-next-line PhanUndeclaredClassMethod -- Jetpack_SEO_Titles lives in plugins/jetpack and is guarded by class_exists. |
| 342 | $title_formats = class_exists( 'Jetpack_SEO_Titles' ) ? Jetpack_SEO_Titles::get_custom_title_formats() : array(); |
| 343 | // @phan-suppress-next-line PhanUndeclaredClassMethod -- Jetpack_SEO_Utils lives in plugins/jetpack and is guarded by class_exists. |
| 344 | $front_page_desc = class_exists( 'Jetpack_SEO_Utils' ) ? Jetpack_SEO_Utils::get_front_page_meta_description() : ''; |
| 345 | |
| 346 | $codes = get_option( 'verification_services_codes', array() ); |
| 347 | if ( ! is_array( $codes ) ) { |
| 348 | $codes = array(); |
| 349 | } |
| 350 | |
| 351 | return array( |
| 352 | 'search_engines_visible' => (int) get_option( 'blog_public', 1 ) === 1, |
| 353 | // The real `sitemaps` module is the source of truth (not a bespoke |
| 354 | // option). The Settings toggle drives it via `/jetpack/v4/settings`. |
| 355 | 'sitemap_active' => $modules->is_active( 'sitemaps' ), |
| 356 | // Cast to object so an empty format set serializes as `{}`, not `[]`. |
| 357 | 'title_formats' => (object) $title_formats, |
| 358 | 'front_page_description' => (string) $front_page_desc, |
| 359 | 'verification' => array( |
| 360 | 'google' => isset( $codes['google'] ) ? (string) $codes['google'] : '', |
| 361 | 'bing' => isset( $codes['bing'] ) ? (string) $codes['bing'] : '', |
| 362 | 'pinterest' => isset( $codes['pinterest'] ) ? (string) $codes['pinterest'] : '', |
| 363 | 'yandex' => isset( $codes['yandex'] ) ? (string) $codes['yandex'] : '', |
| 364 | 'facebook' => isset( $codes['facebook'] ) ? (string) $codes['facebook'] : '', |
| 365 | ), |
| 366 | ); |
| 367 | } |
| 368 | } |