PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-beta
Jetpack – WP Security, Backup, Speed, & Growth v16.3-beta
16.3-beta 16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 All 507 releases
← All changes | _inc/lib/admin-pages/class-jetpack-ai-page.php +183 -55 16.3-a.1 → 16.3-beta View file →
@@ -12,8 +12,9 @@
12 12 use Automattic\Jetpack\Agents_Manager\Agents_Manager;
13 13 use Automattic\Jetpack\Connection\Initial_State as Connection_Initial_State;
14 14 use Automattic\Jetpack\Connection\Manager as Connection_Manager;
15 15 use Automattic\Jetpack\Feature_Flags\Feature_Flags;
16 +use Automattic\Jetpack\Modules;
16 17 use Automattic\Jetpack\Redirect;
17 18 use Automattic\Jetpack\Status;
18 19 use Automattic\Jetpack\Status\Host;
19 20 use Automattic\Jetpack\Terms_Of_Service;
@@ -23,8 +24,10 @@
23 24 exit( 0 );
24 25 }
25 26
26 27 require_once dirname( __DIR__ ) . '/class-jetpack-ai-feature-flags.php';
28 +require_once dirname( __DIR__ ) . '/class-jetpack-ai-settings.php';
29 +require_once __DIR__ . '/class-jetpack-wp-build-page.php';
27 30
28 31 /**
29 32 * Builds the Jetpack AI admin page and its sidebar menu entry.
30 33 */
@@ -30,8 +33,58 @@
30 33 */
31 34 class Jetpack_AI_Page {
32 35
33 36 /**
37 + * The wp-build route's page id, which must not be the `jetpack-ai` menu slug.
38 + *
39 + * @var string
40 + */
41 + const WP_BUILD_PAGE_ID = 'jetpack-ai-hub';
42 +
43 + /**
44 + * Whether this request renders through wp-build.
45 + *
46 + * Checks the render function too: if the build output is missing, the request would
47 + * otherwise get no bundle at all now that the legacy entry is gone.
48 + *
49 + * @since 16.3
50 + *
51 + * @return bool
52 + */
53 + public static function should_render_wp_build() {
54 + return function_exists( 'jetpack_plugin_jetpack_ai_hub_wp_admin_render_page' );
55 + }
56 +
57 + /**
58 + * Whether the current request targets the AI Hub admin page.
59 + *
60 + * @since 16.3
61 + *
62 + * @return bool
63 + */
64 + private static function is_ai_admin_request() {
65 + if ( ! is_admin() ) {
66 + return false;
67 + }
68 +
69 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading the page slug only.
70 + return isset( $_GET['page'] ) && 'jetpack-ai' === sanitize_text_field( wp_unslash( $_GET['page'] ) );
71 + }
72 +
73 + /**
74 + * Load wp-build for the AI Hub page.
75 + *
76 + * @since 16.3
77 + *
78 + * @return void
79 + */
80 + public static function maybe_load_wp_build() {
81 + if ( self::is_ai_admin_request() ) {
82 + Jetpack_WP_Build_Page::load( self::WP_BUILD_PAGE_ID );
83 + }
84 + }
85 +
86 + /**
34 87 * Register the page and its page-specific hooks.
35 88 *
36 89 * The AI Hub owns its full React layout, so it does not need the legacy
37 90 * Jetpack_Admin_Page lifecycle. Keeping this controller independent also
@@ -78,12 +131,10 @@
78 131 'manage_options',
79 132 'jetpack-ai',
80 133 array( $this, 'render' ),
81 134 null,
82 - array(
83 - 'product' => 'jetpack-ai',
84 - 'key' => 'jetpack-ai',
85 - )
135 + // No product gate: the Hub also holds the MCP and Connectors tab, which works with AI off.
136 + array( 'key' => 'jetpack-ai' )
86 137 );
87 138 }
88 139
89 140 /**
@@ -200,18 +251,12 @@
200 251 /**
201 252 * Enqueue scripts and styles for the AI admin page.
202 253 */
203 254 public function page_admin_scripts() {
204 - $script_path = JETPACK__PLUGIN_DIR . '_inc/build/jetpack-ai-admin.asset.php';
205 - $script_deps = array( 'wp-element', 'wp-components', 'wp-i18n', 'wp-polyfill' );
255 + // wp-build owns the route bundle and its dependencies; this handle only carries the
256 + // inline settings below, so the plugin version is version enough to bust its cache.
206 257 $script_version = JETPACK__VERSION;
207 258
208 - if ( file_exists( $script_path ) ) {
209 - $asset_manifest = include $script_path;
210 - $script_deps = $asset_manifest['dependencies'];
211 - $script_version = $asset_manifest['version'];
212 - }
213 -
214 259 $blog_id = Connection_Manager::get_site_id( true );
215 260 $status = new Status();
216 261 $site_suffix = $status->get_site_suffix();
217 262 // Use the plain hostname for the Atomic activity log URL — get_site_suffix() can
@@ -218,14 +263,23 @@
218 263 // include '::' for subdirectory installs, which would break the URL. This matches
219 264 // the approach used by jetpack-mu-wpcom for the sidebar Activity Log link.
220 265 $site_host = wp_parse_url( home_url(), PHP_URL_HOST );
221 266 $activity_log_site = ( is_string( $site_host ) && '' !== $site_host ) ? $site_host : $site_suffix;
222 - // On Atomic link to WPCOM activity log; on self-hosted link to the local wp-admin page.
223 - $activity_log_url = ( new Host() )->is_woa_site()
224 - ? 'https://wordpress.com/activity-log/' . $activity_log_site
225 - : admin_url( 'admin.php?page=jetpack-activity-log' );
226 267
227 268 /*
269 + * On Atomic link to WPCOM activity log; on self-hosted link to the local
270 + * wp-admin page, which only exists while the `activity-log` module is on.
271 + * An empty URL hides the row rather than linking to an unregistered page.
272 + */
273 + if ( ( new Host() )->is_woa_site() ) {
274 + $activity_log_url = 'https://wordpress.com/activity-log/' . $activity_log_site;
275 + } elseif ( ( new Modules() )->is_active( 'activity-log' ) ) {
276 + $activity_log_url = admin_url( 'admin.php?page=jetpack-activity-log' );
277 + } else {
278 + $activity_log_url = '';
279 + }
280 +
281 + /*
228 282 * Link SEO settings to the dedicated Jetpack SEO page where it exists,
229 283 * falling back to the Traffic settings card. Checking the `rsm_jetpack_seo`
230 284 * filter is required in addition to the cohort check: is_seo_surface_visible()
231 285 * alone returns true on all of wpcom-platform even while the flag is off —
@@ -231,9 +285,9 @@
231 285 * alone returns true on all of wpcom-platform even while the flag is off —
232 286 * it answers only the cohort half, and page registration requires both
233 287 * (see packages/seo Initializer::init()).
234 288 */
235 - $seo_settings_url = admin_url( 'admin.php?page=jetpack#/traffic' );
289 + $seo_settings_url = admin_url( 'admin.php?page=jetpack-settings#/traffic' );
236 290 $is_internal_test = jetpack_is_internal_testing_environment();
237 291 $show_scheduled_tasks_view = self::is_scheduled_tasks_enabled();
238 292 if (
239 293 // The exact-symbol guard matters: the autoloader can select an older
@@ -245,18 +299,13 @@
245 299 ) {
246 300 $seo_settings_url = admin_url( 'admin.php?page=jetpack-seo' );
247 301 }
248 302
249 - wp_enqueue_script(
250 - 'jetpack-ai-admin',
251 - plugins_url( '_inc/build/jetpack-ai-admin.js', JETPACK__PLUGIN_FILE ),
252 - $script_deps,
253 - $script_version,
254 - true
255 - );
303 + // The route bundle is registered by wp-build; this handle exists only to carry the
304 + // inline settings below, which the app reads from `window.jetpackAiSettings`.
305 + wp_register_script( 'jetpack-ai-admin', false, array(), $script_version, true );
306 + wp_enqueue_script( 'jetpack-ai-admin' );
256 307
257 - wp_set_script_translations( 'jetpack-ai-admin', 'jetpack' );
258 -
259 308 // The Tracks sender (w.js); without it, queued events never leave the
260 309 // browser. Consent-gated like the other surfaces that load it.
261 310 $can_send_tracks = ( new Tracking( 'jetpack', new Connection_Manager() ) )->should_enable_tracking( new Terms_Of_Service(), $status );
262 311 if ( $can_send_tracks ) {
@@ -262,19 +311,15 @@
262 311 if ( $can_send_tracks ) {
263 312 Tracking::register_tracks_functions_scripts( true );
264 313 }
265 314
266 - if ( $show_scheduled_tasks_view ) {
267 - Connection_Initial_State::render_script( 'jetpack-ai-admin' );
268 - // Webpack reads this to load the lazy Scheduled tasks chunk; see _inc/client/ai/public-path.js.
269 - wp_add_inline_script(
270 - 'jetpack-ai-admin',
271 - 'window.Jetpack_AI_Admin_Assets_Base_Url = ' . wp_json_encode( plugins_url( '_inc/build/', JETPACK__PLUGIN_FILE ), JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ) . ';',
272 - 'before'
273 - );
274 - }
315 + // Unconditional, as on the other Jetpack admin pages: the connection store
316 + // reads it, and only Scheduled tasks used to need it here.
317 + Connection_Initial_State::render_script( 'jetpack-ai-admin' );
275 318
276 - $host = new Host();
319 + $host = new Host();
320 + $has_my_jetpack = self::has_my_jetpack();
321 + $is_offline_mode = $status->is_offline_mode();
277 322
278 323 /**
279 324 * Filters the host-specific AI Hub configuration.
280 325 *
@@ -289,13 +334,27 @@
289 334 // Keep this filterable so hosts can close them independently.
290 335 'showGatedViews' => ! $host->is_vip_site()
291 336 && ( ! $host->is_wpcom_platform() || ( $host->is_woa_site() && $is_internal_test ) ),
292 337 'showA12sBadge' => $host->is_woa_site() && $is_internal_test,
293 - 'isUserConnected' => ( new Connection_Manager() )->is_user_connected(),
294 - // My Jetpack is removed on VIP, so that route dead-ends there.
295 - 'userConnectionUrl' => $host->is_vip_site()
296 - ? 'admin.php?page=jetpack#/connect-user'
297 - : 'admin.php?page=my-jetpack#/connection',
338 + // The same verdicts the feature-settings endpoint reports. That call
339 + // exists for the AI Features toggles; the notice must not wait on it.
340 + 'isUserConnected' => Jetpack_AI_Settings::user_is_connected(),
341 + 'isConnected' => Jetpack_AI_Settings::site_is_connected(),
342 + 'hostAllowsAi' => Jetpack_AI_Settings::host_allows_ai(),
343 + 'masterEnabled' => Jetpack_AI_Settings::is_master_enabled(),
344 + // The route, not a flag: each one documents a different hook.
345 + 'masterForcedOff' => Jetpack_AI_Settings::get_master_forced_off_route(),
346 + 'isOfflineMode' => $is_offline_mode,
347 + 'canConnectSite' => current_user_can( 'jetpack_connect' ),
348 + // These three answer one question; a filter changing one alone leaves
349 + // a label pointing at a page that is not there.
350 + 'hasMyJetpack' => $has_my_jetpack,
351 + 'userConnectionUrl' => $has_my_jetpack
352 + ? 'admin.php?page=my-jetpack#/connection'
353 + : 'admin.php?page=jetpack-settings#/connect-user',
354 + 'manageUrl' => $has_my_jetpack
355 + ? 'admin.php?page=my-jetpack#/products'
356 + : 'admin.php?page=jetpack_modules',
298 357 'mcpSettingsApi' => array(
299 358 'path' => '/wpcom/v2/jetpack-ai/mcp-settings',
300 359 'format' => 'jetpack',
301 360 ),
@@ -309,13 +368,32 @@
309 368 $settings = array(
310 369 'blogId' => $blog_id ? (int) $blog_id : 0,
311 370 'activityLogUrl' => $activity_log_url,
312 371 'seoSettingsUrl' => $seo_settings_url,
372 + 'searchSettingsUrl' => self::get_search_settings_url(),
313 373 'siteAdminUrl' => admin_url(),
314 - 'userConnectionUrl' => esc_url_raw( $config['userConnectionUrl'] ),
374 + 'userConnectionUrl' => esc_url_raw( $config['userConnectionUrl'] ?? '' ),
375 + 'manageUrl' => esc_url_raw( $config['manageUrl'] ?? '' ),
376 + 'hasMyJetpack' => ! empty( $config['hasMyJetpack'] ),
377 + 'isConnected' => ! empty( $config['isConnected'] ),
378 + 'hostAllowsAi' => ! empty( $config['hostAllowsAi'] ),
379 + 'masterEnabled' => ! empty( $config['masterEnabled'] ),
380 + 'masterForcedOff' => in_array(
381 + $config['masterForcedOff'] ?? '',
382 + array(
383 + Jetpack_AI_Settings::FORCED_OFF_ROUTE_FILTER,
384 + Jetpack_AI_Settings::FORCED_OFF_ROUTE_FILTER_VIP,
385 + Jetpack_AI_Settings::FORCED_OFF_ROUTE_MODULES,
386 + ),
387 + true
388 + ) ? $config['masterForcedOff'] : '',
389 + 'isOfflineMode' => ! empty( $config['isOfflineMode'] ),
390 + 'canConnectSite' => ! empty( $config['canConnectSite'] ),
315 391 'apiRoot' => esc_url_raw( rest_url() ),
316 392 'apiNonce' => wp_create_nonce( 'wp_rest' ),
317 393 'pluginUrl' => plugins_url( '', JETPACK__PLUGIN_FILE ),
394 + // Images ship from the plugin directory, so the plugin version is what busts their cache.
395 + 'assetsVersion' => JETPACK__VERSION,
318 396 // The redirect entry bakes in the jetpack_ai_yearly product and
319 397 // a post-checkout return to this page, so both can be
320 398 // retargeted without shipping a code change.
321 399 'upgradeUrl' => Redirect::get_url( 'jetpack-ai-hub-upgrade' ),
@@ -339,9 +417,9 @@
339 417 'isTest' => $is_internal_test,
340 418 // Identity for Tracks; the lookup can call WordPress.com on a
341 419 // cache miss, so it shares the sender's guard.
342 420 'tracksUserData' => $can_send_tracks ? self::get_tracks_user_data() : null,
343 - 'mcpSettingsApi' => $config['mcpSettingsApi'],
421 + 'mcpSettingsApi' => $config['mcpSettingsApi'] ?? array(),
344 422 );
345 423
346 424 wp_add_inline_script(
347 425 'jetpack-ai-admin',
@@ -366,15 +444,8 @@
366 444 absint( $blog_id )
367 445 ),
368 446 'before'
369 447 );
370 -
371 - wp_enqueue_style(
372 - 'jetpack-ai-admin',
373 - plugins_url( '_inc/build/jetpack-ai-admin.css', JETPACK__PLUGIN_FILE ),
374 - array( 'wp-components' ),
375 - $script_version
376 - );
377 448 }
378 449
379 450 /**
380 451 * Connected-user identity for Tracks; null when no WordPress.com account is
@@ -421,8 +492,50 @@
421 492 return '' !== $email && '@automattic.com' === substr( $email, -15 );
422 493 }
423 494
424 495 /**
496 + * The Search dashboard page the AI Answers row links to, or '' once a host
497 + * has removed it. Removal is remove_submenu_page() on `admin_menu`, which only
498 + * unsets the registry entry — menu_page_url() still answers for the page and
499 + * wp-admin then denies it — so the registry is read after that hook has run.
500 + *
501 + * @return string
502 + */
503 + private static function get_search_settings_url() {
504 + global $submenu;
505 +
506 + // Search registers under the Jetpack menu, or menu-less (parent '') when
507 + // `jetpack_search_should_add_search_submenu` says no; both are reachable.
508 + foreach ( array( 'jetpack', '' ) as $parent ) {
509 + foreach ( (array) ( $submenu[ $parent ] ?? array() ) as $item ) {
510 + if ( isset( $item[2] ) && 'jetpack-search' === $item[2] ) {
511 + return admin_url( 'admin.php?page=jetpack-search#/ai-answers' );
512 + }
513 + }
514 + }
515 +
516 + return '';
517 + }
518 +
519 + /**
520 + * Whether My Jetpack is loaded on this host.
521 + *
522 + * Hosts drop it with the `jetpack_my_jetpack_should_initialize` filter, and
523 + * VIP removes it from outside this codebase, where that filter cannot answer.
524 + *
525 + * @return bool
526 + */
527 + private static function has_my_jetpack() {
528 + if ( ( new Host() )->is_vip_site() ) {
529 + return false;
530 + }
531 +
532 + return class_exists( 'Automattic\\Jetpack\\My_Jetpack\\Initializer' )
533 + && method_exists( 'Automattic\\Jetpack\\My_Jetpack\\Initializer', 'should_initialize' )
534 + && \Automattic\Jetpack\My_Jetpack\Initializer::should_initialize();
535 + }
536 +
537 + /**
425 538 * Name of the purchase granting this site AI ("Jetpack Complete"), from
426 539 * My Jetpack's purchase data — its Plans section's source.
427 540 *
428 541 * @return array{name: string} An empty string when nothing paid grants AI
@@ -512,14 +625,29 @@
512 625 $this->page_render();
513 626 }
514 627
515 628 /**
516 - * Render the page container. The React app mounts into this div.
629 + * Render the page, or say why it could not be rendered.
517 630 *
518 - * AdminPage from @automattic/jetpack-components handles the full-page layout.
631 + * The generated wp-build page owns the markup the app mounts into.
519 632 */
520 633 public function page_render() {
521 - ?>
522 - <div id="jetpack-ai-root"></div>
523 - <?php
634 + if ( self::should_render_wp_build() ) {
635 + jetpack_plugin_jetpack_ai_hub_wp_admin_render_page(); // @phan-suppress-current-line PhanUndeclaredFunction -- should_render_wp_build() checks function_exists(); defined in the generated build/pages/, which Phan excludes.
636 + return;
637 + }
638 +
639 + // The build output is missing; say so rather than leaving a silent blank page.
640 + printf(
641 + '<div class="wrap"><h1>%s</h1><div class="notice notice-error"><p>%s</p></div></div>',
642 + esc_html__( 'Jetpack AI', 'jetpack' ),
643 + esc_html__( 'Jetpack AI could not be loaded because its assets are missing. Reinstalling or updating the plugin usually fixes this. If it keeps happening, contact your site administrator or host.', 'jetpack' )
644 + );
524 645 }
525 646 }
647 +
648 +/*
649 + * wp-build must load before add_actions() runs on any host, so hook it here: Jetpack_Admin and
650 + * mu-wpcom's WordPress.com Simple integration both require this file before `admin_menu`, and
651 + * add_action() dedupes.
652 + */
653 +add_action( 'admin_menu', array( 'Jetpack_AI_Page', 'maybe_load_wp_build' ), 1 );