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

579 lines 23.2 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 'cache' => __( 'Cache', 'xspeed' ),
240 'minify' => __( 'Optimization', 'xspeed' ),
241 'cdn' => __( 'Network', 'xspeed' ),
242 'health' => __( 'Health', 'xspeed' ),
243 'database' => __( 'Tools', 'xspeed' ),
244 // AI & Agents is an accordion group; anchor to its first row. On
245 // Free 'ai-provider' resolves via its locked manifest placeholder.
246 // License / Branding / Multisite live under Settings (a Free
247 // container, always present, so the anchor resolves on Free too).
248 'ai-provider' => __( 'AI & Agents', 'xspeed' ),
249 'settings' => __( 'Settings', 'xspeed' ),
250 );
251 }
252
253 /**
254 * Pluggable branding for the dashboard chrome. xspeed-pro's
255 * White-Label module hooks `xspeed_branding` to override these
256 * values from saved settings.
257 *
258 * @return array{name:string,footer_credit:?string,hide_help_links:bool,logo_svg:?string}
259 * @since 1.5.0
260 */
261 public static function branding() {
262 $defaults = array(
263 'name' => 'xSpeed Cache',
264 'footer_credit' => null, // null = show the default WPDeveloper credit.
265 'hide_help_links' => false,
266 'logo_svg' => null, // null = use the built-in brand mark.
267 );
268 $out = apply_filters( 'xspeed_branding', $defaults );
269 if ( ! is_array( $out ) ) {
270 return $defaults;
271 }
272 return array_merge( $defaults, $out );
273 }
274
275 /**
276 * Label for the WordPress admin sidebar menu entry — the resolved brand
277 * name verbatim ("xSpeed Cache" by default, or the white-label name).
278 *
279 * @param array{name:string} $brand Resolved branding array.
280 * @return string
281 */
282 private static function menu_label( array $brand ) {
283 return isset( $brand['name'] ) ? (string) $brand['name'] : 'xSpeed Cache';
284 }
285
286 /**
287 * URL of the SVG menu icon — the official xSpeed brand mark. Uses
288 * fill="currentColor" which renders black in <img> context; the inline
289 * style below recolors it via CSS filter for the WP admin menu states.
290 */
291 private static function menu_icon() {
292 return XSPEED_URL . 'assets/icon.svg';
293 }
294
295 public function render() {
296 $dark = 'dark' === self::user_theme() ? ' dark' : '';
297 // Pre-mount skeleton: the React bundle executes a beat after the page
298 // paints, so without this the mount div is an empty dark void while it
299 // loads. We render the xSpeed brand mark (pulsing) straight into the
300 // mount node in PHP; createRoot().render() REPLACES these children the
301 // instant React boots, so the skeleton disappears with no JS wiring.
302 // The mark is the bundled icon.svg; `dark:invert` (scoped in
303 // styles.css) keeps the currentColor mark visible on the dark shell.
304 printf(
305 '<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>',
306 esc_attr( $dark ),
307 esc_attr__( 'Loading', 'xspeed' ),
308 esc_url( XSPEED_URL . 'assets/icon.svg' ),
309 esc_attr__( 'xSpeed', 'xspeed' ),
310 esc_html__( 'Loading…', 'xspeed' )
311 );
312 }
313
314 /**
315 * Enqueue the stylesheet that recolors the menu icon to match the WP
316 * admin color scheme. Loads on every admin page (not just the plugin's
317 * page) because the menu icon is visible site-wide.
318 */
319 public function enqueue_menu_styles() {
320 wp_enqueue_style(
321 'xspeed-menu-icon',
322 XSPEED_URL . 'assets/menu-icon.css',
323 array(),
324 XSPEED_VERSION
325 );
326
327 // menu-icon.css recolors the built-in mark (fill=currentColor) to white
328 // via a brightness/invert filter. A white-label logo is a real image
329 // (often colored), so cancel the filter for it — otherwise the agency
330 // logo renders as a white silhouette. (FBS-82222)
331 $brand = self::branding();
332 if ( ! empty( $brand['logo_svg'] ) ) {
333 wp_add_inline_style(
334 'xspeed-menu-icon',
335 '#toplevel_page_' . self::PAGE_SLUG . ' .wp-menu-image img{filter:none;opacity:1}'
336 );
337 }
338 }
339
340 public function enqueue( $hook ) {
341 if ( 'toplevel_page_' . self::PAGE_SLUG !== $hook ) {
342 return;
343 }
344
345 $asset_js = XSPEED_DIR . 'assets/admin.js';
346 $asset_css = XSPEED_DIR . 'assets/admin.css';
347
348 // Needed for the media-library picker used by schema 'media' fields
349 // (e.g. the white-label brand logo). Loads window.wp.media.
350 wp_enqueue_media();
351
352 if ( file_exists( $asset_js ) ) {
353 // filemtime() cache-busts on every rebuild so a stable
354 // VERSION constant never serves stale JS through browser
355 // caches.
356 wp_enqueue_script(
357 'xspeed-admin',
358 XSPEED_URL . 'assets/admin.js',
359 array( 'wp-api-fetch', 'wp-i18n' ),
360 XSPEED_VERSION . '.' . filemtime( $asset_js ),
361 true
362 );
363 // Loads .mo files for the 'xspeed' text-domain into
364 // window.wp.i18n so the React `__()` helper resolves.
365 if ( function_exists( 'wp_set_script_translations' ) ) {
366 wp_set_script_translations(
367 'xspeed-admin',
368 'xspeed',
369 XSPEED_DIR . 'languages'
370 );
371 }
372 }
373
374 if ( file_exists( $asset_css ) ) {
375 wp_enqueue_style(
376 'xspeed-admin',
377 XSPEED_URL . 'assets/admin.css',
378 array(),
379 XSPEED_VERSION . '.' . filemtime( $asset_css )
380 );
381 }
382
383 /**
384 * Fires after the Free dashboard bundle is enqueued, before its
385 * config is localized. Pro hooks this to enqueue its own bundle
386 * with `xspeed-admin` as a dependency, so its panel
387 * registrations run after `window.XSpeedPro` is installed by
388 * Free's main.tsx.
389 *
390 * @since 1.5.0
391 */
392 do_action( 'xspeed_admin_enqueue', $hook );
393
394 wp_localize_script(
395 'xspeed-admin',
396 'XSpeedConfig',
397 array(
398 'restUrl' => esc_url_raw( rest_url( Rest_Api::NAMESPACE_V1 ) ),
399 'nonce' => wp_create_nonce( 'wp_rest' ),
400 'version' => XSPEED_VERSION,
401 'branding' => self::branding(),
402 // Site's UTC offset in seconds — so datetime pickers (e.g. the
403 // Pro prewarm scheduler) align with WP's own post-scheduling,
404 // which is site-time, not the admin's browser-local time.
405 'gmtOffset' => (int) round( (float) get_option( 'gmt_offset', 0 ) * HOUR_IN_SECONDS ),
406 // 'pro' when xspeed-pro is active + speaks our API
407 // version (see Tier_Registry); 'free' otherwise.
408 // 'trial' reserved for future license-server work.
409 'tier' => class_exists( '\\XSpeed\\Tier_Registry' ) && Tier_Registry::pro_active() ? 'pro' : 'free',
410 // Three-state Pro status so gated UI can show the RIGHT message:
411 // 'not_installed' — Pro plugin not active → "Upgrade to Pro"
412 // 'unlicensed' — Pro active, no valid license → "Activate license"
413 // 'active' — Pro active + licensed → (modules unlocked)
414 // Free can't read Pro's license directly (Free never references
415 // Pro), so Pro filters this via `xspeed_pro_state`. Default:
416 // not_installed when Pro is absent; 'active' when Pro is present
417 // (Pro downgrades to 'unlicensed' when its license isn't valid).
418 'proState' => self::pro_state(),
419 'bootstrap' => self::bootstrap_payload(),
420 )
421 );
422 }
423
424 /**
425 * Pre-rendered settings + status payload, baked into the page so the
426 * React app can mount with real values instead of showing a loading state
427 * while it waits for /settings and /status REST calls.
428 */
429 /**
430 * Three-state Pro status for the gated UI ('not_installed' | 'unlicensed'
431 * | 'active'). Free cannot read Pro's license (it never references Pro),
432 * so the authoritative value comes from the `xspeed_pro_state` filter that
433 * xspeed-pro hooks. The default here only distinguishes installed vs not —
434 * when Pro is active, Pro itself downgrades the value to 'unlicensed' if
435 * its license isn't valid.
436 *
437 * @return string
438 */
439 private static function pro_state(): string {
440 $pro_present = class_exists( '\\XSpeed\\Tier_Registry' ) && Tier_Registry::pro_active();
441 $default = $pro_present ? 'active' : 'not_installed';
442
443 /**
444 * Filter: xspeed_pro_state
445 *
446 * Lets the Pro plugin report its real license state so Free's gated
447 * panels can show "Activate license" (Pro installed, unlicensed) vs
448 * "Upgrade to Pro" (Pro not installed).
449 *
450 * @param string $state One of 'not_installed' | 'unlicensed' | 'active'.
451 */
452 $state = (string) apply_filters( 'xspeed_pro_state', $default );
453
454 return in_array( $state, array( 'not_installed', 'unlicensed', 'active' ), true ) ? $state : $default;
455 }
456
457 private static function bootstrap_payload() {
458 $opts = Settings::get();
459 $stats = Cache::get_stats();
460 // Static-rewrite probe state shipped to the React side so the
461 // dashboard can show a persistent banner when nginx/Apache
462 // hasn't been wired to bypass PHP yet. Cache-ONLY here (no $allow_probe
463 // arg) so the dashboard bootstrap never makes the loopback HTTP probe
464 // — that could add seconds to every admin page load on hosts that
465 // stall self-requests. The Health tab runs the live probe on demand;
466 // here we just surface whatever it last cached. (FBS-82142)
467 $server_type = Server::type();
468 // LiteSpeed serves hits via the PHP drop-in by design (its .htaccess
469 // can't add the HIT header or log a static hit), so the static-rewrite
470 // probe is N/A there — surfacing it would pop the "PHP fallback" nag
471 // for a setup working as designed. Only nginx + Apache probe.
472 $rewrite_capable = ( $server_type === Server::NGINX || $server_type === Server::APACHE );
473 $rewrite_probe = null;
474 if ( $opts['cache_enabled'] && $rewrite_capable ) {
475 $probe = Cache::probe_static_rewrite();
476 $rewrite_probe = array(
477 'active' => (bool) ( $probe['active'] ?? false ),
478 'server_type' => $server_type,
479 'snippet' => Cache::nginx_snippet(), // null on non-nginx hosts
480 'topology' => Server::rewrite_topology(),
481 // A reverse proxy / CDN in front (X-Forwarded-* present) usually
482 // means the request-terminating nginx isn't user-editable on this
483 // host — the banner uses this to switch to honest messaging
484 // instead of dangling a snippet the user can't apply.
485 'behind_proxy' => Server::is_behind_proxy(),
486 );
487 }
488
489 return array(
490 'settings' => $opts,
491 'status' => array(
492 'enabled' => (bool) $opts['cache_enabled'],
493 'stats' => $stats,
494 'server' => array(
495 'type' => $server_type,
496 'gzip_mode' => Server::gzip_mode(),
497 'gzip_active' => Gzip::probe_active(),
498 'nginx_snippet' => Gzip::nginx_snippet(),
499 ),
500 'rewrite_probe' => $rewrite_probe,
501 // Separate Mobile Cache visibility (FBS-83145) — mirrors the
502 // /status block so the dashboard callout renders on first paint
503 // without waiting for a status re-fetch.
504 'mobile_separate' => array(
505 'enabled' => (bool) ( $opts['cache_enabled'] ? ( Settings_Manager::get( 'cache' )['mobile_separate'] ?? false ) : false ),
506 'blocking' => $rewrite_capable && 'mobile_separate' === Cache::static_rewrite_block_reason(),
507 'needs_review' => Cache::mobile_separate_needs_review(),
508 ),
509 // One consolidated nginx server-block snippet aggregating
510 // every enabled module's directives (Cache static-rewrite,
511 // BrowserCache headers, GZIP, …). Null on non-nginx hosts
512 // or when no module contributes directives. Replaces the
513 // per-module "paste this snippet" notices.
514 'nginx_server_block' => Cache::full_nginx_server_block(),
515 ),
516 // Registered Modules (Free + Pro). The React app discovers them
517 // here and renders one sidebar item + one panel per module that
518 // declares a settings schema. Hidden modules are filtered.
519 'modules' => self::modules_payload(),
520 );
521 }
522
523 /**
524 * Serialize every available Module for the React dashboard. Each entry
525 * carries enough to render: identity (slug + tier), UI metadata (label,
526 * icon, optional description), current settings, and the typed schema
527 * the panel uses to render controls.
528 *
529 * Modules that declare `hidden => true` in ui_metadata (e.g., engine
530 * modules with no user-facing settings) are skipped.
531 */
532 public static function modules_payload() {
533 if ( ! class_exists( 'XSpeed\\Module_Registry' ) ) {
534 return array();
535 }
536 $out = array();
537 foreach ( Module_Registry::available() as $slug => $module ) {
538 $meta = $module->ui_metadata();
539 if ( ! empty( $meta['hidden'] ) ) {
540 continue;
541 }
542 $schema = $module->settings_schema();
543 $custom_panel = $meta['custom_panel'] ?? null;
544 // Skip only when the module has neither a schema nor a custom
545 // panel — i.e., truly nothing to render in the dashboard.
546 if ( empty( $schema ) && empty( $custom_panel ) ) {
547 continue;
548 }
549 $entry = array(
550 'slug' => $slug,
551 'tier' => $module->tier(),
552 'version' => $module->version(),
553 'label' => $meta['label'] ?? ucfirst( $slug ),
554 'icon' => $meta['icon'] ?? 'Square',
555 'description' => $meta['description'] ?? '',
556 // Short label for the module's own tab when it hosts a tabbed
557 // page (FBS-83633). Only set on host modules.
558 'tab_label' => $meta['tab_label'] ?? null,
559 'settings' => Settings_Manager::get( $slug ),
560 'schema' => $schema,
561 'notices' => $module->ui_notices(),
562 'custom_panel' => $meta['custom_panel'] ?? null,
563 );
564
565 /**
566 * Last-mile descriptor filter. Lets Pro (or third-party
567 * extensions) override any field before the module is
568 * shipped to React. Primary use: xspeed-pro hooks this to
569 * swap `custom_panel` to LicenseLockedPanel for Pro modules
570 * when the license is invalid, so unlocked modules stay
571 * visible in the sidebar (good upsell UX) but the panel
572 * shows an activation prompt instead of the real surface.
573 */
574 $out[] = apply_filters( 'xspeed_module_descriptor', $entry, $module );
575 }
576 return $out;
577 }
578 }
579