PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.3
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.3
1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 All 29 releases
← All changes | includes/class-admin.php +177 -46 1.0.81.3.3 View file →
@@ -163,12 +163,11 @@
163 163 // Use the white-label logo for the admin menu icon when set, so the
164 164 // rebrand carries to the menu mark too — not just the title. Falls
165 165 // back to the built-in xSpeed SVG. (FBS-82222)
166 166 $menu_icon = ! empty( $brand['logo_svg'] ) ? $brand['logo_svg'] : self::menu_icon();
167 - // Menu label reads "xSpeed Cache" (the WP sidebar names the plugin by
168 - // what it does), while the page <title> keeps the shorter brand name.
169 - // White-label overrides use the custom brand verbatim — we only append
170 - // " Cache" to the built-in default. Positioned just after Appearance
167 + // Menu label + page <title> both read the resolved brand name
168 + // ("xSpeed Cache" by default; a white-label brand is used verbatim).
169 + // Positioned just after Appearance
171 170 // (WP core slot 60) — up with the site-management group, not buried
172 171 // down by Settings and not pinned to the very top.
173 172 $menu_label = self::menu_label( $brand );
174 173 add_menu_page(
@@ -208,43 +207,41 @@
208 207 }
209 208 }
210 209
211 210 /**
212 - * Section deep-links rendered under the xSpeed menu. Mirrors the
213 - * React sidebar's group manifest (see src/components/sidebarGroups.ts)
214 - * so the WP admin rail reads as the same map as the in-app sidebar.
215 - * Each entry points at the first module slug in that group; the
216 - * React app's hash router lands the user on that module, which is
217 - * the first row of that group's sidebar section — no manual scrolling.
211 + * Section deep-links rendered under the xSpeed menu.
218 212 *
219 - * If you add a group to React's SIDEBAR_GROUPS, add it here too. The
220 - * two arrays are intentionally co-located in PR review (same change
221 - * touches both) rather than DRY'd through a generated config file —
222 - * this is the only PHP↔TS coupling and it's tiny.
213 + * This is deliberately a SHORT LIST, not a mirror of React's
214 + * SIDEBAR_GROUPS. It used to mirror all eight groups 1:1, which made the
215 + * WP admin rail a second, competing copy of the in-app sidebar — the same
216 + * map rendered twice, one of them permanently expanded and pushing every
217 + * other plugin's menu down the page.
223 218 *
224 - * @return array<string,string> map of first-module-hash → group label.
219 + * What stays are the entry points a user navigates to from OUTSIDE the
220 + * app: the dashboard itself, the two areas people arrive at with a
221 + * specific errand (AI & agents to connect an assistant, Settings), and the
222 + * wizard. Everything else — Cache, Optimization, Network, Health &
223 + * insights, Tools — is one click away in the app's own sidebar, which is
224 + * where in-app navigation belongs.
225 + *
226 + * So adding a group to React's SIDEBAR_GROUPS no longer means adding it
227 + * here. The two lists are intentionally different lengths now.
228 + *
229 + * Setup Wizard is NOT in this array — it's a real submenu page registered
230 + * by Onboarding::register_menu() on `admin_menu` at priority 20, so it
231 + * lands after these entries.
232 + *
233 + * @return array<string,string> map of route hash → menu label.
225 234 */
226 235 private static function deep_link_items() {
227 - // Mirrors the dashboard sidebar groups (SIDEBAR_GROUPS in
228 - // sidebarGroups.ts) 1:1, in the same order. Each key is the first
229 - // module slug of that group (the anchor the submenu deep-links to).
230 - // Keep these two lists in sync — they're the only PHP↔TS coupling.
231 - // Anchors must be slugs that are always present in the /modules
232 - // payload so the hash resolves on Free too: 'ai-provider' (AI group;
233 - // the first AI slug — injected as a locked placeholder on Free via
234 - // the upsell manifest, so it resolves even though the real module is
235 - // Pro) and 'multisite' (Pro Add-ons; resolves to the real module on a
236 - // licensed multisite). NOTE: must match a slug React actually
237 - // renders — 'ai-privacy' was removed from the groups, so anchoring
238 - // AI there opened nothing (FBS-82743 #2). (FBS-82096)
236 + // Keys are route hashes (`/<group-id>`); the submenu loop prepends '#'.
237 + // React (nav.ts parseRoute) resolves `#/<group-id>` to that landing;
238 + // legacy `#slug` links still redirect, so old bookmarks keep working —
239 + // including bookmarks to the groups no longer listed here.
239 240 return array(
240 - 'cache' => __( 'Cache', 'xspeed' ),
241 - 'minify' => __( 'Performance', 'xspeed' ),
242 - 'cdn' => __( 'Network', 'xspeed' ),
243 - 'health' => __( 'Insights', 'xspeed' ),
244 - 'database' => __( 'Tools', 'xspeed' ),
245 - 'ai-provider' => __( 'AI', 'xspeed' ),
246 - 'multisite' => __( 'Pro Add-ons', 'xspeed' ),
241 + '/overview' => __( 'Overview', 'xspeed' ),
242 + '/ai-agents' => __( 'AI & agents', 'xspeed' ),
243 + '/settings' => __( 'Settings', 'xspeed' ),
247 244 );
248 245 }
249 246
250 247 /**
@@ -256,9 +253,9 @@
256 253 * @since 1.5.0
257 254 */
258 255 public static function branding() {
259 256 $defaults = array(
260 - 'name' => 'xSpeed',
257 + 'name' => 'xSpeed Cache',
261 258 'footer_credit' => null, // null = show the default WPDeveloper credit.
262 259 'hide_help_links' => false,
263 260 'logo_svg' => null, // null = use the built-in brand mark.
264 261 );
@@ -269,19 +266,16 @@
269 266 return array_merge( $defaults, $out );
270 267 }
271 268
272 269 /**
273 - * Label for the WordPress admin sidebar menu entry. The default brand
274 - * ("xSpeed") reads as "xSpeed Cache" in the menu so the sidebar names
275 - * the plugin by what it does. A white-label brand is used verbatim —
276 - * we never append " Cache" to a custom name.
270 + * Label for the WordPress admin sidebar menu entry — the resolved brand
271 + * name verbatim ("xSpeed Cache" by default, or the white-label name).
277 272 *
278 273 * @param array{name:string} $brand Resolved branding array.
279 274 * @return string
280 275 */
281 276 private static function menu_label( array $brand ) {
282 - $name = isset( $brand['name'] ) ? (string) $brand['name'] : 'xSpeed';
283 - return 'xSpeed' === $name ? __( 'xSpeed Cache', 'xspeed' ) : $name;
277 + return isset( $brand['name'] ) ? (string) $brand['name'] : 'xSpeed Cache';
284 278 }
285 279
286 280 /**
287 281 * URL of the SVG menu icon — the official xSpeed brand mark. Uses
@@ -293,9 +287,23 @@
293 287 }
294 288
295 289 public function render() {
296 290 $dark = 'dark' === self::user_theme() ? ' dark' : '';
297 - printf( '<div id="xspeed-app" class="xspeed-root%s"></div>', esc_attr( $dark ) );
291 + // Pre-mount skeleton: the React bundle executes a beat after the page
292 + // paints, so without this the mount div is an empty dark void while it
293 + // loads. We render the xSpeed brand mark (pulsing) straight into the
294 + // mount node in PHP; createRoot().render() REPLACES these children the
295 + // instant React boots, so the skeleton disappears with no JS wiring.
296 + // The mark is the bundled icon.svg; `dark:invert` (scoped in
297 + // styles.css) keeps the currentColor mark visible on the dark shell.
298 + printf(
299 + '<div id="xspeed-app" class="xspeed-root%1$s"><div class="xspeed-boot" role="status" aria-label="%2$s"><img class="xspeed-boot-mark" src="%3$s" alt="%4$s" width="48" height="48" /><span class="screen-reader-text">%5$s</span></div></div>',
300 + esc_attr( $dark ),
301 + esc_attr__( 'Loading', 'xspeed' ),
302 + esc_url( XSPEED_URL . 'assets/icon.svg' ),
303 + esc_attr__( 'xSpeed', 'xspeed' ),
304 + esc_html__( 'Loading…', 'xspeed' )
305 + );
298 306 }
299 307
300 308 /**
301 309 * Enqueue the stylesheet that recolors the menu icon to match the WP
@@ -356,13 +364,25 @@
356 364 );
357 365 }
358 366 }
359 367
368 + // Redesign v2 design tokens + self-hosted fonts. Hand-written (not
369 + // Vite-bundled) so the @font-face url('./fonts/…') resolve relative to
370 + // assets/. admin.css depends on it so the CSS vars are defined first.
371 + $theme_css = XSPEED_DIR . 'assets/theme.css';
372 + if ( file_exists( $theme_css ) ) {
373 + wp_enqueue_style(
374 + 'xspeed-theme',
375 + XSPEED_URL . 'assets/theme.css',
376 + array(),
377 + XSPEED_VERSION . '.' . filemtime( $theme_css )
378 + );
379 + }
360 380 if ( file_exists( $asset_css ) ) {
361 381 wp_enqueue_style(
362 382 'xspeed-admin',
363 383 XSPEED_URL . 'assets/admin.css',
364 - array(),
384 + array( 'xspeed-theme' ),
365 385 XSPEED_VERSION . '.' . filemtime( $asset_css )
366 386 );
367 387 }
368 388
@@ -376,10 +396,23 @@
376 396 * @since 1.5.0
377 397 */
378 398 do_action( 'xspeed_admin_enqueue', $hook );
379 399
380 - wp_localize_script(
381 - 'xspeed-admin',
400 + // NOT wp_localize_script(). WP_Scripts::localize() casts every scalar
401 + // to a string (wp-includes/class-wp-scripts.php: `(string) $value`),
402 + // so an int arrives in JS as "21600" and a bool as "1" or "".
403 + //
404 + // That silently broke the Pro prewarm scheduler: it guards with
405 + // `typeof gmtOffset === 'number'`, which a string fails, so the site's
406 + // UTC offset was treated as 0 and every one-off warm was scheduled
407 + // against UTC instead of site time — hours late on any non-UTC site,
408 + // under a label that confidently read "Site time (UTC)".
409 + //
410 + // wp_add_inline_script() with wp_json_encode() preserves types, so
411 + // numbers stay numbers and booleans stay booleans. Worth doing beyond
412 + // the one field: every future numeric or boolean config value would
413 + // hit the same trap. (#105)
414 + self::print_config(
382 415 'XSpeedConfig',
383 416 array(
384 417 'restUrl' => esc_url_raw( rest_url( Rest_Api::NAMESPACE_V1 ) ),
385 418 'nonce' => wp_create_nonce( 'wp_rest' ),
@@ -401,8 +434,10 @@
401 434 // Pro), so Pro filters this via `xspeed_pro_state`. Default:
402 435 // not_installed when Pro is absent; 'active' when Pro is present
403 436 // (Pro downgrades to 'unlicensed' when its license isn't valid).
404 437 'proState' => self::pro_state(),
438 + // Setup Wizard URL, surfaced in the sidebar profile popover.
439 + 'wizardUrl' => admin_url( 'admin.php?page=' . Onboarding::PAGE_SLUG ),
405 440 'bootstrap' => self::bootstrap_payload(),
406 441 )
407 442 );
408 443 }
@@ -407,8 +442,36 @@
407 442 );
408 443 }
409 444
410 445 /**
446 + * Emit a JS global for the admin bundle with types intact.
447 + *
448 + * The type-preserving replacement for wp_localize_script(), which
449 + * stringifies every scalar. Attached to the `xspeed-admin` handle as a
450 + * `before` script so it is defined by the time the bundle executes —
451 + * exactly the ordering guarantee localize gave us.
452 + *
453 + * Shared by the dashboard and the onboarding wizard so neither can drift
454 + * back to the stringifying path.
455 + *
456 + * @param string $var_name JS global to define.
457 + * @param array $data Payload; encoded with wp_json_encode().
458 + */
459 + public static function print_config( string $var_name, array $data ): void {
460 + $json = wp_json_encode( $data );
461 + if ( false === $json ) {
462 + // Never emit a broken assignment — the bundle reads this global
463 + // on mount and a syntax error here blanks the whole screen.
464 + $json = '{}';
465 + }
466 + wp_add_inline_script(
467 + 'xspeed-admin',
468 + 'var ' . $var_name . ' = ' . $json . ';',
469 + 'before'
470 + );
471 + }
472 +
473 + /**
411 474 * Pre-rendered settings + status payload, baked into the page so the
412 475 * React app can mount with real values instead of showing a loading state
413 476 * while it waits for /settings and /status REST calls.
414 477 */
@@ -497,17 +560,41 @@
497 560 // BrowserCache headers, GZIP, …). Null on non-nginx hosts
498 561 // or when no module contributes directives. Replaces the
499 562 // per-module "paste this snippet" notices.
500 563 'nginx_server_block' => Cache::full_nginx_server_block(),
564 + // Mirrors the /status block so the enable-time disclosure is
565 + // correct on FIRST PAINT. Without it the top-bar switch can be
566 + // clicked before a status fetch lands, and the one moment the
567 + // warning exists for — a leftover drop-in about to be
568 + // replaced — is exactly when it would be missing.
569 + 'dropin' => Page_Cache_Detector::dropin_disclosure(),
501 570 ),
502 571 // Registered Modules (Free + Pro). The React app discovers them
503 572 // here and renders one sidebar item + one panel per module that
504 573 // declares a settings schema. Hidden modules are filtered.
505 574 'modules' => self::modules_payload(),
575 + // xSpeed Hub connection snapshot so the Account panel + header chip
576 + // render the correct connected/not-connected state on FIRST PAINT —
577 + // no fetch, no "not connected → connected" flash. Null when the MCP
578 + // module is unavailable (the panel then falls back to /mcp/hub).
579 + 'hub' => self::hub_payload(),
506 580 );
507 581 }
508 582
509 583 /**
584 + * xSpeed Hub connection snapshot for the dashboard bootstrap. Guarded so the
585 + * dashboard never hard-depends on the MCP module. Same shape as GET /mcp/hub.
586 + *
587 + * @return array<string,mixed>|null
588 + */
589 + private static function hub_payload() {
590 + if ( ! class_exists( '\XSpeed\Modules\Mcp\Mcp_Hub' ) ) {
591 + return null;
592 + }
593 + return \XSpeed\Modules\Mcp\Mcp_Hub::public_status();
594 + }
595 +
596 + /**
510 597 * Serialize every available Module for the React dashboard. Each entry
511 598 * carries enough to render: identity (slug + tier), UI metadata (label,
512 599 * icon, optional description), current settings, and the typed schema
513 600 * the panel uses to render controls.
@@ -531,16 +618,60 @@
531 618 // panel — i.e., truly nothing to render in the dashboard.
532 619 if ( empty( $schema ) && empty( $custom_panel ) ) {
533 620 continue;
534 621 }
622 + $settings = Settings_Manager::get_public( $slug );
623 +
535 624 $entry = array(
536 625 'slug' => $slug,
537 626 'tier' => $module->tier(),
538 627 'version' => $module->version(),
628 + // Promoted from inside `settings` so the payload is
629 + // self-describing. Consumers kept tripping on this — the Hub
630 + // rendered every module "Inactive" until it learned to look
631 + // inside the settings bag. `settings.enabled` is kept below
632 + // for back-compat; this is the same value, not a second
633 + // source of truth. Modules with no `enabled` key (status
634 + // panels like Health) report null rather than a misleading
635 + // false. (#146)
636 + 'enabled' => array_key_exists( 'enabled', $settings )
637 + ? (bool) $settings['enabled']
638 + : null,
639 + // Whether the module is actually DOING something, which is not
640 + // the same question as `enabled` above. "On" has several shapes
641 + // -- page caching lives in the global option, Minify and Lazy
642 + // are on when any flag is set, MCP when it is connected -- so
643 + // each module answers for itself via is_active(). Consumers
644 + // that want "what is switched on?" (the sidebar's "N on" badge)
645 + // must read THIS, not `enabled`, which only ever described the
646 + // modules that happen to store that one key. null means the
647 + // module has no meaningful on/off and should be excluded from
648 + // any count rather than treated as off. (#363)
649 + 'active' => $module->is_active(),
650 + // One sentence explaining the line above, computed next to it
651 + // so the two cannot disagree. The UI shows it behind an (i)
652 + // beside the status pill: "On" is a bare assertion otherwise,
653 + // and least obvious exactly where it matters -- Media
654 + // Optimization reads On while its two most prominent switches
655 + // are off, because three other flags are on. (#363)
656 + 'active_reason' => $module->active_reason(),
539 657 'label' => $meta['label'] ?? ucfirst( $slug ),
540 658 'icon' => $meta['icon'] ?? 'Square',
541 659 'description' => $meta['description'] ?? '',
542 - 'settings' => Settings_Manager::get( $slug ),
660 + // Short label for the module's own tab when it hosts a tabbed
661 + // page (FBS-83633). Only set on host modules.
662 + 'tab_label' => $meta['tab_label'] ?? null,
663 + // Public view: real values except secret fields, which are masked.
664 + // The dashboard bundle localizes this into page HTML, so a raw
665 + // credential here would be readable from view-source. (#115)
666 + 'settings' => $settings,
667 + // Where each value actually came from: a wp-config.php constant,
668 + // the option row, or the schema default. The panel renders a
669 + // constant-sourced field read-only and names the constant, so it
670 + // can never present an editable box over a value the site is not
671 + // using. Every module gets this, not just the ones that declare
672 + // constants today. (#398)
673 + 'setting_origins' => Settings_Manager::origins( $slug ),
543 674 'schema' => $schema,
544 675 'notices' => $module->ui_notices(),
545 676 'custom_panel' => $meta['custom_panel'] ?? null,
546 677 );