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

451 lines 16.9 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 // Strip third-party admin notices on our screens only. Fires in
24 // `in_admin_header` (after `get_current_screen()` is populated
25 // but before notices render) so the screen check is reliable.
26 add_action( 'in_admin_header', array( __CLASS__, 'suppress_foreign_notices' ), 0 );
27 }
28
29 /**
30 * Remove every third-party admin notice on xSpeed admin screens so the
31 * dashboard stays visually clean. Scoped via `is_plugin_page()` — runs
32 * nowhere else. Our own notices stay rendered: register them on the
33 * dedicated `xspeed_admin_notices` action below, which fires after
34 * this suppression and is wired to all three core notice hooks.
35 *
36 * Hooks cleared: `admin_notices`, `all_admin_notices`,
37 * `user_admin_notices`, `network_admin_notices`. WordPress's own
38 * settings-saved / updated messages are emitted via `settings_errors()`
39 * and printed inline by `options.php` — they are NOT on these hooks
40 * and are unaffected.
41 *
42 * @return void
43 */
44 public static function suppress_foreign_notices() {
45 if ( ! self::is_plugin_page() ) {
46 return;
47 }
48 remove_all_actions( 'admin_notices' );
49 remove_all_actions( 'all_admin_notices' );
50 remove_all_actions( 'user_admin_notices' );
51 remove_all_actions( 'network_admin_notices' );
52
53 // Re-route the four standard notice hooks to a single namespaced
54 // action so xSpeed (and any deliberate extender that opts in)
55 // keeps a place to emit notices after the strip.
56 $relay = static function () {
57 /**
58 * Fires in place of WP's `admin_notices` family on xSpeed
59 * admin screens. Use this instead of `admin_notices` when
60 * you want a notice to survive xSpeed's third-party
61 * suppression.
62 *
63 * @since 1.0.3
64 */
65 do_action( 'xspeed_admin_notices' );
66 };
67 add_action( 'admin_notices', $relay );
68 add_action( 'all_admin_notices', $relay );
69 add_action( 'user_admin_notices', $relay );
70 add_action( 'network_admin_notices', $relay );
71 }
72
73 /**
74 * Server-side theme detection from the cookie written by useTheme. Used
75 * to emit the `.dark` class on the React mount node and the
76 * `xspeed-dark` class on `<body>` during the initial render — kills the
77 * light→dark flash that happens when JS adds those classes after the
78 * page has already painted.
79 *
80 * @return string 'dark' | 'light'
81 */
82 public static function user_theme() {
83 // 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.
84 $raw = isset( $_COOKIE[ self::THEME_COOKIE ] ) ? wp_unslash( $_COOKIE[ self::THEME_COOKIE ] ) : '';
85 return 'dark' === sanitize_key( $raw ) ? 'dark' : 'light';
86 }
87
88 public static function is_plugin_page() {
89 // Prefer the ?page= slug: it's brand-independent. WordPress derives
90 // the submenu screen base from the *sanitized parent menu title*, so
91 // once White-Label renames the menu the base becomes e.g.
92 // "acmespeed_page_xspeed-onboarding" and any check anchored on
93 // self::PAGE_SLUG ("xspeed_page_…") silently stops matching — which
94 // dropped the xspeed-page / xspeed-dark body classes on the wizard and
95 // left it unstyled. Match the slug instead. (FBS-82222)
96 $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only screen gate.
97 if ( self::PAGE_SLUG === $page || Onboarding::PAGE_SLUG === $page ) {
98 return true;
99 }
100
101 // Fallback for contexts where $_GET['page'] isn't set but the screen is
102 // available. The toplevel base is slug-based (stable); the submenu base
103 // is title-derived, so match on its slug SUFFIX rather than the prefix.
104 $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
105 if ( ! $screen ) {
106 return false;
107 }
108 $base = (string) $screen->base;
109 return 0 === strpos( $base, 'toplevel_page_' . self::PAGE_SLUG )
110 || (bool) preg_match( '/_page_' . preg_quote( self::PAGE_SLUG, '/' ) . '($|-)/', $base );
111 }
112
113 public static function admin_body_class( $classes ) {
114 if ( ! self::is_plugin_page() ) {
115 return $classes;
116 }
117 $classes .= ' xspeed-page';
118 if ( 'dark' === self::user_theme() ) {
119 $classes .= ' xspeed-dark';
120 }
121 return $classes;
122 }
123
124 public function register_menu() {
125 $brand = self::branding();
126 // Use the white-label logo for the admin menu icon when set, so the
127 // rebrand carries to the menu mark too — not just the title. Falls
128 // back to the built-in xSpeed SVG. (FBS-82222)
129 $menu_icon = ! empty( $brand['logo_svg'] ) ? $brand['logo_svg'] : self::menu_icon();
130 add_menu_page(
131 $brand['name'],
132 $brand['name'],
133 'manage_options',
134 self::PAGE_SLUG,
135 array( $this, 'render' ),
136 $menu_icon,
137 80
138 );
139
140 // Drop WP's auto-generated duplicate first submenu (which
141 // inherits the toplevel "xSpeed" title). The group deep-links
142 // below replace it — keeping it would render a redundant
143 // "xSpeed" / "Dashboard" row that just re-links to the same
144 // page as the toplevel entry.
145 global $submenu;
146 // $submenu may not yet be populated for this slug; the
147 // `remove_submenu_page` call covers either case.
148 remove_submenu_page( self::PAGE_SLUG, self::PAGE_SLUG );
149
150 // Deep-link submenus — each points to the same dashboard page
151 // with a section hash so React's App.tsx routing lands the
152 // user inside the right module. WordPress's add_submenu_page
153 // strips the hash, so we inject directly into $submenu where
154 // it survives intact (the same trick Yoast / WooCommerce use
155 // for their per-area shortcuts).
156 global $submenu;
157 $deep_links = self::deep_link_items();
158 foreach ( $deep_links as $hash => $label ) {
159 $submenu[ self::PAGE_SLUG ][] = array(
160 $label,
161 'manage_options',
162 'admin.php?page=' . self::PAGE_SLUG . '#' . $hash,
163 );
164 }
165 }
166
167 /**
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.
174 *
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.
179 *
180 * @return array<string,string> map of first-module-hash → group label.
181 */
182 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 Free, always-visible module slugs so the submenu
188 // works without Pro: 'ai-privacy' (AI group) and 'multisite' (the
189 // Pro Add-ons group placeholder; resolves to the real module on a
190 // licensed multisite). (FBS-82096)
191 return array(
192 'cache' => __( 'Cache', 'xspeed' ),
193 'minify' => __( 'Performance', 'xspeed' ),
194 'cdn' => __( 'Network', 'xspeed' ),
195 'health' => __( 'Insights', 'xspeed' ),
196 'database' => __( 'Tools', 'xspeed' ),
197 'ai-privacy' => __( 'AI', 'xspeed' ),
198 'multisite' => __( 'Pro Add-ons', 'xspeed' ),
199 );
200 }
201
202 /**
203 * Pluggable branding for the dashboard chrome. xspeed-pro's
204 * White-Label module hooks `xspeed_branding` to override these
205 * values from saved settings.
206 *
207 * @return array{name:string,footer_credit:?string,hide_help_links:bool,logo_svg:?string}
208 * @since 1.5.0
209 */
210 public static function branding() {
211 $defaults = array(
212 'name' => 'xSpeed',
213 'footer_credit' => null, // null = show the default WPDeveloper credit.
214 'hide_help_links' => false,
215 'logo_svg' => null, // null = use the built-in brand mark.
216 );
217 $out = apply_filters( 'xspeed_branding', $defaults );
218 if ( ! is_array( $out ) ) {
219 return $defaults;
220 }
221 return array_merge( $defaults, $out );
222 }
223
224 /**
225 * URL of the SVG menu icon — the official xSpeed brand mark. Uses
226 * fill="currentColor" which renders black in <img> context; the inline
227 * style below recolors it via CSS filter for the WP admin menu states.
228 */
229 private static function menu_icon() {
230 return XSPEED_URL . 'assets/icon.svg';
231 }
232
233 public function render() {
234 $dark = 'dark' === self::user_theme() ? ' dark' : '';
235 printf( '<div id="xspeed-app" class="xspeed-root%s"></div>', esc_attr( $dark ) );
236 }
237
238 /**
239 * Enqueue the stylesheet that recolors the menu icon to match the WP
240 * admin color scheme. Loads on every admin page (not just the plugin's
241 * page) because the menu icon is visible site-wide.
242 */
243 public function enqueue_menu_styles() {
244 wp_enqueue_style(
245 'xspeed-menu-icon',
246 XSPEED_URL . 'assets/menu-icon.css',
247 array(),
248 XSPEED_VERSION
249 );
250
251 // menu-icon.css recolors the built-in mark (fill=currentColor) to white
252 // via a brightness/invert filter. A white-label logo is a real image
253 // (often colored), so cancel the filter for it — otherwise the agency
254 // logo renders as a white silhouette. (FBS-82222)
255 $brand = self::branding();
256 if ( ! empty( $brand['logo_svg'] ) ) {
257 wp_add_inline_style(
258 'xspeed-menu-icon',
259 '#toplevel_page_' . self::PAGE_SLUG . ' .wp-menu-image img{filter:none;opacity:1}'
260 );
261 }
262 }
263
264 public function enqueue( $hook ) {
265 if ( 'toplevel_page_' . self::PAGE_SLUG !== $hook ) {
266 return;
267 }
268
269 $asset_js = XSPEED_DIR . 'assets/admin.js';
270 $asset_css = XSPEED_DIR . 'assets/admin.css';
271
272 // Needed for the media-library picker used by schema 'media' fields
273 // (e.g. the white-label brand logo). Loads window.wp.media.
274 wp_enqueue_media();
275
276 if ( file_exists( $asset_js ) ) {
277 // filemtime() cache-busts on every rebuild so a stable
278 // VERSION constant never serves stale JS through browser
279 // caches.
280 wp_enqueue_script(
281 'xspeed-admin',
282 XSPEED_URL . 'assets/admin.js',
283 array( 'wp-api-fetch', 'wp-i18n' ),
284 XSPEED_VERSION . '.' . filemtime( $asset_js ),
285 true
286 );
287 // Loads .mo files for the 'xspeed' text-domain into
288 // window.wp.i18n so the React `__()` helper resolves.
289 if ( function_exists( 'wp_set_script_translations' ) ) {
290 wp_set_script_translations(
291 'xspeed-admin',
292 'xspeed',
293 XSPEED_DIR . 'languages'
294 );
295 }
296 }
297
298 if ( file_exists( $asset_css ) ) {
299 wp_enqueue_style(
300 'xspeed-admin',
301 XSPEED_URL . 'assets/admin.css',
302 array(),
303 XSPEED_VERSION . '.' . filemtime( $asset_css )
304 );
305 }
306
307 /**
308 * Fires after the Free dashboard bundle is enqueued, before its
309 * config is localized. Pro hooks this to enqueue its own bundle
310 * with `xspeed-admin` as a dependency, so its panel
311 * registrations run after `window.XSpeedPro` is installed by
312 * Free's main.tsx.
313 *
314 * @since 1.5.0
315 */
316 do_action( 'xspeed_admin_enqueue', $hook );
317
318 wp_localize_script(
319 'xspeed-admin',
320 'XSpeedConfig',
321 array(
322 'restUrl' => esc_url_raw( rest_url( Rest_Api::NAMESPACE_V1 ) ),
323 'nonce' => wp_create_nonce( 'wp_rest' ),
324 'version' => XSPEED_VERSION,
325 'branding' => self::branding(),
326 // 'pro' when xspeed-pro is active + speaks our API
327 // version (see Tier_Registry); 'free' otherwise.
328 // 'trial' reserved for future license-server work.
329 'tier' => class_exists( '\\XSpeed\\Tier_Registry' ) && Tier_Registry::pro_active() ? 'pro' : 'free',
330 'bootstrap' => self::bootstrap_payload(),
331 )
332 );
333 }
334
335 /**
336 * Pre-rendered settings + status payload, baked into the page so the
337 * React app can mount with real values instead of showing a loading state
338 * while it waits for /settings and /status REST calls.
339 */
340 private static function bootstrap_payload() {
341 $opts = Settings::get();
342 $stats = Cache::get_stats();
343 // Static-rewrite probe state shipped to the React side so the
344 // dashboard can show a persistent banner when nginx/Apache
345 // hasn't been wired to bypass PHP yet. Cache-ONLY here (no $allow_probe
346 // arg) so the dashboard bootstrap never makes the loopback HTTP probe
347 // — that could add seconds to every admin page load on hosts that
348 // stall self-requests. The Health tab runs the live probe on demand;
349 // here we just surface whatever it last cached. (FBS-82142)
350 $server_type = Server::type();
351 // LiteSpeed serves hits via the PHP drop-in by design (its .htaccess
352 // can't add the HIT header or log a static hit), so the static-rewrite
353 // probe is N/A there — surfacing it would pop the "PHP fallback" nag
354 // for a setup working as designed. Only nginx + Apache probe.
355 $rewrite_capable = ( $server_type === Server::NGINX || $server_type === Server::APACHE );
356 $rewrite_probe = null;
357 if ( $opts['cache_enabled'] && $rewrite_capable ) {
358 $probe = Cache::probe_static_rewrite();
359 $rewrite_probe = array(
360 'active' => (bool) ( $probe['active'] ?? false ),
361 'server_type' => $server_type,
362 'snippet' => Cache::nginx_snippet(), // null on non-nginx hosts
363 'topology' => Server::rewrite_topology(),
364 // A reverse proxy / CDN in front (X-Forwarded-* present) usually
365 // means the request-terminating nginx isn't user-editable on this
366 // host — the banner uses this to switch to honest messaging
367 // instead of dangling a snippet the user can't apply.
368 'behind_proxy' => Server::is_behind_proxy(),
369 );
370 }
371
372 return array(
373 'settings' => $opts,
374 'status' => array(
375 'enabled' => (bool) $opts['cache_enabled'],
376 'stats' => $stats,
377 'server' => array(
378 'type' => $server_type,
379 'gzip_mode' => Server::gzip_mode(),
380 'gzip_active' => Gzip::probe_active(),
381 'nginx_snippet' => Gzip::nginx_snippet(),
382 ),
383 'rewrite_probe' => $rewrite_probe,
384 // One consolidated nginx server-block snippet aggregating
385 // every enabled module's directives (Cache static-rewrite,
386 // BrowserCache headers, GZIP, …). Null on non-nginx hosts
387 // or when no module contributes directives. Replaces the
388 // per-module "paste this snippet" notices.
389 'nginx_server_block' => Cache::full_nginx_server_block(),
390 ),
391 // Registered Modules (Free + Pro). The React app discovers them
392 // here and renders one sidebar item + one panel per module that
393 // declares a settings schema. Hidden modules are filtered.
394 'modules' => self::modules_payload(),
395 );
396 }
397
398 /**
399 * Serialize every available Module for the React dashboard. Each entry
400 * carries enough to render: identity (slug + tier), UI metadata (label,
401 * icon, optional description), current settings, and the typed schema
402 * the panel uses to render controls.
403 *
404 * Modules that declare `hidden => true` in ui_metadata (e.g., engine
405 * modules with no user-facing settings) are skipped.
406 */
407 public static function modules_payload() {
408 if ( ! class_exists( 'XSpeed\\Module_Registry' ) ) {
409 return array();
410 }
411 $out = array();
412 foreach ( Module_Registry::available() as $slug => $module ) {
413 $meta = $module->ui_metadata();
414 if ( ! empty( $meta['hidden'] ) ) {
415 continue;
416 }
417 $schema = $module->settings_schema();
418 $custom_panel = $meta['custom_panel'] ?? null;
419 // Skip only when the module has neither a schema nor a custom
420 // panel — i.e., truly nothing to render in the dashboard.
421 if ( empty( $schema ) && empty( $custom_panel ) ) {
422 continue;
423 }
424 $entry = array(
425 'slug' => $slug,
426 'tier' => $module->tier(),
427 'version' => $module->version(),
428 'label' => $meta['label'] ?? ucfirst( $slug ),
429 'icon' => $meta['icon'] ?? 'Square',
430 'description' => $meta['description'] ?? '',
431 'settings' => Settings_Manager::get( $slug ),
432 'schema' => $schema,
433 'notices' => $module->ui_notices(),
434 'custom_panel' => $meta['custom_panel'] ?? null,
435 );
436
437 /**
438 * Last-mile descriptor filter. Lets Pro (or third-party
439 * extensions) override any field before the module is
440 * shipped to React. Primary use: xspeed-pro hooks this to
441 * swap `custom_panel` to LicenseLockedPanel for Pro modules
442 * when the license is invalid, so unlocked modules stay
443 * visible in the sidebar (good upsell UX) but the panel
444 * shows an activation prompt instead of the real surface.
445 */
446 $out[] = apply_filters( 'xspeed_module_descriptor', $entry, $module );
447 }
448 return $out;
449 }
450 }
451