PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.1.2
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.1.2
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
xspeed / includes / class-admin.php

class-admin.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.1.2, at includes/class-admin.php

604 lines 24.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Admin menu + asset enqueue.
4 *
5 * @package XSpeed
6 */
7
8 namespace XSpeed;
9
10 defined( 'ABSPATH' ) || exit;
11
12 class Admin {
13
14 const PAGE_SLUG = 'xspeed';
15
16 const THEME_COOKIE = 'xspeed_theme';
17
18 public function __construct() {
19 add_action( 'admin_menu', array( $this, 'register_menu' ) );
20 add_action( 'admin_enqueue_scripts', array( $this, 'enqueue' ) );
21 add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_menu_styles' ) );
22 add_filter( 'admin_body_class', array( __CLASS__, 'admin_body_class' ) );
23 // Add a "Settings" shortcut to the plugin's row on the Plugins screen,
24 // deep-linking straight to the xSpeed dashboard. (FBS-83234)
25 add_filter( 'plugin_action_links_' . plugin_basename( XSPEED_FILE ), array( __CLASS__, 'plugin_action_links' ) );
26 // Strip third-party admin notices on our screens only. Fires in
27 // `in_admin_header` (after `get_current_screen()` is populated
28 // but before notices render) so the screen check is reliable.
29 add_action( 'in_admin_header', array( __CLASS__, 'suppress_foreign_notices' ), 0 );
30 }
31
32 /**
33 * Remove every third-party admin notice on xSpeed admin screens so the
34 * dashboard stays visually clean. Scoped via `is_plugin_page()` — runs
35 * nowhere else. Our own notices stay rendered: register them on the
36 * dedicated `xspeed_admin_notices` action below, which fires after
37 * this suppression and is wired to all three core notice hooks.
38 *
39 * Hooks cleared: `admin_notices`, `all_admin_notices`,
40 * `user_admin_notices`, `network_admin_notices`. WordPress's own
41 * settings-saved / updated messages are emitted via `settings_errors()`
42 * and printed inline by `options.php` — they are NOT on these hooks
43 * and are unaffected.
44 *
45 * @return void
46 */
47 public static function suppress_foreign_notices() {
48 if ( ! self::is_plugin_page() ) {
49 return;
50 }
51 remove_all_actions( 'admin_notices' );
52 remove_all_actions( 'all_admin_notices' );
53 remove_all_actions( 'user_admin_notices' );
54 remove_all_actions( 'network_admin_notices' );
55
56 // Re-route the four standard notice hooks to a single namespaced
57 // action so xSpeed (and any deliberate extender that opts in)
58 // keeps a place to emit notices after the strip.
59 $relay = static function () {
60 /**
61 * Fires in place of WP's `admin_notices` family on xSpeed
62 * admin screens. Use this instead of `admin_notices` when
63 * you want a notice to survive xSpeed's third-party
64 * suppression.
65 *
66 * @since 1.0.3
67 */
68 do_action( 'xspeed_admin_notices' );
69 };
70 add_action( 'admin_notices', $relay );
71 add_action( 'all_admin_notices', $relay );
72 add_action( 'user_admin_notices', $relay );
73 add_action( 'network_admin_notices', $relay );
74 }
75
76 /**
77 * Server-side theme detection from the cookie written by useTheme. Used
78 * to emit the `.dark` class on the React mount node and the
79 * `xspeed-dark` class on `<body>` during the initial render — kills the
80 * light→dark flash that happens when JS adds those classes after the
81 * page has already painted.
82 *
83 * @return string 'dark' | 'light'
84 */
85 public static function user_theme() {
86 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotValidated, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- read-only string compare; value is sanitized via sanitize_key() on the next line before any use.
87 $raw = isset( $_COOKIE[ self::THEME_COOKIE ] ) ? wp_unslash( $_COOKIE[ self::THEME_COOKIE ] ) : '';
88 return 'dark' === sanitize_key( $raw ) ? 'dark' : 'light';
89 }
90
91 public static function is_plugin_page() {
92 // Prefer the ?page= slug: it's brand-independent. WordPress derives
93 // the submenu screen base from the *sanitized parent menu title*, so
94 // once White-Label renames the menu the base becomes e.g.
95 // "acmespeed_page_xspeed-onboarding" and any check anchored on
96 // self::PAGE_SLUG ("xspeed_page_…") silently stops matching — which
97 // dropped the xspeed-page / xspeed-dark body classes on the wizard and
98 // left it unstyled. Match the slug instead. (FBS-82222)
99 $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only screen gate.
100 if ( self::PAGE_SLUG === $page || Onboarding::PAGE_SLUG === $page ) {
101 return true;
102 }
103
104 // Fallback for contexts where $_GET['page'] isn't set but the screen is
105 // available. The toplevel base is slug-based (stable); the submenu base
106 // is title-derived, so match on its slug SUFFIX rather than the prefix.
107 $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
108 if ( ! $screen ) {
109 return false;
110 }
111 $base = (string) $screen->base;
112 return 0 === strpos( $base, 'toplevel_page_' . self::PAGE_SLUG )
113 || (bool) preg_match( '/_page_' . preg_quote( self::PAGE_SLUG, '/' ) . '($|-)/', $base );
114 }
115
116 public static function admin_body_class( $classes ) {
117 if ( ! self::is_plugin_page() ) {
118 return $classes;
119 }
120 $classes .= ' xspeed-page';
121 // Dashboard-only marker (NOT the onboarding wizard, which shares
122 // `xspeed-page` + the same admin.css but must scroll as a normal
123 // centered card). Layout rules that reshape the WP admin chrome — the
124 // sticky content column that pins the fixed-height dashboard while a
125 // tall admin menu scrolls — are scoped to `.xspeed-dashboard` so they
126 // never touch the wizard. Keyed on the ?page= slug (brand-independent).
127 $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only screen gate.
128 if ( self::PAGE_SLUG === $page ) {
129 $classes .= ' xspeed-dashboard';
130 }
131 if ( 'dark' === self::user_theme() ) {
132 $classes .= ' xspeed-dark';
133 }
134 return $classes;
135 }
136
137 /**
138 * Prepend a "Settings" link to the plugin's action links on the Plugins
139 * screen, deep-linking to the xSpeed dashboard. Only users who can reach
140 * the settings page (`manage_options`, the same cap the menu uses) see it.
141 *
142 * @param array $links Existing action links (Deactivate, etc.).
143 * @return array
144 */
145 public static function plugin_action_links( $links ) {
146 if ( ! current_user_can( 'manage_options' ) ) {
147 return $links;
148 }
149
150 $settings_link = sprintf(
151 '<a href="%1$s">%2$s</a>',
152 esc_url( admin_url( 'admin.php?page=' . self::PAGE_SLUG ) ),
153 esc_html__( 'Settings', 'xspeed' )
154 );
155
156 array_unshift( $links, $settings_link );
157
158 return $links;
159 }
160
161 public function register_menu() {
162 $brand = self::branding();
163 // Use the white-label logo for the admin menu icon when set, so the
164 // rebrand carries to the menu mark too — not just the title. Falls
165 // back to the built-in xSpeed SVG. (FBS-82222)
166 $menu_icon = ! empty( $brand['logo_svg'] ) ? $brand['logo_svg'] : self::menu_icon();
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
170 // (WP core slot 60) — up with the site-management group, not buried
171 // down by Settings and not pinned to the very top.
172 $menu_label = self::menu_label( $brand );
173 add_menu_page(
174 $brand['name'],
175 $menu_label,
176 'manage_options',
177 self::PAGE_SLUG,
178 array( $this, 'render' ),
179 $menu_icon,
180 61
181 );
182
183 // Drop WP's auto-generated duplicate first submenu (which
184 // inherits the toplevel "xSpeed" title). The group deep-links
185 // below replace it — keeping it would render a redundant
186 // "xSpeed" / "Dashboard" row that just re-links to the same
187 // page as the toplevel entry.
188 global $submenu;
189 // $submenu may not yet be populated for this slug; the
190 // `remove_submenu_page` call covers either case.
191 remove_submenu_page( self::PAGE_SLUG, self::PAGE_SLUG );
192
193 // Deep-link submenus — each points to the same dashboard page
194 // with a section hash so React's App.tsx routing lands the
195 // user inside the right module. WordPress's add_submenu_page
196 // strips the hash, so we inject directly into $submenu where
197 // it survives intact (the same trick Yoast / WooCommerce use
198 // for their per-area shortcuts).
199 global $submenu;
200 $deep_links = self::deep_link_items();
201 foreach ( $deep_links as $hash => $label ) {
202 $submenu[ self::PAGE_SLUG ][] = array(
203 $label,
204 'manage_options',
205 'admin.php?page=' . self::PAGE_SLUG . '#' . $hash,
206 );
207 }
208 }
209
210 /**
211 * Section deep-links rendered under the xSpeed menu. Mirrors the
212 * React sidebar's group manifest (see src/components/sidebarGroups.ts)
213 * so the WP admin rail reads as the same map as the in-app sidebar.
214 * Each entry points at the first module slug in that group; the
215 * React app's hash router lands the user on that module, which is
216 * the first row of that group's sidebar section — no manual scrolling.
217 *
218 * If you add a group to React's SIDEBAR_GROUPS, add it here too. The
219 * two arrays are intentionally co-located in PR review (same change
220 * touches both) rather than DRY'd through a generated config file —
221 * this is the only PHP↔TS coupling and it's tiny.
222 *
223 * @return array<string,string> map of first-module-hash → group label.
224 */
225 private static function deep_link_items() {
226 // Mirrors the dashboard sidebar groups (SIDEBAR_GROUPS in
227 // sidebarGroups.ts) 1:1, in the same order. Each key is the first
228 // module slug of that group (the anchor the submenu deep-links to).
229 // Keep these two lists in sync — they're the only PHP↔TS coupling.
230 // Anchors must be slugs that are always present in the /modules
231 // payload so the hash resolves on Free too: 'ai-provider' (AI group;
232 // the first AI slug — injected as a locked placeholder on Free via
233 // the upsell manifest, so it resolves even though the real module is
234 // Pro) and 'multisite' (Pro Add-ons; resolves to the real module on a
235 // licensed multisite). NOTE: must match a slug React actually
236 // renders — 'ai-privacy' was removed from the groups, so anchoring
237 // AI there opened nothing (FBS-82743 #2). (FBS-82096)
238 return array(
239 // Overview — the default landing screen. Not a module: it's a
240 // standalone top-level view React renders for the '#overview' hash
241 // (and for no hash at all). Pinned first, mirroring the pinned
242 // Overview row at the top of the in-app sidebar (Sidebar.tsx).
243 'overview' => __( 'Overview', 'xspeed' ),
244 'cache' => __( 'Cache', 'xspeed' ),
245 'minify' => __( 'Optimization', 'xspeed' ),
246 'cdn' => __( 'Network', 'xspeed' ),
247 'health' => __( 'Health', 'xspeed' ),
248 'database' => __( 'Tools', 'xspeed' ),
249 // AI & Agents is an accordion group; anchor to its first row. On
250 // Free 'ai-provider' resolves via its locked manifest placeholder.
251 // License / Branding / Multisite live under Settings (a Free
252 // container, always present, so the anchor resolves on Free too).
253 'ai-provider' => __( 'AI & Agents', 'xspeed' ),
254 'settings' => __( 'Settings', 'xspeed' ),
255 );
256 }
257
258 /**
259 * Pluggable branding for the dashboard chrome. xspeed-pro's
260 * White-Label module hooks `xspeed_branding` to override these
261 * values from saved settings.
262 *
263 * @return array{name:string,footer_credit:?string,hide_help_links:bool,logo_svg:?string}
264 * @since 1.5.0
265 */
266 public static function branding() {
267 $defaults = array(
268 'name' => 'xSpeed Cache',
269 'footer_credit' => null, // null = show the default WPDeveloper credit.
270 'hide_help_links' => false,
271 'logo_svg' => null, // null = use the built-in brand mark.
272 );
273 $out = apply_filters( 'xspeed_branding', $defaults );
274 if ( ! is_array( $out ) ) {
275 return $defaults;
276 }
277 return array_merge( $defaults, $out );
278 }
279
280 /**
281 * Label for the WordPress admin sidebar menu entry — the resolved brand
282 * name verbatim ("xSpeed Cache" by default, or the white-label name).
283 *
284 * @param array{name:string} $brand Resolved branding array.
285 * @return string
286 */
287 private static function menu_label( array $brand ) {
288 return isset( $brand['name'] ) ? (string) $brand['name'] : 'xSpeed Cache';
289 }
290
291 /**
292 * URL of the SVG menu icon — the official xSpeed brand mark. Uses
293 * fill="currentColor" which renders black in <img> context; the inline
294 * style below recolors it via CSS filter for the WP admin menu states.
295 */
296 private static function menu_icon() {
297 return XSPEED_URL . 'assets/icon.svg';
298 }
299
300 public function render() {
301 $dark = 'dark' === self::user_theme() ? ' dark' : '';
302 // Pre-mount skeleton: the React bundle executes a beat after the page
303 // paints, so without this the mount div is an empty dark void while it
304 // loads. We render the xSpeed brand mark (pulsing) straight into the
305 // mount node in PHP; createRoot().render() REPLACES these children the
306 // instant React boots, so the skeleton disappears with no JS wiring.
307 // The mark is the bundled icon.svg; `dark:invert` (scoped in
308 // styles.css) keeps the currentColor mark visible on the dark shell.
309 printf(
310 '<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>',
311 esc_attr( $dark ),
312 esc_attr__( 'Loading', 'xspeed' ),
313 esc_url( XSPEED_URL . 'assets/icon.svg' ),
314 esc_attr__( 'xSpeed', 'xspeed' ),
315 esc_html__( 'Loading…', 'xspeed' )
316 );
317 }
318
319 /**
320 * Enqueue the stylesheet that recolors the menu icon to match the WP
321 * admin color scheme. Loads on every admin page (not just the plugin's
322 * page) because the menu icon is visible site-wide.
323 */
324 public function enqueue_menu_styles() {
325 wp_enqueue_style(
326 'xspeed-menu-icon',
327 XSPEED_URL . 'assets/menu-icon.css',
328 array(),
329 XSPEED_VERSION
330 );
331
332 // menu-icon.css recolors the built-in mark (fill=currentColor) to white
333 // via a brightness/invert filter. A white-label logo is a real image
334 // (often colored), so cancel the filter for it — otherwise the agency
335 // logo renders as a white silhouette. (FBS-82222)
336 $brand = self::branding();
337 if ( ! empty( $brand['logo_svg'] ) ) {
338 wp_add_inline_style(
339 'xspeed-menu-icon',
340 '#toplevel_page_' . self::PAGE_SLUG . ' .wp-menu-image img{filter:none;opacity:1}'
341 );
342 }
343 }
344
345 public function enqueue( $hook ) {
346 if ( 'toplevel_page_' . self::PAGE_SLUG !== $hook ) {
347 return;
348 }
349
350 $asset_js = XSPEED_DIR . 'assets/admin.js';
351 $asset_css = XSPEED_DIR . 'assets/admin.css';
352
353 // Needed for the media-library picker used by schema 'media' fields
354 // (e.g. the white-label brand logo). Loads window.wp.media.
355 wp_enqueue_media();
356
357 if ( file_exists( $asset_js ) ) {
358 // filemtime() cache-busts on every rebuild so a stable
359 // VERSION constant never serves stale JS through browser
360 // caches.
361 wp_enqueue_script(
362 'xspeed-admin',
363 XSPEED_URL . 'assets/admin.js',
364 array( 'wp-api-fetch', 'wp-i18n' ),
365 XSPEED_VERSION . '.' . filemtime( $asset_js ),
366 true
367 );
368 // Loads .mo files for the 'xspeed' text-domain into
369 // window.wp.i18n so the React `__()` helper resolves.
370 if ( function_exists( 'wp_set_script_translations' ) ) {
371 wp_set_script_translations(
372 'xspeed-admin',
373 'xspeed',
374 XSPEED_DIR . 'languages'
375 );
376 }
377 }
378
379 if ( file_exists( $asset_css ) ) {
380 wp_enqueue_style(
381 'xspeed-admin',
382 XSPEED_URL . 'assets/admin.css',
383 array(),
384 XSPEED_VERSION . '.' . filemtime( $asset_css )
385 );
386 }
387
388 /**
389 * Fires after the Free dashboard bundle is enqueued, before its
390 * config is localized. Pro hooks this to enqueue its own bundle
391 * with `xspeed-admin` as a dependency, so its panel
392 * registrations run after `window.XSpeedPro` is installed by
393 * Free's main.tsx.
394 *
395 * @since 1.5.0
396 */
397 do_action( 'xspeed_admin_enqueue', $hook );
398
399 wp_localize_script(
400 'xspeed-admin',
401 'XSpeedConfig',
402 array(
403 'restUrl' => esc_url_raw( rest_url( Rest_Api::NAMESPACE_V1 ) ),
404 'nonce' => wp_create_nonce( 'wp_rest' ),
405 'version' => XSPEED_VERSION,
406 'branding' => self::branding(),
407 // Site's UTC offset in seconds — so datetime pickers (e.g. the
408 // Pro prewarm scheduler) align with WP's own post-scheduling,
409 // which is site-time, not the admin's browser-local time.
410 'gmtOffset' => (int) round( (float) get_option( 'gmt_offset', 0 ) * HOUR_IN_SECONDS ),
411 // 'pro' when xspeed-pro is active + speaks our API
412 // version (see Tier_Registry); 'free' otherwise.
413 // 'trial' reserved for future license-server work.
414 'tier' => class_exists( '\\XSpeed\\Tier_Registry' ) && Tier_Registry::pro_active() ? 'pro' : 'free',
415 // Three-state Pro status so gated UI can show the RIGHT message:
416 // 'not_installed' — Pro plugin not active → "Upgrade to Pro"
417 // 'unlicensed' — Pro active, no valid license → "Activate license"
418 // 'active' — Pro active + licensed → (modules unlocked)
419 // Free can't read Pro's license directly (Free never references
420 // Pro), so Pro filters this via `xspeed_pro_state`. Default:
421 // not_installed when Pro is absent; 'active' when Pro is present
422 // (Pro downgrades to 'unlicensed' when its license isn't valid).
423 'proState' => self::pro_state(),
424 // Setup Wizard URL, surfaced in the sidebar profile popover.
425 'wizardUrl' => admin_url( 'admin.php?page=' . Onboarding::PAGE_SLUG ),
426 'bootstrap' => self::bootstrap_payload(),
427 )
428 );
429 }
430
431 /**
432 * Pre-rendered settings + status payload, baked into the page so the
433 * React app can mount with real values instead of showing a loading state
434 * while it waits for /settings and /status REST calls.
435 */
436 /**
437 * Three-state Pro status for the gated UI ('not_installed' | 'unlicensed'
438 * | 'active'). Free cannot read Pro's license (it never references Pro),
439 * so the authoritative value comes from the `xspeed_pro_state` filter that
440 * xspeed-pro hooks. The default here only distinguishes installed vs not —
441 * when Pro is active, Pro itself downgrades the value to 'unlicensed' if
442 * its license isn't valid.
443 *
444 * @return string
445 */
446 private static function pro_state(): string {
447 $pro_present = class_exists( '\\XSpeed\\Tier_Registry' ) && Tier_Registry::pro_active();
448 $default = $pro_present ? 'active' : 'not_installed';
449
450 /**
451 * Filter: xspeed_pro_state
452 *
453 * Lets the Pro plugin report its real license state so Free's gated
454 * panels can show "Activate license" (Pro installed, unlicensed) vs
455 * "Upgrade to Pro" (Pro not installed).
456 *
457 * @param string $state One of 'not_installed' | 'unlicensed' | 'active'.
458 */
459 $state = (string) apply_filters( 'xspeed_pro_state', $default );
460
461 return in_array( $state, array( 'not_installed', 'unlicensed', 'active' ), true ) ? $state : $default;
462 }
463
464 private static function bootstrap_payload() {
465 $opts = Settings::get();
466 $stats = Cache::get_stats();
467 // Static-rewrite probe state shipped to the React side so the
468 // dashboard can show a persistent banner when nginx/Apache
469 // hasn't been wired to bypass PHP yet. Cache-ONLY here (no $allow_probe
470 // arg) so the dashboard bootstrap never makes the loopback HTTP probe
471 // — that could add seconds to every admin page load on hosts that
472 // stall self-requests. The Health tab runs the live probe on demand;
473 // here we just surface whatever it last cached. (FBS-82142)
474 $server_type = Server::type();
475 // LiteSpeed serves hits via the PHP drop-in by design (its .htaccess
476 // can't add the HIT header or log a static hit), so the static-rewrite
477 // probe is N/A there — surfacing it would pop the "PHP fallback" nag
478 // for a setup working as designed. Only nginx + Apache probe.
479 $rewrite_capable = ( $server_type === Server::NGINX || $server_type === Server::APACHE );
480 $rewrite_probe = null;
481 if ( $opts['cache_enabled'] && $rewrite_capable ) {
482 $probe = Cache::probe_static_rewrite();
483 $rewrite_probe = array(
484 'active' => (bool) ( $probe['active'] ?? false ),
485 'server_type' => $server_type,
486 'snippet' => Cache::nginx_snippet(), // null on non-nginx hosts
487 'topology' => Server::rewrite_topology(),
488 // A reverse proxy / CDN in front (X-Forwarded-* present) usually
489 // means the request-terminating nginx isn't user-editable on this
490 // host — the banner uses this to switch to honest messaging
491 // instead of dangling a snippet the user can't apply.
492 'behind_proxy' => Server::is_behind_proxy(),
493 );
494 }
495
496 return array(
497 'settings' => $opts,
498 'status' => array(
499 'enabled' => (bool) $opts['cache_enabled'],
500 'stats' => $stats,
501 'server' => array(
502 'type' => $server_type,
503 'gzip_mode' => Server::gzip_mode(),
504 'gzip_active' => Gzip::probe_active(),
505 'nginx_snippet' => Gzip::nginx_snippet(),
506 ),
507 'rewrite_probe' => $rewrite_probe,
508 // Separate Mobile Cache visibility (FBS-83145) — mirrors the
509 // /status block so the dashboard callout renders on first paint
510 // without waiting for a status re-fetch.
511 'mobile_separate' => array(
512 'enabled' => (bool) ( $opts['cache_enabled'] ? ( Settings_Manager::get( 'cache' )['mobile_separate'] ?? false ) : false ),
513 'blocking' => $rewrite_capable && 'mobile_separate' === Cache::static_rewrite_block_reason(),
514 'needs_review' => Cache::mobile_separate_needs_review(),
515 ),
516 // One consolidated nginx server-block snippet aggregating
517 // every enabled module's directives (Cache static-rewrite,
518 // BrowserCache headers, GZIP, …). Null on non-nginx hosts
519 // or when no module contributes directives. Replaces the
520 // per-module "paste this snippet" notices.
521 'nginx_server_block' => Cache::full_nginx_server_block(),
522 ),
523 // Registered Modules (Free + Pro). The React app discovers them
524 // here and renders one sidebar item + one panel per module that
525 // declares a settings schema. Hidden modules are filtered.
526 'modules' => self::modules_payload(),
527 // xSpeed Hub connection snapshot so the Account panel + header chip
528 // render the correct connected/not-connected state on FIRST PAINT —
529 // no fetch, no "not connected → connected" flash. Null when the MCP
530 // module is unavailable (the panel then falls back to /mcp/hub).
531 'hub' => self::hub_payload(),
532 );
533 }
534
535 /**
536 * xSpeed Hub connection snapshot for the dashboard bootstrap. Guarded so the
537 * dashboard never hard-depends on the MCP module. Same shape as GET /mcp/hub.
538 *
539 * @return array<string,mixed>|null
540 */
541 private static function hub_payload() {
542 if ( ! class_exists( '\XSpeed\Modules\Mcp\Mcp_Hub' ) ) {
543 return null;
544 }
545 return \XSpeed\Modules\Mcp\Mcp_Hub::public_status();
546 }
547
548 /**
549 * Serialize every available Module for the React dashboard. Each entry
550 * carries enough to render: identity (slug + tier), UI metadata (label,
551 * icon, optional description), current settings, and the typed schema
552 * the panel uses to render controls.
553 *
554 * Modules that declare `hidden => true` in ui_metadata (e.g., engine
555 * modules with no user-facing settings) are skipped.
556 */
557 public static function modules_payload() {
558 if ( ! class_exists( 'XSpeed\\Module_Registry' ) ) {
559 return array();
560 }
561 $out = array();
562 foreach ( Module_Registry::available() as $slug => $module ) {
563 $meta = $module->ui_metadata();
564 if ( ! empty( $meta['hidden'] ) ) {
565 continue;
566 }
567 $schema = $module->settings_schema();
568 $custom_panel = $meta['custom_panel'] ?? null;
569 // Skip only when the module has neither a schema nor a custom
570 // panel — i.e., truly nothing to render in the dashboard.
571 if ( empty( $schema ) && empty( $custom_panel ) ) {
572 continue;
573 }
574 $entry = array(
575 'slug' => $slug,
576 'tier' => $module->tier(),
577 'version' => $module->version(),
578 'label' => $meta['label'] ?? ucfirst( $slug ),
579 'icon' => $meta['icon'] ?? 'Square',
580 'description' => $meta['description'] ?? '',
581 // Short label for the module's own tab when it hosts a tabbed
582 // page (FBS-83633). Only set on host modules.
583 'tab_label' => $meta['tab_label'] ?? null,
584 'settings' => Settings_Manager::get( $slug ),
585 'schema' => $schema,
586 'notices' => $module->ui_notices(),
587 'custom_panel' => $meta['custom_panel'] ?? null,
588 );
589
590 /**
591 * Last-mile descriptor filter. Lets Pro (or third-party
592 * extensions) override any field before the module is
593 * shipped to React. Primary use: xspeed-pro hooks this to
594 * swap `custom_panel` to LicenseLockedPanel for Pro modules
595 * when the license is invalid, so unlocked modules stay
596 * visible in the sidebar (good upsell UX) but the panel
597 * shows an activation prompt instead of the real surface.
598 */
599 $out[] = apply_filters( 'xspeed_module_descriptor', $entry, $module );
600 }
601 return $out;
602 }
603 }
604