| @@ -19,11 +19,62 @@ | ||
| 19 | 19 | add_action( 'admin_menu', array( $this, 'register_menu' ) ); |
| 20 | 20 | add_action( 'admin_enqueue_scripts', array( $this, 'enqueue' ) ); |
| 21 | 21 | add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_menu_styles' ) ); |
| 22 | 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 ); | |
| 23 | 30 | } |
| 24 | 31 | |
| 25 | 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 | + /** | |
| 26 | 77 | * Server-side theme detection from the cookie written by useTheme. Used |
| 27 | 78 | * to emit the `.dark` class on the React mount node and the |
| 28 | 79 | * `xspeed-dark` class on `<body>` during the initial render — kills the |
| 29 | 80 | * light→dark flash that happens when JS adds those classes after the |
| @@ -37,17 +88,30 @@ | ||
| 37 | 88 | return 'dark' === sanitize_key( $raw ) ? 'dark' : 'light'; |
| 38 | 89 | } |
| 39 | 90 | |
| 40 | 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. | |
| 41 | 107 | $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; |
| 42 | 108 | if ( ! $screen ) { |
| 43 | 109 | return false; |
| 44 | 110 | } |
| 45 | - // Toplevel page and any submenu (e.g., the onboarding wizard) share | |
| 46 | - // the `xspeed` prefix in their screen base. | |
| 47 | - return 0 === strpos( (string) $screen->base, 'toplevel_page_' . self::PAGE_SLUG ) | |
| 48 | - || 0 === strpos( (string) $screen->base, self::PAGE_SLUG . '_page_' ) | |
| 49 | - || 0 === strpos( (string) $screen->base, 'admin_page_' . Onboarding::PAGE_SLUG ); | |
| 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 ); | |
| 50 | 114 | } |
| 51 | 115 | |
| 52 | 116 | public static function admin_body_class( $classes ) { |
| 53 | 117 | if ( ! self::is_plugin_page() ) { |
| @@ -53,8 +117,18 @@ | ||
| 53 | 117 | if ( ! self::is_plugin_page() ) { |
| 54 | 118 | return $classes; |
| 55 | 119 | } |
| 56 | 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 | + } | |
| 57 | 131 | if ( 'dark' === self::user_theme() ) { |
| 58 | 132 | $classes .= ' xspeed-dark'; |
| 59 | 133 | } |
| 60 | 134 | return $classes; |
| @@ -59,32 +133,63 @@ | ||
| 59 | 133 | } |
| 60 | 134 | return $classes; |
| 61 | 135 | } |
| 62 | 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 | + | |
| 63 | 161 | public function register_menu() { |
| 64 | 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 ); | |
| 65 | 173 | add_menu_page( |
| 66 | 174 | $brand['name'], |
| 67 | - $brand['name'], | |
| 175 | + $menu_label, | |
| 68 | 176 | 'manage_options', |
| 69 | 177 | self::PAGE_SLUG, |
| 70 | 178 | array( $this, 'render' ), |
| 71 | - self::menu_icon(), | |
| 72 | - 80 | |
| 179 | + $menu_icon, | |
| 180 | + 61 | |
| 73 | 181 | ); |
| 74 | 182 | |
| 75 | - // Rename the auto-generated first submenu (which inherits the | |
| 76 | - // toplevel title "xSpeed") to "Dashboard" — keeps the same slug so | |
| 77 | - // it stays the default landing page. | |
| 78 | - add_submenu_page( | |
| 79 | - self::PAGE_SLUG, | |
| 80 | - /* translators: %s = brand name (xSpeed by default; agencies may white-label). */ | |
| 81 | - sprintf( __( '%s Dashboard', 'xspeed' ), $brand['name'] ), | |
| 82 | - __( 'Dashboard', 'xspeed' ), | |
| 83 | - 'manage_options', | |
| 84 | - self::PAGE_SLUG, | |
| 85 | - array( $this, 'render' ) | |
| 86 | - ); | |
| 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 ); | |
| 87 | 192 | |
| 88 | 193 | // Deep-link submenus — each points to the same dashboard page |
| 89 | 194 | // with a section hash so React's App.tsx routing lands the |
| 90 | 195 | // user inside the right module. WordPress's add_submenu_page |
| @@ -102,20 +207,41 @@ | ||
| 102 | 207 | } |
| 103 | 208 | } |
| 104 | 209 | |
| 105 | 210 | /** |
| 106 | - * Section deep-links rendered under the xSpeed menu. Keep the list | |
| 107 | - * short — one entry per top-level concern, not per module. Anything | |
| 108 | - * over ~5 entries clutters the WP admin menu rail. | |
| 211 | + * Section deep-links rendered under the xSpeed menu. | |
| 109 | 212 | * |
| 110 | - * @return array<string,string> map of hash → label. | |
| 213 | + * This is deliberately a SHORT LIST, not a mirror of React's | |
| 214 | + * SIDEBAR_GROUPS. It used to mirror all eight groups 1:1, which made the | |
| 215 | + * WP admin rail a second, competing copy of the in-app sidebar — the same | |
| 216 | + * map rendered twice, one of them permanently expanded and pushing every | |
| 217 | + * other plugin's menu down the page. | |
| 218 | + * | |
| 219 | + * What stays are the entry points a user navigates to from OUTSIDE the | |
| 220 | + * app: the dashboard itself, the two areas people arrive at with a | |
| 221 | + * specific errand (AI & agents to connect an assistant, Settings), and the | |
| 222 | + * wizard. Everything else — Cache, Optimization, Network, Health & | |
| 223 | + * insights, Tools — is one click away in the app's own sidebar, which is | |
| 224 | + * where in-app navigation belongs. | |
| 225 | + * | |
| 226 | + * So adding a group to React's SIDEBAR_GROUPS no longer means adding it | |
| 227 | + * here. The two lists are intentionally different lengths now. | |
| 228 | + * | |
| 229 | + * Setup Wizard is NOT in this array — it's a real submenu page registered | |
| 230 | + * by Onboarding::register_menu() on `admin_menu` at priority 20, so it | |
| 231 | + * lands after these entries. | |
| 232 | + * | |
| 233 | + * @return array<string,string> map of route hash → menu label. | |
| 111 | 234 | */ |
| 112 | 235 | private static function deep_link_items() { |
| 236 | + // Keys are route hashes (`/<group-id>`); the submenu loop prepends '#'. | |
| 237 | + // React (nav.ts parseRoute) resolves `#/<group-id>` to that landing; | |
| 238 | + // legacy `#slug` links still redirect, so old bookmarks keep working — | |
| 239 | + // including bookmarks to the groups no longer listed here. | |
| 113 | 240 | return array( |
| 114 | - 'cache' => __( 'Cache', 'xspeed' ), | |
| 115 | - 'health' => __( 'Health', 'xspeed' ), | |
| 116 | - 'minify' => __( 'Performance', 'xspeed' ), | |
| 117 | - 'database' => __( 'Tools', 'xspeed' ), | |
| 241 | + '/overview' => __( 'Overview', 'xspeed' ), | |
| 242 | + '/ai-agents' => __( 'AI & agents', 'xspeed' ), | |
| 243 | + '/settings' => __( 'Settings', 'xspeed' ), | |
| 118 | 244 | ); |
| 119 | 245 | } |
| 120 | 246 | |
| 121 | 247 | /** |
| @@ -127,9 +253,9 @@ | ||
| 127 | 253 | * @since 1.5.0 |
| 128 | 254 | */ |
| 129 | 255 | public static function branding() { |
| 130 | 256 | $defaults = array( |
| 131 | - 'name' => 'xSpeed', | |
| 257 | + 'name' => 'xSpeed Cache', | |
| 132 | 258 | 'footer_credit' => null, // null = show the default WPDeveloper credit. |
| 133 | 259 | 'hide_help_links' => false, |
| 134 | 260 | 'logo_svg' => null, // null = use the built-in brand mark. |
| 135 | 261 | ); |
| @@ -140,8 +266,19 @@ | ||
| 140 | 266 | return array_merge( $defaults, $out ); |
| 141 | 267 | } |
| 142 | 268 | |
| 143 | 269 | /** |
| 270 | + * Label for the WordPress admin sidebar menu entry — the resolved brand | |
| 271 | + * name verbatim ("xSpeed Cache" by default, or the white-label name). | |
| 272 | + * | |
| 273 | + * @param array{name:string} $brand Resolved branding array. | |
| 274 | + * @return string | |
| 275 | + */ | |
| 276 | + private static function menu_label( array $brand ) { | |
| 277 | + return isset( $brand['name'] ) ? (string) $brand['name'] : 'xSpeed Cache'; | |
| 278 | + } | |
| 279 | + | |
| 280 | + /** | |
| 144 | 281 | * URL of the SVG menu icon — the official xSpeed brand mark. Uses |
| 145 | 282 | * fill="currentColor" which renders black in <img> context; the inline |
| 146 | 283 | * style below recolors it via CSS filter for the WP admin menu states. |
| 147 | 284 | */ |
| @@ -150,9 +287,23 @@ | ||
| 150 | 287 | } |
| 151 | 288 | |
| 152 | 289 | public function render() { |
| 153 | 290 | $dark = 'dark' === self::user_theme() ? ' dark' : ''; |
| 154 | - printf( '<div id="xspeed-app" class="xspeed-root%s"></div>', esc_attr( $dark ) ); | |
| 291 | + // Pre-mount skeleton: the React bundle executes a beat after the page | |
| 292 | + // paints, so without this the mount div is an empty dark void while it | |
| 293 | + // loads. We render the xSpeed brand mark (pulsing) straight into the | |
| 294 | + // mount node in PHP; createRoot().render() REPLACES these children the | |
| 295 | + // instant React boots, so the skeleton disappears with no JS wiring. | |
| 296 | + // The mark is the bundled icon.svg; `dark:invert` (scoped in | |
| 297 | + // styles.css) keeps the currentColor mark visible on the dark shell. | |
| 298 | + printf( | |
| 299 | + '<div id="xspeed-app" class="xspeed-root%1$s"><div class="xspeed-boot" role="status" aria-label="%2$s"><img class="xspeed-boot-mark" src="%3$s" alt="%4$s" width="48" height="48" /><span class="screen-reader-text">%5$s</span></div></div>', | |
| 300 | + esc_attr( $dark ), | |
| 301 | + esc_attr__( 'Loading', 'xspeed' ), | |
| 302 | + esc_url( XSPEED_URL . 'assets/icon.svg' ), | |
| 303 | + esc_attr__( 'xSpeed', 'xspeed' ), | |
| 304 | + esc_html__( 'Loading…', 'xspeed' ) | |
| 305 | + ); | |
| 155 | 306 | } |
| 156 | 307 | |
| 157 | 308 | /** |
| 158 | 309 | * Enqueue the stylesheet that recolors the menu icon to match the WP |
| @@ -165,8 +316,20 @@ | ||
| 165 | 316 | XSPEED_URL . 'assets/menu-icon.css', |
| 166 | 317 | array(), |
| 167 | 318 | XSPEED_VERSION |
| 168 | 319 | ); |
| 320 | + | |
| 321 | + // menu-icon.css recolors the built-in mark (fill=currentColor) to white | |
| 322 | + // via a brightness/invert filter. A white-label logo is a real image | |
| 323 | + // (often colored), so cancel the filter for it — otherwise the agency | |
| 324 | + // logo renders as a white silhouette. (FBS-82222) | |
| 325 | + $brand = self::branding(); | |
| 326 | + if ( ! empty( $brand['logo_svg'] ) ) { | |
| 327 | + wp_add_inline_style( | |
| 328 | + 'xspeed-menu-icon', | |
| 329 | + '#toplevel_page_' . self::PAGE_SLUG . ' .wp-menu-image img{filter:none;opacity:1}' | |
| 330 | + ); | |
| 331 | + } | |
| 169 | 332 | } |
| 170 | 333 | |
| 171 | 334 | public function enqueue( $hook ) { |
| 172 | 335 | if ( 'toplevel_page_' . self::PAGE_SLUG !== $hook ) { |
| @@ -175,8 +338,12 @@ | ||
| 175 | 338 | |
| 176 | 339 | $asset_js = XSPEED_DIR . 'assets/admin.js'; |
| 177 | 340 | $asset_css = XSPEED_DIR . 'assets/admin.css'; |
| 178 | 341 | |
| 342 | + // Needed for the media-library picker used by schema 'media' fields | |
| 343 | + // (e.g. the white-label brand logo). Loads window.wp.media. | |
| 344 | + wp_enqueue_media(); | |
| 345 | + | |
| 179 | 346 | if ( file_exists( $asset_js ) ) { |
| 180 | 347 | // filemtime() cache-busts on every rebuild so a stable |
| 181 | 348 | // VERSION constant never serves stale JS through browser |
| 182 | 349 | // caches. |
| @@ -197,13 +364,25 @@ | ||
| 197 | 364 | ); |
| 198 | 365 | } |
| 199 | 366 | } |
| 200 | 367 | |
| 368 | + // Redesign v2 design tokens + self-hosted fonts. Hand-written (not | |
| 369 | + // Vite-bundled) so the @font-face url('./fonts/…') resolve relative to | |
| 370 | + // assets/. admin.css depends on it so the CSS vars are defined first. | |
| 371 | + $theme_css = XSPEED_DIR . 'assets/theme.css'; | |
| 372 | + if ( file_exists( $theme_css ) ) { | |
| 373 | + wp_enqueue_style( | |
| 374 | + 'xspeed-theme', | |
| 375 | + XSPEED_URL . 'assets/theme.css', | |
| 376 | + array(), | |
| 377 | + XSPEED_VERSION . '.' . filemtime( $theme_css ) | |
| 378 | + ); | |
| 379 | + } | |
| 201 | 380 | if ( file_exists( $asset_css ) ) { |
| 202 | 381 | wp_enqueue_style( |
| 203 | 382 | 'xspeed-admin', |
| 204 | 383 | XSPEED_URL . 'assets/admin.css', |
| 205 | - array(), | |
| 384 | + array( 'xspeed-theme' ), | |
| 206 | 385 | XSPEED_VERSION . '.' . filemtime( $asset_css ) |
| 207 | 386 | ); |
| 208 | 387 | } |
| 209 | 388 | |
| @@ -217,10 +396,23 @@ | ||
| 217 | 396 | * @since 1.5.0 |
| 218 | 397 | */ |
| 219 | 398 | do_action( 'xspeed_admin_enqueue', $hook ); |
| 220 | 399 | |
| 221 | - wp_localize_script( | |
| 222 | - 'xspeed-admin', | |
| 400 | + // NOT wp_localize_script(). WP_Scripts::localize() casts every scalar | |
| 401 | + // to a string (wp-includes/class-wp-scripts.php: `(string) $value`), | |
| 402 | + // so an int arrives in JS as "21600" and a bool as "1" or "". | |
| 403 | + // | |
| 404 | + // That silently broke the Pro prewarm scheduler: it guards with | |
| 405 | + // `typeof gmtOffset === 'number'`, which a string fails, so the site's | |
| 406 | + // UTC offset was treated as 0 and every one-off warm was scheduled | |
| 407 | + // against UTC instead of site time — hours late on any non-UTC site, | |
| 408 | + // under a label that confidently read "Site time (UTC)". | |
| 409 | + // | |
| 410 | + // wp_add_inline_script() with wp_json_encode() preserves types, so | |
| 411 | + // numbers stay numbers and booleans stay booleans. Worth doing beyond | |
| 412 | + // the one field: every future numeric or boolean config value would | |
| 413 | + // hit the same trap. (#105) | |
| 414 | + self::print_config( | |
| 223 | 415 | 'XSpeedConfig', |
| 224 | 416 | array( |
| 225 | 417 | 'restUrl' => esc_url_raw( rest_url( Rest_Api::NAMESPACE_V1 ) ), |
| 226 | 418 | 'nonce' => wp_create_nonce( 'wp_rest' ), |
| @@ -225,12 +417,27 @@ | ||
| 225 | 417 | 'restUrl' => esc_url_raw( rest_url( Rest_Api::NAMESPACE_V1 ) ), |
| 226 | 418 | 'nonce' => wp_create_nonce( 'wp_rest' ), |
| 227 | 419 | 'version' => XSPEED_VERSION, |
| 228 | 420 | 'branding' => self::branding(), |
| 421 | + // Site's UTC offset in seconds — so datetime pickers (e.g. the | |
| 422 | + // Pro prewarm scheduler) align with WP's own post-scheduling, | |
| 423 | + // which is site-time, not the admin's browser-local time. | |
| 424 | + 'gmtOffset' => (int) round( (float) get_option( 'gmt_offset', 0 ) * HOUR_IN_SECONDS ), | |
| 229 | 425 | // 'pro' when xspeed-pro is active + speaks our API |
| 230 | 426 | // version (see Tier_Registry); 'free' otherwise. |
| 231 | 427 | // 'trial' reserved for future license-server work. |
| 232 | 428 | 'tier' => class_exists( '\\XSpeed\\Tier_Registry' ) && Tier_Registry::pro_active() ? 'pro' : 'free', |
| 429 | + // Three-state Pro status so gated UI can show the RIGHT message: | |
| 430 | + // 'not_installed' — Pro plugin not active → "Upgrade to Pro" | |
| 431 | + // 'unlicensed' — Pro active, no valid license → "Activate license" | |
| 432 | + // 'active' — Pro active + licensed → (modules unlocked) | |
| 433 | + // Free can't read Pro's license directly (Free never references | |
| 434 | + // Pro), so Pro filters this via `xspeed_pro_state`. Default: | |
| 435 | + // not_installed when Pro is absent; 'active' when Pro is present | |
| 436 | + // (Pro downgrades to 'unlicensed' when its license isn't valid). | |
| 437 | + 'proState' => self::pro_state(), | |
| 438 | + // Setup Wizard URL, surfaced in the sidebar profile popover. | |
| 439 | + 'wizardUrl' => admin_url( 'admin.php?page=' . Onboarding::PAGE_SLUG ), | |
| 233 | 440 | 'bootstrap' => self::bootstrap_payload(), |
| 234 | 441 | ) |
| 235 | 442 | ); |
| 236 | 443 | } |
| @@ -235,28 +442,97 @@ | ||
| 235 | 442 | ); |
| 236 | 443 | } |
| 237 | 444 | |
| 238 | 445 | /** |
| 446 | + * Emit a JS global for the admin bundle with types intact. | |
| 447 | + * | |
| 448 | + * The type-preserving replacement for wp_localize_script(), which | |
| 449 | + * stringifies every scalar. Attached to the `xspeed-admin` handle as a | |
| 450 | + * `before` script so it is defined by the time the bundle executes — | |
| 451 | + * exactly the ordering guarantee localize gave us. | |
| 452 | + * | |
| 453 | + * Shared by the dashboard and the onboarding wizard so neither can drift | |
| 454 | + * back to the stringifying path. | |
| 455 | + * | |
| 456 | + * @param string $var_name JS global to define. | |
| 457 | + * @param array $data Payload; encoded with wp_json_encode(). | |
| 458 | + */ | |
| 459 | + public static function print_config( string $var_name, array $data ): void { | |
| 460 | + $json = wp_json_encode( $data ); | |
| 461 | + if ( false === $json ) { | |
| 462 | + // Never emit a broken assignment — the bundle reads this global | |
| 463 | + // on mount and a syntax error here blanks the whole screen. | |
| 464 | + $json = '{}'; | |
| 465 | + } | |
| 466 | + wp_add_inline_script( | |
| 467 | + 'xspeed-admin', | |
| 468 | + 'var ' . $var_name . ' = ' . $json . ';', | |
| 469 | + 'before' | |
| 470 | + ); | |
| 471 | + } | |
| 472 | + | |
| 473 | + /** | |
| 239 | 474 | * Pre-rendered settings + status payload, baked into the page so the |
| 240 | 475 | * React app can mount with real values instead of showing a loading state |
| 241 | 476 | * while it waits for /settings and /status REST calls. |
| 242 | 477 | */ |
| 478 | + /** | |
| 479 | + * Three-state Pro status for the gated UI ('not_installed' | 'unlicensed' | |
| 480 | + * | 'active'). Free cannot read Pro's license (it never references Pro), | |
| 481 | + * so the authoritative value comes from the `xspeed_pro_state` filter that | |
| 482 | + * xspeed-pro hooks. The default here only distinguishes installed vs not — | |
| 483 | + * when Pro is active, Pro itself downgrades the value to 'unlicensed' if | |
| 484 | + * its license isn't valid. | |
| 485 | + * | |
| 486 | + * @return string | |
| 487 | + */ | |
| 488 | + private static function pro_state(): string { | |
| 489 | + $pro_present = class_exists( '\\XSpeed\\Tier_Registry' ) && Tier_Registry::pro_active(); | |
| 490 | + $default = $pro_present ? 'active' : 'not_installed'; | |
| 491 | + | |
| 492 | + /** | |
| 493 | + * Filter: xspeed_pro_state | |
| 494 | + * | |
| 495 | + * Lets the Pro plugin report its real license state so Free's gated | |
| 496 | + * panels can show "Activate license" (Pro installed, unlicensed) vs | |
| 497 | + * "Upgrade to Pro" (Pro not installed). | |
| 498 | + * | |
| 499 | + * @param string $state One of 'not_installed' | 'unlicensed' | 'active'. | |
| 500 | + */ | |
| 501 | + $state = (string) apply_filters( 'xspeed_pro_state', $default ); | |
| 502 | + | |
| 503 | + return in_array( $state, array( 'not_installed', 'unlicensed', 'active' ), true ) ? $state : $default; | |
| 504 | + } | |
| 505 | + | |
| 243 | 506 | private static function bootstrap_payload() { |
| 244 | 507 | $opts = Settings::get(); |
| 245 | 508 | $stats = Cache::get_stats(); |
| 246 | 509 | // Static-rewrite probe state shipped to the React side so the |
| 247 | 510 | // dashboard can show a persistent banner when nginx/Apache |
| 248 | - // hasn't been wired to bypass PHP yet. Cheap — Cache::probe… | |
| 249 | - // is transient-throttled to one HTTP round-trip per 5 min. | |
| 511 | + // hasn't been wired to bypass PHP yet. Cache-ONLY here (no $allow_probe | |
| 512 | + // arg) so the dashboard bootstrap never makes the loopback HTTP probe | |
| 513 | + // — that could add seconds to every admin page load on hosts that | |
| 514 | + // stall self-requests. The Health tab runs the live probe on demand; | |
| 515 | + // here we just surface whatever it last cached. (FBS-82142) | |
| 250 | 516 | $server_type = Server::type(); |
| 251 | - $rewrite_capable = ( $server_type === Server::NGINX || $server_type === Server::APACHE || $server_type === Server::LITESPEED ); | |
| 517 | + // LiteSpeed serves hits via the PHP drop-in by design (its .htaccess | |
| 518 | + // can't add the HIT header or log a static hit), so the static-rewrite | |
| 519 | + // probe is N/A there — surfacing it would pop the "PHP fallback" nag | |
| 520 | + // for a setup working as designed. Only nginx + Apache probe. | |
| 521 | + $rewrite_capable = ( $server_type === Server::NGINX || $server_type === Server::APACHE ); | |
| 252 | 522 | $rewrite_probe = null; |
| 253 | 523 | if ( $opts['cache_enabled'] && $rewrite_capable ) { |
| 254 | 524 | $probe = Cache::probe_static_rewrite(); |
| 255 | 525 | $rewrite_probe = array( |
| 256 | - 'active' => (bool) ( $probe['active'] ?? false ), | |
| 257 | - 'server_type' => $server_type, | |
| 258 | - 'snippet' => Cache::nginx_snippet(), // null on non-nginx hosts | |
| 526 | + 'active' => (bool) ( $probe['active'] ?? false ), | |
| 527 | + 'server_type' => $server_type, | |
| 528 | + 'snippet' => Cache::nginx_snippet(), // null on non-nginx hosts | |
| 529 | + 'topology' => Server::rewrite_topology(), | |
| 530 | + // A reverse proxy / CDN in front (X-Forwarded-* present) usually | |
| 531 | + // means the request-terminating nginx isn't user-editable on this | |
| 532 | + // host — the banner uses this to switch to honest messaging | |
| 533 | + // instead of dangling a snippet the user can't apply. | |
| 534 | + 'behind_proxy' => Server::is_behind_proxy(), | |
| 259 | 535 | ); |
| 260 | 536 | } |
| 261 | 537 | |
| 262 | 538 | return array( |
| @@ -270,17 +546,55 @@ | ||
| 270 | 546 | 'gzip_active' => Gzip::probe_active(), |
| 271 | 547 | 'nginx_snippet' => Gzip::nginx_snippet(), |
| 272 | 548 | ), |
| 273 | 549 | 'rewrite_probe' => $rewrite_probe, |
| 550 | + // Separate Mobile Cache visibility (FBS-83145) — mirrors the | |
| 551 | + // /status block so the dashboard callout renders on first paint | |
| 552 | + // without waiting for a status re-fetch. | |
| 553 | + 'mobile_separate' => array( | |
| 554 | + 'enabled' => (bool) ( $opts['cache_enabled'] ? ( Settings_Manager::get( 'cache' )['mobile_separate'] ?? false ) : false ), | |
| 555 | + 'blocking' => $rewrite_capable && 'mobile_separate' === Cache::static_rewrite_block_reason(), | |
| 556 | + 'needs_review' => Cache::mobile_separate_needs_review(), | |
| 557 | + ), | |
| 558 | + // One consolidated nginx server-block snippet aggregating | |
| 559 | + // every enabled module's directives (Cache static-rewrite, | |
| 560 | + // BrowserCache headers, GZIP, …). Null on non-nginx hosts | |
| 561 | + // or when no module contributes directives. Replaces the | |
| 562 | + // per-module "paste this snippet" notices. | |
| 563 | + 'nginx_server_block' => Cache::full_nginx_server_block(), | |
| 564 | + // Mirrors the /status block so the enable-time disclosure is | |
| 565 | + // correct on FIRST PAINT. Without it the top-bar switch can be | |
| 566 | + // clicked before a status fetch lands, and the one moment the | |
| 567 | + // warning exists for — a leftover drop-in about to be | |
| 568 | + // replaced — is exactly when it would be missing. | |
| 569 | + 'dropin' => Page_Cache_Detector::dropin_disclosure(), | |
| 274 | 570 | ), |
| 275 | 571 | // Registered Modules (Free + Pro). The React app discovers them |
| 276 | 572 | // here and renders one sidebar item + one panel per module that |
| 277 | 573 | // declares a settings schema. Hidden modules are filtered. |
| 278 | 574 | 'modules' => self::modules_payload(), |
| 575 | + // xSpeed Hub connection snapshot so the Account panel + header chip | |
| 576 | + // render the correct connected/not-connected state on FIRST PAINT — | |
| 577 | + // no fetch, no "not connected → connected" flash. Null when the MCP | |
| 578 | + // module is unavailable (the panel then falls back to /mcp/hub). | |
| 579 | + 'hub' => self::hub_payload(), | |
| 279 | 580 | ); |
| 280 | 581 | } |
| 281 | 582 | |
| 282 | 583 | /** |
| 584 | + * xSpeed Hub connection snapshot for the dashboard bootstrap. Guarded so the | |
| 585 | + * dashboard never hard-depends on the MCP module. Same shape as GET /mcp/hub. | |
| 586 | + * | |
| 587 | + * @return array<string,mixed>|null | |
| 588 | + */ | |
| 589 | + private static function hub_payload() { | |
| 590 | + if ( ! class_exists( '\XSpeed\Modules\Mcp\Mcp_Hub' ) ) { | |
| 591 | + return null; | |
| 592 | + } | |
| 593 | + return \XSpeed\Modules\Mcp\Mcp_Hub::public_status(); | |
| 594 | + } | |
| 595 | + | |
| 596 | + /** | |
| 283 | 597 | * Serialize every available Module for the React dashboard. Each entry |
| 284 | 598 | * carries enough to render: identity (slug + tier), UI metadata (label, |
| 285 | 599 | * icon, optional description), current settings, and the typed schema |
| 286 | 600 | * the panel uses to render controls. |
| @@ -287,9 +601,9 @@ | ||
| 287 | 601 | * |
| 288 | 602 | * Modules that declare `hidden => true` in ui_metadata (e.g., engine |
| 289 | 603 | * modules with no user-facing settings) are skipped. |
| 290 | 604 | */ |
| 291 | - private static function modules_payload() { | |
| 605 | + public static function modules_payload() { | |
| 292 | 606 | if ( ! class_exists( 'XSpeed\\Module_Registry' ) ) { |
| 293 | 607 | return array(); |
| 294 | 608 | } |
| 295 | 609 | $out = array(); |
| @@ -304,16 +618,60 @@ | ||
| 304 | 618 | // panel — i.e., truly nothing to render in the dashboard. |
| 305 | 619 | if ( empty( $schema ) && empty( $custom_panel ) ) { |
| 306 | 620 | continue; |
| 307 | 621 | } |
| 622 | + $settings = Settings_Manager::get_public( $slug ); | |
| 623 | + | |
| 308 | 624 | $entry = array( |
| 309 | 625 | 'slug' => $slug, |
| 310 | 626 | 'tier' => $module->tier(), |
| 311 | 627 | 'version' => $module->version(), |
| 628 | + // Promoted from inside `settings` so the payload is | |
| 629 | + // self-describing. Consumers kept tripping on this — the Hub | |
| 630 | + // rendered every module "Inactive" until it learned to look | |
| 631 | + // inside the settings bag. `settings.enabled` is kept below | |
| 632 | + // for back-compat; this is the same value, not a second | |
| 633 | + // source of truth. Modules with no `enabled` key (status | |
| 634 | + // panels like Health) report null rather than a misleading | |
| 635 | + // false. (#146) | |
| 636 | + 'enabled' => array_key_exists( 'enabled', $settings ) | |
| 637 | + ? (bool) $settings['enabled'] | |
| 638 | + : null, | |
| 639 | + // Whether the module is actually DOING something, which is not | |
| 640 | + // the same question as `enabled` above. "On" has several shapes | |
| 641 | + // -- page caching lives in the global option, Minify and Lazy | |
| 642 | + // are on when any flag is set, MCP when it is connected -- so | |
| 643 | + // each module answers for itself via is_active(). Consumers | |
| 644 | + // that want "what is switched on?" (the sidebar's "N on" badge) | |
| 645 | + // must read THIS, not `enabled`, which only ever described the | |
| 646 | + // modules that happen to store that one key. null means the | |
| 647 | + // module has no meaningful on/off and should be excluded from | |
| 648 | + // any count rather than treated as off. (#363) | |
| 649 | + 'active' => $module->is_active(), | |
| 650 | + // One sentence explaining the line above, computed next to it | |
| 651 | + // so the two cannot disagree. The UI shows it behind an (i) | |
| 652 | + // beside the status pill: "On" is a bare assertion otherwise, | |
| 653 | + // and least obvious exactly where it matters -- Media | |
| 654 | + // Optimization reads On while its two most prominent switches | |
| 655 | + // are off, because three other flags are on. (#363) | |
| 656 | + 'active_reason' => $module->active_reason(), | |
| 312 | 657 | 'label' => $meta['label'] ?? ucfirst( $slug ), |
| 313 | 658 | 'icon' => $meta['icon'] ?? 'Square', |
| 314 | 659 | 'description' => $meta['description'] ?? '', |
| 315 | - 'settings' => Settings_Manager::get( $slug ), | |
| 660 | + // Short label for the module's own tab when it hosts a tabbed | |
| 661 | + // page (FBS-83633). Only set on host modules. | |
| 662 | + 'tab_label' => $meta['tab_label'] ?? null, | |
| 663 | + // Public view: real values except secret fields, which are masked. | |
| 664 | + // The dashboard bundle localizes this into page HTML, so a raw | |
| 665 | + // credential here would be readable from view-source. (#115) | |
| 666 | + 'settings' => $settings, | |
| 667 | + // Where each value actually came from: a wp-config.php constant, | |
| 668 | + // the option row, or the schema default. The panel renders a | |
| 669 | + // constant-sourced field read-only and names the constant, so it | |
| 670 | + // can never present an editable box over a value the site is not | |
| 671 | + // using. Every module gets this, not just the ones that declare | |
| 672 | + // constants today. (#398) | |
| 673 | + 'setting_origins' => Settings_Manager::origins( $slug ), | |
| 316 | 674 | 'schema' => $schema, |
| 317 | 675 | 'notices' => $module->ui_notices(), |
| 318 | 676 | 'custom_panel' => $meta['custom_panel'] ?? null, |
| 319 | 677 | ); |