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

562 lines 22.3 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 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
171 // (WP core slot 60) — up with the site-management group, not buried
172 // down by Settings and not pinned to the very top.
173 $menu_label = self::menu_label( $brand );
174 add_menu_page(
175 $brand['name'],
176 $menu_label,
177 'manage_options',
178 self::PAGE_SLUG,
179 array( $this, 'render' ),
180 $menu_icon,
181 61
182 );
183
184 // Drop WP's auto-generated duplicate first submenu (which
185 // inherits the toplevel "xSpeed" title). The group deep-links
186 // below replace it — keeping it would render a redundant
187 // "xSpeed" / "Dashboard" row that just re-links to the same
188 // page as the toplevel entry.
189 global $submenu;
190 // $submenu may not yet be populated for this slug; the
191 // `remove_submenu_page` call covers either case.
192 remove_submenu_page( self::PAGE_SLUG, self::PAGE_SLUG );
193
194 // Deep-link submenus — each points to the same dashboard page
195 // with a section hash so React's App.tsx routing lands the
196 // user inside the right module. WordPress's add_submenu_page
197 // strips the hash, so we inject directly into $submenu where
198 // it survives intact (the same trick Yoast / WooCommerce use
199 // for their per-area shortcuts).
200 global $submenu;
201 $deep_links = self::deep_link_items();
202 foreach ( $deep_links as $hash => $label ) {
203 $submenu[ self::PAGE_SLUG ][] = array(
204 $label,
205 'manage_options',
206 'admin.php?page=' . self::PAGE_SLUG . '#' . $hash,
207 );
208 }
209 }
210
211 /**
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.
218 *
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.
223 *
224 * @return array<string,string> map of first-module-hash → group label.
225 */
226 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)
239 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' ),
247 );
248 }
249
250 /**
251 * Pluggable branding for the dashboard chrome. xspeed-pro's
252 * White-Label module hooks `xspeed_branding` to override these
253 * values from saved settings.
254 *
255 * @return array{name:string,footer_credit:?string,hide_help_links:bool,logo_svg:?string}
256 * @since 1.5.0
257 */
258 public static function branding() {
259 $defaults = array(
260 'name' => 'xSpeed',
261 'footer_credit' => null, // null = show the default WPDeveloper credit.
262 'hide_help_links' => false,
263 'logo_svg' => null, // null = use the built-in brand mark.
264 );
265 $out = apply_filters( 'xspeed_branding', $defaults );
266 if ( ! is_array( $out ) ) {
267 return $defaults;
268 }
269 return array_merge( $defaults, $out );
270 }
271
272 /**
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.
277 *
278 * @param array{name:string} $brand Resolved branding array.
279 * @return string
280 */
281 private static function menu_label( array $brand ) {
282 $name = isset( $brand['name'] ) ? (string) $brand['name'] : 'xSpeed';
283 return 'xSpeed' === $name ? __( 'xSpeed Cache', 'xspeed' ) : $name;
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 printf( '<div id="xspeed-app" class="xspeed-root%s"></div>', esc_attr( $dark ) );
298 }
299
300 /**
301 * Enqueue the stylesheet that recolors the menu icon to match the WP
302 * admin color scheme. Loads on every admin page (not just the plugin's
303 * page) because the menu icon is visible site-wide.
304 */
305 public function enqueue_menu_styles() {
306 wp_enqueue_style(
307 'xspeed-menu-icon',
308 XSPEED_URL . 'assets/menu-icon.css',
309 array(),
310 XSPEED_VERSION
311 );
312
313 // menu-icon.css recolors the built-in mark (fill=currentColor) to white
314 // via a brightness/invert filter. A white-label logo is a real image
315 // (often colored), so cancel the filter for it — otherwise the agency
316 // logo renders as a white silhouette. (FBS-82222)
317 $brand = self::branding();
318 if ( ! empty( $brand['logo_svg'] ) ) {
319 wp_add_inline_style(
320 'xspeed-menu-icon',
321 '#toplevel_page_' . self::PAGE_SLUG . ' .wp-menu-image img{filter:none;opacity:1}'
322 );
323 }
324 }
325
326 public function enqueue( $hook ) {
327 if ( 'toplevel_page_' . self::PAGE_SLUG !== $hook ) {
328 return;
329 }
330
331 $asset_js = XSPEED_DIR . 'assets/admin.js';
332 $asset_css = XSPEED_DIR . 'assets/admin.css';
333
334 // Needed for the media-library picker used by schema 'media' fields
335 // (e.g. the white-label brand logo). Loads window.wp.media.
336 wp_enqueue_media();
337
338 if ( file_exists( $asset_js ) ) {
339 // filemtime() cache-busts on every rebuild so a stable
340 // VERSION constant never serves stale JS through browser
341 // caches.
342 wp_enqueue_script(
343 'xspeed-admin',
344 XSPEED_URL . 'assets/admin.js',
345 array( 'wp-api-fetch', 'wp-i18n' ),
346 XSPEED_VERSION . '.' . filemtime( $asset_js ),
347 true
348 );
349 // Loads .mo files for the 'xspeed' text-domain into
350 // window.wp.i18n so the React `__()` helper resolves.
351 if ( function_exists( 'wp_set_script_translations' ) ) {
352 wp_set_script_translations(
353 'xspeed-admin',
354 'xspeed',
355 XSPEED_DIR . 'languages'
356 );
357 }
358 }
359
360 if ( file_exists( $asset_css ) ) {
361 wp_enqueue_style(
362 'xspeed-admin',
363 XSPEED_URL . 'assets/admin.css',
364 array(),
365 XSPEED_VERSION . '.' . filemtime( $asset_css )
366 );
367 }
368
369 /**
370 * Fires after the Free dashboard bundle is enqueued, before its
371 * config is localized. Pro hooks this to enqueue its own bundle
372 * with `xspeed-admin` as a dependency, so its panel
373 * registrations run after `window.XSpeedPro` is installed by
374 * Free's main.tsx.
375 *
376 * @since 1.5.0
377 */
378 do_action( 'xspeed_admin_enqueue', $hook );
379
380 wp_localize_script(
381 'xspeed-admin',
382 'XSpeedConfig',
383 array(
384 'restUrl' => esc_url_raw( rest_url( Rest_Api::NAMESPACE_V1 ) ),
385 'nonce' => wp_create_nonce( 'wp_rest' ),
386 'version' => XSPEED_VERSION,
387 'branding' => self::branding(),
388 // Site's UTC offset in seconds — so datetime pickers (e.g. the
389 // Pro prewarm scheduler) align with WP's own post-scheduling,
390 // which is site-time, not the admin's browser-local time.
391 'gmtOffset' => (int) round( (float) get_option( 'gmt_offset', 0 ) * HOUR_IN_SECONDS ),
392 // 'pro' when xspeed-pro is active + speaks our API
393 // version (see Tier_Registry); 'free' otherwise.
394 // 'trial' reserved for future license-server work.
395 'tier' => class_exists( '\\XSpeed\\Tier_Registry' ) && Tier_Registry::pro_active() ? 'pro' : 'free',
396 // Three-state Pro status so gated UI can show the RIGHT message:
397 // 'not_installed' — Pro plugin not active → "Upgrade to Pro"
398 // 'unlicensed' — Pro active, no valid license → "Activate license"
399 // 'active' — Pro active + licensed → (modules unlocked)
400 // Free can't read Pro's license directly (Free never references
401 // Pro), so Pro filters this via `xspeed_pro_state`. Default:
402 // not_installed when Pro is absent; 'active' when Pro is present
403 // (Pro downgrades to 'unlicensed' when its license isn't valid).
404 'proState' => self::pro_state(),
405 'bootstrap' => self::bootstrap_payload(),
406 )
407 );
408 }
409
410 /**
411 * Pre-rendered settings + status payload, baked into the page so the
412 * React app can mount with real values instead of showing a loading state
413 * while it waits for /settings and /status REST calls.
414 */
415 /**
416 * Three-state Pro status for the gated UI ('not_installed' | 'unlicensed'
417 * | 'active'). Free cannot read Pro's license (it never references Pro),
418 * so the authoritative value comes from the `xspeed_pro_state` filter that
419 * xspeed-pro hooks. The default here only distinguishes installed vs not —
420 * when Pro is active, Pro itself downgrades the value to 'unlicensed' if
421 * its license isn't valid.
422 *
423 * @return string
424 */
425 private static function pro_state(): string {
426 $pro_present = class_exists( '\\XSpeed\\Tier_Registry' ) && Tier_Registry::pro_active();
427 $default = $pro_present ? 'active' : 'not_installed';
428
429 /**
430 * Filter: xspeed_pro_state
431 *
432 * Lets the Pro plugin report its real license state so Free's gated
433 * panels can show "Activate license" (Pro installed, unlicensed) vs
434 * "Upgrade to Pro" (Pro not installed).
435 *
436 * @param string $state One of 'not_installed' | 'unlicensed' | 'active'.
437 */
438 $state = (string) apply_filters( 'xspeed_pro_state', $default );
439
440 return in_array( $state, array( 'not_installed', 'unlicensed', 'active' ), true ) ? $state : $default;
441 }
442
443 private static function bootstrap_payload() {
444 $opts = Settings::get();
445 $stats = Cache::get_stats();
446 // Static-rewrite probe state shipped to the React side so the
447 // dashboard can show a persistent banner when nginx/Apache
448 // hasn't been wired to bypass PHP yet. Cache-ONLY here (no $allow_probe
449 // arg) so the dashboard bootstrap never makes the loopback HTTP probe
450 // — that could add seconds to every admin page load on hosts that
451 // stall self-requests. The Health tab runs the live probe on demand;
452 // here we just surface whatever it last cached. (FBS-82142)
453 $server_type = Server::type();
454 // LiteSpeed serves hits via the PHP drop-in by design (its .htaccess
455 // can't add the HIT header or log a static hit), so the static-rewrite
456 // probe is N/A there — surfacing it would pop the "PHP fallback" nag
457 // for a setup working as designed. Only nginx + Apache probe.
458 $rewrite_capable = ( $server_type === Server::NGINX || $server_type === Server::APACHE );
459 $rewrite_probe = null;
460 if ( $opts['cache_enabled'] && $rewrite_capable ) {
461 $probe = Cache::probe_static_rewrite();
462 $rewrite_probe = array(
463 'active' => (bool) ( $probe['active'] ?? false ),
464 'server_type' => $server_type,
465 'snippet' => Cache::nginx_snippet(), // null on non-nginx hosts
466 'topology' => Server::rewrite_topology(),
467 // A reverse proxy / CDN in front (X-Forwarded-* present) usually
468 // means the request-terminating nginx isn't user-editable on this
469 // host — the banner uses this to switch to honest messaging
470 // instead of dangling a snippet the user can't apply.
471 'behind_proxy' => Server::is_behind_proxy(),
472 );
473 }
474
475 return array(
476 'settings' => $opts,
477 'status' => array(
478 'enabled' => (bool) $opts['cache_enabled'],
479 'stats' => $stats,
480 'server' => array(
481 'type' => $server_type,
482 'gzip_mode' => Server::gzip_mode(),
483 'gzip_active' => Gzip::probe_active(),
484 'nginx_snippet' => Gzip::nginx_snippet(),
485 ),
486 'rewrite_probe' => $rewrite_probe,
487 // Separate Mobile Cache visibility (FBS-83145) — mirrors the
488 // /status block so the dashboard callout renders on first paint
489 // without waiting for a status re-fetch.
490 'mobile_separate' => array(
491 'enabled' => (bool) ( $opts['cache_enabled'] ? ( Settings_Manager::get( 'cache' )['mobile_separate'] ?? false ) : false ),
492 'blocking' => $rewrite_capable && 'mobile_separate' === Cache::static_rewrite_block_reason(),
493 'needs_review' => Cache::mobile_separate_needs_review(),
494 ),
495 // One consolidated nginx server-block snippet aggregating
496 // every enabled module's directives (Cache static-rewrite,
497 // BrowserCache headers, GZIP, …). Null on non-nginx hosts
498 // or when no module contributes directives. Replaces the
499 // per-module "paste this snippet" notices.
500 'nginx_server_block' => Cache::full_nginx_server_block(),
501 ),
502 // Registered Modules (Free + Pro). The React app discovers them
503 // here and renders one sidebar item + one panel per module that
504 // declares a settings schema. Hidden modules are filtered.
505 'modules' => self::modules_payload(),
506 );
507 }
508
509 /**
510 * Serialize every available Module for the React dashboard. Each entry
511 * carries enough to render: identity (slug + tier), UI metadata (label,
512 * icon, optional description), current settings, and the typed schema
513 * the panel uses to render controls.
514 *
515 * Modules that declare `hidden => true` in ui_metadata (e.g., engine
516 * modules with no user-facing settings) are skipped.
517 */
518 public static function modules_payload() {
519 if ( ! class_exists( 'XSpeed\\Module_Registry' ) ) {
520 return array();
521 }
522 $out = array();
523 foreach ( Module_Registry::available() as $slug => $module ) {
524 $meta = $module->ui_metadata();
525 if ( ! empty( $meta['hidden'] ) ) {
526 continue;
527 }
528 $schema = $module->settings_schema();
529 $custom_panel = $meta['custom_panel'] ?? null;
530 // Skip only when the module has neither a schema nor a custom
531 // panel — i.e., truly nothing to render in the dashboard.
532 if ( empty( $schema ) && empty( $custom_panel ) ) {
533 continue;
534 }
535 $entry = array(
536 'slug' => $slug,
537 'tier' => $module->tier(),
538 'version' => $module->version(),
539 'label' => $meta['label'] ?? ucfirst( $slug ),
540 'icon' => $meta['icon'] ?? 'Square',
541 'description' => $meta['description'] ?? '',
542 'settings' => Settings_Manager::get( $slug ),
543 'schema' => $schema,
544 'notices' => $module->ui_notices(),
545 'custom_panel' => $meta['custom_panel'] ?? null,
546 );
547
548 /**
549 * Last-mile descriptor filter. Lets Pro (or third-party
550 * extensions) override any field before the module is
551 * shipped to React. Primary use: xspeed-pro hooks this to
552 * swap `custom_panel` to LicenseLockedPanel for Pro modules
553 * when the license is invalid, so unlocked modules stay
554 * visible in the sidebar (good upsell UX) but the panel
555 * shows an activation prompt instead of the real surface.
556 */
557 $out[] = apply_filters( 'xspeed_module_descriptor', $entry, $module );
558 }
559 return $out;
560 }
561 }
562