PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.2
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.2
1.4.2 1.4.1 1.4.0 1.3.7 1.3.6 1.3.5 1.3.4 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 All 36 releases
← All changes | includes/class-admin.php +325 -37 1.0.6 → 1.4.2 View file →
@@ -14,13 +14,23 @@
14 14 const PAGE_SLUG = 'xspeed';
15 15
16 16 const THEME_COOKIE = 'xspeed_theme';
17 17
18 + /**
19 + * The dashboard's sidebar offer reads a module's `purchase_needed` before
20 + * its `status_label`. An add-on checks for this constant before it labels
21 + * a setup step, which an older dashboard would read as "still for sale".
22 + */
23 + const OFFER_READS_PURCHASE_NEEDED = true;
24 +
18 25 public function __construct() {
19 26 add_action( 'admin_menu', array( $this, 'register_menu' ) );
20 27 add_action( 'admin_enqueue_scripts', array( $this, 'enqueue' ) );
21 28 add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_menu_styles' ) );
22 29 add_filter( 'admin_body_class', array( __CLASS__, 'admin_body_class' ) );
30 + // Add a "Settings" shortcut to the plugin's row on the Plugins screen,
31 + // deep-linking straight to the xSpeed dashboard. (FBS-83234)
32 + add_filter( 'plugin_action_links_' . plugin_basename( XSPEED_FILE ), array( __CLASS__, 'plugin_action_links' ) );
23 33 // Strip third-party admin notices on our screens only. Fires in
24 34 // `in_admin_header` (after `get_current_screen()` is populated
25 35 // but before notices render) so the screen check is reliable.
26 36 add_action( 'in_admin_header', array( __CLASS__, 'suppress_foreign_notices' ), 0 );
@@ -114,8 +124,18 @@
114 124 if ( ! self::is_plugin_page() ) {
115 125 return $classes;
116 126 }
117 127 $classes .= ' xspeed-page';
128 + // Dashboard-only marker (NOT the onboarding wizard, which shares
129 + // `xspeed-page` + the same admin.css but must scroll as a normal
130 + // centered card). Layout rules that reshape the WP admin chrome — the
131 + // sticky content column that pins the fixed-height dashboard while a
132 + // tall admin menu scrolls — are scoped to `.xspeed-dashboard` so they
133 + // never touch the wizard. Keyed on the ?page= slug (brand-independent).
134 + $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only screen gate.
135 + if ( self::PAGE_SLUG === $page ) {
136 + $classes .= ' xspeed-dashboard';
137 + }
118 138 if ( 'dark' === self::user_theme() ) {
119 139 $classes .= ' xspeed-dark';
120 140 }
121 141 return $classes;
@@ -120,8 +140,32 @@
120 140 }
121 141 return $classes;
122 142 }
123 143
144 + /**
145 + * Prepend a "Settings" link to the plugin's action links on the Plugins
146 + * screen, deep-linking to the xSpeed dashboard. Only users who can reach
147 + * the settings page (`manage_options`, the same cap the menu uses) see it.
148 + *
149 + * @param array $links Existing action links (Deactivate, etc.).
150 + * @return array
151 + */
152 + public static function plugin_action_links( $links ) {
153 + if ( ! current_user_can( 'manage_options' ) ) {
154 + return $links;
155 + }
156 +
157 + $settings_link = sprintf(
158 + '<a href="%1$s">%2$s</a>',
159 + esc_url( admin_url( 'admin.php?page=' . self::PAGE_SLUG ) ),
160 + esc_html__( 'Settings', 'xspeed' )
161 + );
162 +
163 + array_unshift( $links, $settings_link );
164 +
165 + return $links;
166 + }
167 +
124 168 public function register_menu() {
125 169 $brand = self::branding();
126 170 // Use the white-label logo for the admin menu icon when set, so the
127 171 // rebrand carries to the menu mark too — not just the title. Falls
@@ -126,16 +170,22 @@
126 170 // Use the white-label logo for the admin menu icon when set, so the
127 171 // rebrand carries to the menu mark too — not just the title. Falls
128 172 // back to the built-in xSpeed SVG. (FBS-82222)
129 173 $menu_icon = ! empty( $brand['logo_svg'] ) ? $brand['logo_svg'] : self::menu_icon();
174 + // Menu label + page <title> both read the resolved brand name
175 + // ("xSpeed Cache" by default; a white-label brand is used verbatim).
176 + // Positioned just after Appearance
177 + // (WP core slot 60) — up with the site-management group, not buried
178 + // down by Settings and not pinned to the very top.
179 + $menu_label = self::menu_label( $brand );
130 180 add_menu_page(
131 181 $brand['name'],
132 - $brand['name'],
182 + $menu_label,
133 183 'manage_options',
134 184 self::PAGE_SLUG,
135 185 array( $this, 'render' ),
136 186 $menu_icon,
137 - 80
187 + 61
138 188 );
139 189
140 190 // Drop WP's auto-generated duplicate first submenu (which
141 191 // inherits the toplevel "xSpeed" title). The group deep-links
@@ -152,8 +202,16 @@
152 202 // user inside the right module. WordPress's add_submenu_page
153 203 // strips the hash, so we inject directly into $submenu where
154 204 // it survives intact (the same trick Yoast / WooCommerce use
155 205 // for their per-area shortcuts).
206 + //
207 + // Writing to $submenu skips the capability check add_submenu_page()
208 + // does, and core keeps a top-level item the user can't open while its
209 + // submenu has entries. Without this guard an Editor saw an empty
210 + // "xSpeed Cache" flyout that led to a permissions error. (#720)
211 + if ( ! current_user_can( 'manage_options' ) ) {
212 + return;
213 + }
156 214 global $submenu;
157 215 $deep_links = self::deep_link_items();
158 216 foreach ( $deep_links as $hash => $label ) {
159 217 $submenu[ self::PAGE_SLUG ][] = array(
@@ -164,42 +222,41 @@
164 222 }
165 223 }
166 224
167 225 /**
168 - * Section deep-links rendered under the xSpeed menu. Mirrors the
169 - * React sidebar's group manifest (see src/components/sidebarGroups.ts)
170 - * so the WP admin rail reads as the same map as the in-app sidebar.
171 - * Each entry points at the first module slug in that group; the
172 - * React app's hash router lands the user on that module, which is
173 - * the first row of that group's sidebar section — no manual scrolling.
226 + * Section deep-links rendered under the xSpeed menu.
174 227 *
175 - * If you add a group to React's SIDEBAR_GROUPS, add it here too. The
176 - * two arrays are intentionally co-located in PR review (same change
177 - * touches both) rather than DRY'd through a generated config file —
178 - * this is the only PHP↔TS coupling and it's tiny.
228 + * This is deliberately a SHORT LIST, not a mirror of React's
229 + * SIDEBAR_GROUPS. It used to mirror all eight groups 1:1, which made the
230 + * WP admin rail a second, competing copy of the in-app sidebar — the same
231 + * map rendered twice, one of them permanently expanded and pushing every
232 + * other plugin's menu down the page.
179 233 *
180 - * @return array<string,string> map of first-module-hash → group label.
234 + * What stays are the entry points a user navigates to from OUTSIDE the
235 + * app: the dashboard itself, the two areas people arrive at with a
236 + * specific errand (AI & agents to connect an assistant, Settings), and the
237 + * wizard. Everything else — Cache, Optimization, Network, Health &
238 + * insights, Tools — is one click away in the app's own sidebar, which is
239 + * where in-app navigation belongs.
240 + *
241 + * So adding a group to React's SIDEBAR_GROUPS no longer means adding it
242 + * here. The two lists are intentionally different lengths now.
243 + *
244 + * Setup Wizard is NOT in this array — it's a real submenu page registered
245 + * by Onboarding::register_menu() on `admin_menu` at priority 20, so it
246 + * lands after these entries.
247 + *
248 + * @return array<string,string> map of route hash → menu label.
181 249 */
182 250 private static function deep_link_items() {
183 - // Mirrors the dashboard sidebar groups (SIDEBAR_GROUPS in
184 - // sidebarGroups.ts) 1:1, in the same order. Each key is the first
185 - // module slug of that group (the anchor the submenu deep-links to).
186 - // Keep these two lists in sync — they're the only PHP↔TS coupling.
187 - // Anchors must be slugs that are always present in the /modules
188 - // payload so the hash resolves on Free too: 'ai-provider' (AI group;
189 - // injected as a locked placeholder on Free via the upsell manifest)
190 - // and 'multisite' (Pro Add-ons; resolves to the real module on a
191 - // licensed multisite). NOTE: must match a slug React actually
192 - // renders — 'ai-privacy' was removed from the groups, so anchoring
193 - // AI there opened nothing (FBS-82743 #2). (FBS-82096)
251 + // Keys are route hashes (`/<group-id>`); the submenu loop prepends '#'.
252 + // React (nav.ts parseRoute) resolves `#/<group-id>` to that landing;
253 + // legacy `#slug` links still redirect, so old bookmarks keep working —
254 + // including bookmarks to the groups no longer listed here.
194 255 return array(
195 - 'cache' => __( 'Cache', 'xspeed' ),
196 - 'minify' => __( 'Performance', 'xspeed' ),
197 - 'cdn' => __( 'Network', 'xspeed' ),
198 - 'health' => __( 'Insights', 'xspeed' ),
199 - 'database' => __( 'Tools', 'xspeed' ),
200 - 'ai-provider' => __( 'AI', 'xspeed' ),
201 - 'multisite' => __( 'Pro Add-ons', 'xspeed' ),
256 + '/overview' => __( 'Overview', 'xspeed' ),
257 + '/ai-agents' => __( 'AI & agents', 'xspeed' ),
258 + '/settings' => __( 'Settings', 'xspeed' ),
202 259 );
203 260 }
204 261
205 262 /**
@@ -211,9 +268,9 @@
211 268 * @since 1.5.0
212 269 */
213 270 public static function branding() {
214 271 $defaults = array(
215 - 'name' => 'xSpeed',
272 + 'name' => 'xSpeed Cache',
216 273 'footer_credit' => null, // null = show the default WPDeveloper credit.
217 274 'hide_help_links' => false,
218 275 'logo_svg' => null, // null = use the built-in brand mark.
219 276 );
@@ -224,8 +281,19 @@
224 281 return array_merge( $defaults, $out );
225 282 }
226 283
227 284 /**
285 + * Label for the WordPress admin sidebar menu entry — the resolved brand
286 + * name verbatim ("xSpeed Cache" by default, or the white-label name).
287 + *
288 + * @param array{name:string} $brand Resolved branding array.
289 + * @return string
290 + */
291 + private static function menu_label( array $brand ) {
292 + return isset( $brand['name'] ) ? (string) $brand['name'] : 'xSpeed Cache';
293 + }
294 +
295 + /**
228 296 * URL of the SVG menu icon — the official xSpeed brand mark. Uses
229 297 * fill="currentColor" which renders black in <img> context; the inline
230 298 * style below recolors it via CSS filter for the WP admin menu states.
231 299 */
@@ -234,9 +302,23 @@
234 302 }
235 303
236 304 public function render() {
237 305 $dark = 'dark' === self::user_theme() ? ' dark' : '';
238 - printf( '<div id="xspeed-app" class="xspeed-root%s"></div>', esc_attr( $dark ) );
306 + // Pre-mount skeleton: the React bundle executes a beat after the page
307 + // paints, so without this the mount div is an empty dark void while it
308 + // loads. We render the xSpeed brand mark (pulsing) straight into the
309 + // mount node in PHP; createRoot().render() REPLACES these children the
310 + // instant React boots, so the skeleton disappears with no JS wiring.
311 + // The mark is the bundled icon.svg; `dark:invert` (scoped in
312 + // styles.css) keeps the currentColor mark visible on the dark shell.
313 + printf(
314 + '<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>',
315 + esc_attr( $dark ),
316 + esc_attr__( 'Loading', 'xspeed' ),
317 + esc_url( XSPEED_URL . 'assets/icon.svg' ),
318 + esc_attr__( 'xSpeed', 'xspeed' ),
319 + esc_html__( 'Loading…', 'xspeed' )
320 + );
239 321 }
240 322
241 323 /**
242 324 * Enqueue the stylesheet that recolors the menu icon to match the WP
@@ -297,13 +379,25 @@
297 379 );
298 380 }
299 381 }
300 382
383 + // Redesign v2 design tokens + self-hosted fonts. Hand-written (not
384 + // Vite-bundled) so the @font-face url('./fonts/…') resolve relative to
385 + // assets/. admin.css depends on it so the CSS vars are defined first.
386 + $theme_css = XSPEED_DIR . 'assets/theme.css';
387 + if ( file_exists( $theme_css ) ) {
388 + wp_enqueue_style(
389 + 'xspeed-theme',
390 + XSPEED_URL . 'assets/theme.css',
391 + array(),
392 + XSPEED_VERSION . '.' . filemtime( $theme_css )
393 + );
394 + }
301 395 if ( file_exists( $asset_css ) ) {
302 396 wp_enqueue_style(
303 397 'xspeed-admin',
304 398 XSPEED_URL . 'assets/admin.css',
305 - array(),
399 + array( 'xspeed-theme' ),
306 400 XSPEED_VERSION . '.' . filemtime( $asset_css )
307 401 );
308 402 }
309 403
@@ -317,10 +411,23 @@
317 411 * @since 1.5.0
318 412 */
319 413 do_action( 'xspeed_admin_enqueue', $hook );
320 414
321 - wp_localize_script(
322 - 'xspeed-admin',
415 + // NOT wp_localize_script(). WP_Scripts::localize() casts every scalar
416 + // to a string (wp-includes/class-wp-scripts.php: `(string) $value`),
417 + // so an int arrives in JS as "21600" and a bool as "1" or "".
418 + //
419 + // That silently broke the Pro prewarm scheduler: it guards with
420 + // `typeof gmtOffset === 'number'`, which a string fails, so the site's
421 + // UTC offset was treated as 0 and every one-off warm was scheduled
422 + // against UTC instead of site time — hours late on any non-UTC site,
423 + // under a label that confidently read "Site time (UTC)".
424 + //
425 + // wp_add_inline_script() with wp_json_encode() preserves types, so
426 + // numbers stay numbers and booleans stay booleans. Worth doing beyond
427 + // the one field: every future numeric or boolean config value would
428 + // hit the same trap. (#105)
429 + self::print_config(
323 430 'XSpeedConfig',
324 431 array(
325 432 'restUrl' => esc_url_raw( rest_url( Rest_Api::NAMESPACE_V1 ) ),
326 433 'nonce' => wp_create_nonce( 'wp_rest' ),
@@ -333,8 +440,23 @@
333 440 // 'pro' when xspeed-pro is active + speaks our API
334 441 // version (see Tier_Registry); 'free' otherwise.
335 442 // 'trial' reserved for future license-server work.
336 443 'tier' => class_exists( '\\XSpeed\\Tier_Registry' ) && Tier_Registry::pro_active() ? 'pro' : 'free',
444 + // Three-state Pro status so gated UI can show the RIGHT message:
445 + // 'not_installed' — Pro plugin not active → "Upgrade to Pro"
446 + // 'unlicensed' — Pro active, no valid license → "Activate license"
447 + // 'active' — Pro active + licensed → (modules unlocked)
448 + // Free can't read Pro's license directly (Free never references
449 + // Pro), so Pro filters this via `xspeed_pro_state`. Default:
450 + // not_installed when Pro is absent; 'active' when Pro is present
451 + // (Pro downgrades to 'unlicensed' when its license isn't valid).
452 + 'proState' => self::pro_state(),
453 + // The xCloud purge plugin is here: the site already has
454 + // Cloudflare Enterprise from its host, so the dashboard does
455 + // not offer the add-on.
456 + 'xcloudPurgePlugin' => Managed_Edge_Purge::installed(),
457 + // Setup Wizard URL, surfaced in the sidebar profile popover.
458 + 'wizardUrl' => admin_url( 'admin.php?page=' . Onboarding::PAGE_SLUG ),
337 459 'bootstrap' => self::bootstrap_payload(),
338 460 )
339 461 );
340 462 }
@@ -339,12 +461,68 @@
339 461 );
340 462 }
341 463
342 464 /**
465 + * Emit a JS global for the admin bundle with types intact.
466 + *
467 + * The type-preserving replacement for wp_localize_script(), which
468 + * stringifies every scalar. Attached to the `xspeed-admin` handle as a
469 + * `before` script so it is defined by the time the bundle executes —
470 + * exactly the ordering guarantee localize gave us.
471 + *
472 + * Shared by the dashboard and the onboarding wizard so neither can drift
473 + * back to the stringifying path.
474 + *
475 + * @param string $var_name JS global to define.
476 + * @param array $data Payload; encoded with wp_json_encode().
477 + */
478 + public static function print_config( string $var_name, array $data ): void {
479 + $json = wp_json_encode( $data );
480 + if ( false === $json ) {
481 + // Never emit a broken assignment — the bundle reads this global
482 + // on mount and a syntax error here blanks the whole screen.
483 + $json = '{}';
484 + }
485 + wp_add_inline_script(
486 + 'xspeed-admin',
487 + 'var ' . $var_name . ' = ' . $json . ';',
488 + 'before'
489 + );
490 + }
491 +
492 + /**
343 493 * Pre-rendered settings + status payload, baked into the page so the
344 494 * React app can mount with real values instead of showing a loading state
345 495 * while it waits for /settings and /status REST calls.
346 496 */
497 + /**
498 + * Three-state Pro status for the gated UI ('not_installed' | 'unlicensed'
499 + * | 'active'). Free cannot read Pro's license (it never references Pro),
500 + * so the authoritative value comes from the `xspeed_pro_state` filter that
501 + * xspeed-pro hooks. The default here only distinguishes installed vs not —
502 + * when Pro is active, Pro itself downgrades the value to 'unlicensed' if
503 + * its license isn't valid.
504 + *
505 + * @return string
506 + */
507 + private static function pro_state(): string {
508 + $pro_present = class_exists( '\\XSpeed\\Tier_Registry' ) && Tier_Registry::pro_active();
509 + $default = $pro_present ? 'active' : 'not_installed';
510 +
511 + /**
512 + * Filter: xspeed_pro_state
513 + *
514 + * Lets the Pro plugin report its real license state so Free's gated
515 + * panels can show "Activate license" (Pro installed, unlicensed) vs
516 + * "Upgrade to Pro" (Pro not installed).
517 + *
518 + * @param string $state One of 'not_installed' | 'unlicensed' | 'active'.
519 + */
520 + $state = (string) apply_filters( 'xspeed_pro_state', $default );
521 +
522 + return in_array( $state, array( 'not_installed', 'unlicensed', 'active' ), true ) ? $state : $default;
523 + }
524 +
347 525 private static function bootstrap_payload() {
348 526 $opts = Settings::get();
349 527 $stats = Cache::get_stats();
350 528 // Static-rewrite probe state shipped to the React side so the
@@ -372,8 +550,13 @@
372 550 // means the request-terminating nginx isn't user-editable on this
373 551 // host — the banner uses this to switch to honest messaging
374 552 // instead of dangling a snippet the user can't apply.
375 553 'behind_proxy' => Server::is_behind_proxy(),
554 + // Mirrors /status (see the note there). On first paint the
555 + // probe is whatever was last cached, so this is usually
556 + // `unknown` until the Health tab runs a live probe — the
557 + // dashboard needs the key present either way.
558 + 'rules' => Cache::rules_state( $probe ),
376 559 );
377 560 }
378 561
379 562 return array(
@@ -387,8 +570,25 @@
387 570 'gzip_active' => Gzip::probe_active(),
388 571 'nginx_snippet' => Gzip::nginx_snippet(),
389 572 ),
390 573 'rewrite_probe' => $rewrite_probe,
574 + // Separate Mobile Cache visibility (FBS-83145) — mirrors the
575 + // /status block so the dashboard callout renders on first paint
576 + // without waiting for a status re-fetch.
577 + 'mobile_separate' => array(
578 + 'enabled' => (bool) ( $opts['cache_enabled'] ? ( Settings_Manager::get( 'cache' )['mobile_separate'] ?? false ) : false ),
579 + // Gated to servers that HAVE a static fast path — on IIS or
580 + // an undetected server block_reason still falls through to
581 + // mobile_separate, and reporting that as "blocking" would
582 + // nag about a rewrite that does not exist there (#108).
583 + // LiteSpeed joined the capable set with the Static Fast
584 + // Path opt-in (#509); its own refusal (litespeed_dropin)
585 + // outranks mobile_separate, so this stays false there
586 + // until the opt-in is on.
587 + 'blocking' => ( $rewrite_capable || Server::LITESPEED === $server_type )
588 + && 'mobile_separate' === Cache::static_rewrite_block_reason(),
589 + 'needs_review' => Cache::mobile_separate_needs_review(),
590 + ),
391 591 // One consolidated nginx server-block snippet aggregating
392 592 // every enabled module's directives (Cache static-rewrite,
393 593 // BrowserCache headers, GZIP, …). Null on non-nginx hosts
394 594 // or when no module contributes directives. Replaces the
@@ -393,17 +593,41 @@
393 593 // BrowserCache headers, GZIP, …). Null on non-nginx hosts
394 594 // or when no module contributes directives. Replaces the
395 595 // per-module "paste this snippet" notices.
396 596 'nginx_server_block' => Cache::full_nginx_server_block(),
597 + // Mirrors the /status block so the enable-time disclosure is
598 + // correct on FIRST PAINT. Without it the top-bar switch can be
599 + // clicked before a status fetch lands, and the one moment the
600 + // warning exists for — a leftover drop-in about to be
601 + // replaced — is exactly when it would be missing.
602 + 'dropin' => Page_Cache_Detector::dropin_disclosure(),
397 603 ),
398 604 // Registered Modules (Free + Pro). The React app discovers them
399 605 // here and renders one sidebar item + one panel per module that
400 606 // declares a settings schema. Hidden modules are filtered.
401 607 'modules' => self::modules_payload(),
608 + // xSpeed Hub connection snapshot so the Account panel + header chip
609 + // render the correct connected/not-connected state on FIRST PAINT —
610 + // no fetch, no "not connected → connected" flash. Null when the MCP
611 + // module is unavailable (the panel then falls back to /mcp/hub).
612 + 'hub' => self::hub_payload(),
402 613 );
403 614 }
404 615
405 616 /**
617 + * xSpeed Hub connection snapshot for the dashboard bootstrap. Guarded so the
618 + * dashboard never hard-depends on the MCP module. Same shape as GET /mcp/hub.
619 + *
620 + * @return array<string,mixed>|null
621 + */
622 + private static function hub_payload() {
623 + if ( ! class_exists( '\XSpeed\Modules\Mcp\Mcp_Hub' ) ) {
624 + return null;
625 + }
626 + return \XSpeed\Modules\Mcp\Mcp_Hub::public_status();
627 + }
628 +
629 + /**
406 630 * Serialize every available Module for the React dashboard. Each entry
407 631 * carries enough to render: identity (slug + tier), UI metadata (label,
408 632 * icon, optional description), current settings, and the typed schema
409 633 * the panel uses to render controls.
@@ -427,19 +651,83 @@
427 651 // panel — i.e., truly nothing to render in the dashboard.
428 652 if ( empty( $schema ) && empty( $custom_panel ) ) {
429 653 continue;
430 654 }
655 + $settings = Settings_Manager::get_public( $slug );
656 + // Something else on the site has taken over this module's job. It
657 + // stands down whatever its own switch says, so it is reported off,
658 + // with the reason in place of its usual one.
659 + $blocked = $module->blocked_by();
660 + $active = $module->is_active();
661 +
431 662 $entry = array(
432 663 'slug' => $slug,
433 664 'tier' => $module->tier(),
434 665 'version' => $module->version(),
666 + // Promoted from inside `settings` so the payload is
667 + // self-describing. Consumers kept tripping on this — the Hub
668 + // rendered every module "Inactive" until it learned to look
669 + // inside the settings bag. `settings.enabled` is kept below
670 + // for back-compat; this is the same value, not a second
671 + // source of truth. Modules with no `enabled` key (status
672 + // panels like Health) report null rather than a misleading
673 + // false. (#146)
674 + 'enabled' => array_key_exists( 'enabled', $settings )
675 + ? (bool) $settings['enabled']
676 + : null,
677 + // Whether the module is actually DOING something, which is not
678 + // the same question as `enabled` above. "On" has several shapes
679 + // -- page caching lives in the global option, Minify and Lazy
680 + // are on when any flag is set, MCP when it is connected -- so
681 + // each module answers for itself via is_active(). Consumers
682 + // that want "what is switched on?" (the sidebar's "N on" badge)
683 + // must read THIS, not `enabled`, which only ever described the
684 + // modules that happen to store that one key. null means the
685 + // module has no meaningful on/off and should be excluded from
686 + // any count rather than treated as off. (#363)
687 + 'active' => ( null !== $blocked && true === $active ) ? false : $active,
688 + // One sentence explaining the line above, computed next to it
689 + // so the two cannot disagree. The UI shows it behind an (i)
690 + // beside the status pill: "On" is a bare assertion otherwise,
691 + // and least obvious exactly where it matters -- Media
692 + // Optimization reads On while its two most prominent switches
693 + // are off, because three other flags are on. (#363)
694 + 'active_reason' => null !== $blocked ? $blocked : $module->active_reason(),
695 + // A purchase or licence the module still waits on. Shown in the
696 + // page header in place of the On/Off pill. Not while another
697 + // module has taken over: that reason is the one to show.
698 + 'status_label' => null !== $blocked ? null : $module->status_label(),
435 699 'label' => $meta['label'] ?? ucfirst( $slug ),
436 700 'icon' => $meta['icon'] ?? 'Square',
437 701 'description' => $meta['description'] ?? '',
438 - 'settings' => Settings_Manager::get( $slug ),
702 + // The dashboard group the module belongs to (cache, performance,
703 + // network, insights, tools, ai-agents, settings), declared by the
704 + // module itself. The Hub kept its own copy of this map, so every
705 + // new module landed in its "Other" bucket until the Hub shipped.
706 + // Must agree with src/components/sidebarGroups.ts; a unit test
707 + // holds the two together.
708 + 'group' => $meta['group'] ?? null,
709 + // Short label for the module's own tab when it hosts a tabbed
710 + // page (FBS-83633). Only set on host modules.
711 + 'tab_label' => $meta['tab_label'] ?? null,
712 + // Public view: real values except secret fields, which are masked.
713 + // The dashboard bundle localizes this into page HTML, so a raw
714 + // credential here would be readable from view-source. (#115)
715 + 'settings' => $settings,
716 + // Where each value actually came from: a wp-config.php constant,
717 + // the option row, or the schema default. The panel renders a
718 + // constant-sourced field read-only and names the constant, so it
719 + // can never present an editable box over a value the site is not
720 + // using. Every module gets this, not just the ones that declare
721 + // constants today. (#398)
722 + 'setting_origins' => Settings_Manager::origins( $slug ),
439 723 'schema' => $schema,
440 724 'notices' => $module->ui_notices(),
441 725 'custom_panel' => $meta['custom_panel'] ?? null,
726 + // Why this module may not be switched on, when something else
727 + // on the site has taken over from it. Null for almost every
728 + // module almost always — Free never blocks anything itself.
729 + 'blocked_by' => $blocked,
442 730 );
443 731
444 732 /**
445 733 * Last-mile descriptor filter. Lets Pro (or third-party