<?php
/**
* Primary class for the Jetpack Scan package.
*
* @package automattic/jetpack-scan-page
*/
namespace Automattic\Jetpack\Scan_Page;
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
use Automattic\Jetpack\Admin_UI\Admin_Menu;
use Automattic\Jetpack\Connection\Manager as Connection_Manager;
use Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills;
use function add_action;
use function add_filter;
use function apply_filters;
use function call_user_func;
use function current_user_can;
use function did_action;
use function do_action;
use function function_exists;
use function is_multisite;
use function remove_action;
use function remove_all_actions;
use function sanitize_text_field;
use function wp_print_inline_script_tag;
use function wp_scripts;
use function wp_unslash;
/**
* Class Jetpack_Scan
*
* Registers the Scan admin page and its REST routes inside the main
* Jetpack plugin. The page bundle is built by `@wordpress/build`
* (mirroring Newsletter / Forms); this class wires the wp-admin menu
* + the bridges that route our user-facing slug to wp-build's
* auto-generated enqueue / render functions.
*/
class Jetpack_Scan {
/**
* URL-facing menu slug.
*
* @var string
*/
const PAGE_SLUG = 'jetpack-scan';
/**
* Internal slug emitted by `@wordpress/build` (`wpPlugin.pages[0]`
* plus the `-wp-admin` suffix the build template appends). Used to
* find the auto-generated render / enqueue functions.
*
* @var string
*/
const WP_BUILD_SLUG = 'jetpack-scan-wp-admin';
/**
* Filter name that gates the wp-build–based Scan dashboard.
*
* When this filter returns true, the new wp-admin Scan page is
* registered and rendered. Default false during the modernization
* roll-out — the package registers no admin menu and changes
* nothing about the existing Jetpack UI when this filter is off.
*
* @var string
*/
const MODERNIZATION_FILTER = 'rsm_jetpack_ui_modernization_scan';
/**
* Entry point. Idempotent: safe to call from multiple bootstraps.
*/
public static function initialize() {
if ( did_action( 'jetpack_scan_page_initialized' ) ) {
return;
}
if ( ! (bool) apply_filters( self::MODERNIZATION_FILTER, false ) ) {
return;
}
self::load_wp_build();
self::fix_boot_import_map_ordering();
self::bridge_wp_build_enqueue();
add_action( 'admin_menu', array( __CLASS__, 'add_wp_admin_submenu' ) );
add_action( 'rest_api_init', array( __CLASS__, 'register_rest_routes' ) );
add_filter( 'jetpack_package_versions', array( Package_Version::class, 'send_package_version_to_tracker' ) );
/**
* Fires once the Jetpack Scan package has wired its hooks.
*
* @since 0.1.0
*/
do_action( 'jetpack_scan_page_initialized' );
}
/**
* Load wp-build generated registration files. Mirrors Newsletter / Forms.
*/
public static function load_wp_build() {
WP_Build_Polyfills::register(
'jetpack-scan',
array_merge( WP_Build_Polyfills::SCRIPT_HANDLES, WP_Build_Polyfills::MODULE_IDS )
);
$wp_build_index = dirname( __DIR__ ) . '/build/build.php';
if ( file_exists( $wp_build_index ) ) {
require_once $wp_build_index;
}
// `page.php` ships an `admin_init` interceptor that takes over our
// slug with a standalone (non-wp-admin) render. We want the
// wp-admin integrated experience, so unregister it as soon as it's
// loaded.
remove_action(
'admin_init',
'jetpack_scan_jetpack_scan_intercept_render'
);
}
/**
* Bridge wp-build's auto-generated enqueue function — which checks for
* `?page=jetpack-scan-wp-admin` — to our user-facing slug
* `?page=jetpack-scan`. Hooked at priority 9 so the wp-build copy
* (registered at priority 10) sees the original `$_GET['page']` and
* skips its own enqueue.
*/
public static function bridge_wp_build_enqueue() {
add_action(
'admin_enqueue_scripts',
static function ( $hook_suffix ) {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( ! isset( $_GET['page'] ) || self::PAGE_SLUG !== $_GET['page'] ) {
return;
}
$enqueue_fn = 'jetpack_scan_jetpack_scan_wp_admin_enqueue_scripts';
if ( ! function_exists( $enqueue_fn ) ) {
return;
}
// phpcs:disable WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
$original = isset( $_GET['page'] ) ? sanitize_text_field( wp_unslash( $_GET['page'] ) ) : null;
$_GET['page'] = self::WP_BUILD_SLUG;
// @phan-suppress-next-line PhanUndeclaredFunctionInCallable -- Function is generated by @wordpress/build into build/pages/jetpack-scan/page-wp-admin.php, which is outside Phan's analysis scope. The function_exists() guard above protects the call at runtime.
call_user_func( $enqueue_fn, $hook_suffix );
if ( null === $original ) {
unset( $_GET['page'] );
} else {
$_GET['page'] = $original;
}
// phpcs:enable WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
},
9
);
}
/**
* Fix import map ordering for the wp-build boot script.
*
* In wp-admin, `_wp_footer_scripts` (classic scripts) and
* `print_import_map` both hook into `admin_print_footer_scripts` at
* priority 10, but `_wp_footer_scripts` is registered first. This
* causes the inline `import("@wordpress/boot")` to execute before
* the import map exists.
*
* This fix moves the import() call from the classic inline script to
* a `<script type="module">` printed at priority 20 (after the import
* map).
*
* @todo Remove once @wordpress/build ships with the loader.js fix
* upstream (WordPress/gutenberg#76870) and Jetpack updates the
* dependency.
*/
public static function fix_boot_import_map_ordering() {
$handle = self::WP_BUILD_SLUG . '-prerequisites';
add_action(
'admin_enqueue_scripts',
static function () use ( $handle ) {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( ! isset( $_GET['page'] ) || self::PAGE_SLUG !== $_GET['page'] ) {
return;
}
$data = wp_scripts()->get_data( $handle, 'after' );
if ( empty( $data ) ) {
return;
}
$boot_script = null;
$remaining = array();
foreach ( $data as $line ) {
if ( strpos( $line, '@wordpress/boot' ) !== false ) {
$boot_script = $line;
} else {
$remaining[] = $line;
}
}
if ( null === $boot_script ) {
return;
}
wp_scripts()->add_data( $handle, 'after', $remaining );
add_action(
'admin_print_footer_scripts',
static function () use ( $boot_script ) {
wp_print_inline_script_tag( $boot_script, array( 'type' => 'module' ) );
},
20
);
},
PHP_INT_MAX
);
}
/**
* Register the Scan submenu under Jetpack.
*
* @return string|null The resulting page's hook suffix, if registered.
*/
public static function add_wp_admin_submenu() {
if ( ! self::is_available() ) {
return null;
}
$render_fn = 'jetpack_scan_jetpack_scan_wp_admin_render_page';
$render = function_exists( $render_fn )
? $render_fn
: array( __CLASS__, 'render_page_fallback' );
$page_suffix = Admin_Menu::add_menu(
/** "Scan" is a product name, do not translate. */
'Scan',
'Scan',
'manage_options',
self::PAGE_SLUG,
$render,
6
);
if ( $page_suffix ) {
add_action( 'load-' . $page_suffix, array( __CLASS__, 'admin_init' ) );
}
return $page_suffix;
}
/**
* Whether the Scan page should be shown to the current user.
*
* @return bool
*/
public static function is_available() {
if ( is_multisite() ) {
return false;
}
if ( ! current_user_can( 'manage_options' ) ) {
return false;
}
return ( new Connection_Manager() )->is_user_connected();
}
/**
* Fires when the admin page is loaded.
*
* Silences the standard wp-admin notice channels so JITMs and
* plugin-update messages don't reflow the focused Scan layout
* mid-scan or while a fix modal is open.
*/
public static function admin_init() {
remove_all_actions( 'admin_notices' );
remove_all_actions( 'all_admin_notices' );
}
/**
* Fallback render — used only if the wp-build registration file
* isn't loaded (e.g. the package wasn't built yet). Renders a bare
* mount node so the page doesn't 500 in dev.
*/
public static function render_page_fallback() {
?>
<div id="jetpack-scan-page-root"></div>
<?php
}
/**
* Register the REST routes backing the Scan UI.
*/
public static function register_rest_routes() {
REST_Controller::register_rest_routes();
}
}