PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.0.4
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.0.4
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 1.2.0 All 28 releases
xspeed / includes / class-admin.php

class-admin.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.0.4, at includes/class-admin.php

411 lines 14.7 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 $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
90 if ( ! $screen ) {
91 return false;
92 }
93 // Toplevel page and any submenu (e.g., the onboarding wizard) share
94 // the `xspeed` prefix in their screen base.
95 return 0 === strpos( (string) $screen->base, 'toplevel_page_' . self::PAGE_SLUG )
96 || 0 === strpos( (string) $screen->base, self::PAGE_SLUG . '_page_' )
97 || 0 === strpos( (string) $screen->base, 'admin_page_' . Onboarding::PAGE_SLUG );
98 }
99
100 public static function admin_body_class( $classes ) {
101 if ( ! self::is_plugin_page() ) {
102 return $classes;
103 }
104 $classes .= ' xspeed-page';
105 if ( 'dark' === self::user_theme() ) {
106 $classes .= ' xspeed-dark';
107 }
108 return $classes;
109 }
110
111 public function register_menu() {
112 $brand = self::branding();
113 add_menu_page(
114 $brand['name'],
115 $brand['name'],
116 'manage_options',
117 self::PAGE_SLUG,
118 array( $this, 'render' ),
119 self::menu_icon(),
120 80
121 );
122
123 // Drop WP's auto-generated duplicate first submenu (which
124 // inherits the toplevel "xSpeed" title). The group deep-links
125 // below replace it — keeping it would render a redundant
126 // "xSpeed" / "Dashboard" row that just re-links to the same
127 // page as the toplevel entry.
128 global $submenu;
129 // $submenu may not yet be populated for this slug; the
130 // `remove_submenu_page` call covers either case.
131 remove_submenu_page( self::PAGE_SLUG, self::PAGE_SLUG );
132
133 // Deep-link submenus — each points to the same dashboard page
134 // with a section hash so React's App.tsx routing lands the
135 // user inside the right module. WordPress's add_submenu_page
136 // strips the hash, so we inject directly into $submenu where
137 // it survives intact (the same trick Yoast / WooCommerce use
138 // for their per-area shortcuts).
139 global $submenu;
140 $deep_links = self::deep_link_items();
141 foreach ( $deep_links as $hash => $label ) {
142 $submenu[ self::PAGE_SLUG ][] = array(
143 $label,
144 'manage_options',
145 'admin.php?page=' . self::PAGE_SLUG . '#' . $hash,
146 );
147 }
148 }
149
150 /**
151 * Section deep-links rendered under the xSpeed menu. Mirrors the
152 * React sidebar's group manifest (see src/components/sidebarGroups.ts)
153 * so the WP admin rail reads as the same map as the in-app sidebar.
154 * Each entry points at the first module slug in that group; the
155 * React app's hash router lands the user on that module, which is
156 * the first row of that group's sidebar section — no manual scrolling.
157 *
158 * If you add a group to React's SIDEBAR_GROUPS, add it here too. The
159 * two arrays are intentionally co-located in PR review (same change
160 * touches both) rather than DRY'd through a generated config file —
161 * this is the only PHP↔TS coupling and it's tiny.
162 *
163 * @return array<string,string> map of first-module-hash → group label.
164 */
165 private static function deep_link_items() {
166 // Mirrors the dashboard sidebar groups (SIDEBAR_GROUPS in
167 // sidebarGroups.ts) 1:1, in the same order. Each key is the first
168 // module slug of that group (the anchor the submenu deep-links to).
169 // Keep these two lists in sync — they're the only PHP↔TS coupling.
170 // Anchors must be Free, always-visible module slugs so the submenu
171 // works without Pro: 'ai-privacy' (AI group) and 'multisite' (the
172 // Pro Add-ons group placeholder; resolves to the real module on a
173 // licensed multisite). (FBS-82096)
174 return array(
175 'cache' => __( 'Cache', 'xspeed' ),
176 'minify' => __( 'Performance', 'xspeed' ),
177 'cdn' => __( 'Network', 'xspeed' ),
178 'health' => __( 'Insights', 'xspeed' ),
179 'database' => __( 'Tools', 'xspeed' ),
180 'ai-privacy' => __( 'AI', 'xspeed' ),
181 'multisite' => __( 'Pro Add-ons', 'xspeed' ),
182 );
183 }
184
185 /**
186 * Pluggable branding for the dashboard chrome. xspeed-pro's
187 * White-Label module hooks `xspeed_branding` to override these
188 * values from saved settings.
189 *
190 * @return array{name:string,footer_credit:?string,hide_help_links:bool,logo_svg:?string}
191 * @since 1.5.0
192 */
193 public static function branding() {
194 $defaults = array(
195 'name' => 'xSpeed',
196 'footer_credit' => null, // null = show the default WPDeveloper credit.
197 'hide_help_links' => false,
198 'logo_svg' => null, // null = use the built-in brand mark.
199 );
200 $out = apply_filters( 'xspeed_branding', $defaults );
201 if ( ! is_array( $out ) ) {
202 return $defaults;
203 }
204 return array_merge( $defaults, $out );
205 }
206
207 /**
208 * URL of the SVG menu icon — the official xSpeed brand mark. Uses
209 * fill="currentColor" which renders black in <img> context; the inline
210 * style below recolors it via CSS filter for the WP admin menu states.
211 */
212 private static function menu_icon() {
213 return XSPEED_URL . 'assets/icon.svg';
214 }
215
216 public function render() {
217 $dark = 'dark' === self::user_theme() ? ' dark' : '';
218 printf( '<div id="xspeed-app" class="xspeed-root%s"></div>', esc_attr( $dark ) );
219 }
220
221 /**
222 * Enqueue the stylesheet that recolors the menu icon to match the WP
223 * admin color scheme. Loads on every admin page (not just the plugin's
224 * page) because the menu icon is visible site-wide.
225 */
226 public function enqueue_menu_styles() {
227 wp_enqueue_style(
228 'xspeed-menu-icon',
229 XSPEED_URL . 'assets/menu-icon.css',
230 array(),
231 XSPEED_VERSION
232 );
233 }
234
235 public function enqueue( $hook ) {
236 if ( 'toplevel_page_' . self::PAGE_SLUG !== $hook ) {
237 return;
238 }
239
240 $asset_js = XSPEED_DIR . 'assets/admin.js';
241 $asset_css = XSPEED_DIR . 'assets/admin.css';
242
243 if ( file_exists( $asset_js ) ) {
244 // filemtime() cache-busts on every rebuild so a stable
245 // VERSION constant never serves stale JS through browser
246 // caches.
247 wp_enqueue_script(
248 'xspeed-admin',
249 XSPEED_URL . 'assets/admin.js',
250 array( 'wp-api-fetch', 'wp-i18n' ),
251 XSPEED_VERSION . '.' . filemtime( $asset_js ),
252 true
253 );
254 // Loads .mo files for the 'xspeed' text-domain into
255 // window.wp.i18n so the React `__()` helper resolves.
256 if ( function_exists( 'wp_set_script_translations' ) ) {
257 wp_set_script_translations(
258 'xspeed-admin',
259 'xspeed',
260 XSPEED_DIR . 'languages'
261 );
262 }
263 }
264
265 if ( file_exists( $asset_css ) ) {
266 wp_enqueue_style(
267 'xspeed-admin',
268 XSPEED_URL . 'assets/admin.css',
269 array(),
270 XSPEED_VERSION . '.' . filemtime( $asset_css )
271 );
272 }
273
274 /**
275 * Fires after the Free dashboard bundle is enqueued, before its
276 * config is localized. Pro hooks this to enqueue its own bundle
277 * with `xspeed-admin` as a dependency, so its panel
278 * registrations run after `window.XSpeedPro` is installed by
279 * Free's main.tsx.
280 *
281 * @since 1.5.0
282 */
283 do_action( 'xspeed_admin_enqueue', $hook );
284
285 wp_localize_script(
286 'xspeed-admin',
287 'XSpeedConfig',
288 array(
289 'restUrl' => esc_url_raw( rest_url( Rest_Api::NAMESPACE_V1 ) ),
290 'nonce' => wp_create_nonce( 'wp_rest' ),
291 'version' => XSPEED_VERSION,
292 'branding' => self::branding(),
293 // 'pro' when xspeed-pro is active + speaks our API
294 // version (see Tier_Registry); 'free' otherwise.
295 // 'trial' reserved for future license-server work.
296 'tier' => class_exists( '\\XSpeed\\Tier_Registry' ) && Tier_Registry::pro_active() ? 'pro' : 'free',
297 'bootstrap' => self::bootstrap_payload(),
298 )
299 );
300 }
301
302 /**
303 * Pre-rendered settings + status payload, baked into the page so the
304 * React app can mount with real values instead of showing a loading state
305 * while it waits for /settings and /status REST calls.
306 */
307 private static function bootstrap_payload() {
308 $opts = Settings::get();
309 $stats = Cache::get_stats();
310 // Static-rewrite probe state shipped to the React side so the
311 // dashboard can show a persistent banner when nginx/Apache
312 // hasn't been wired to bypass PHP yet. Cheap — Cache::probe…
313 // is transient-throttled to one HTTP round-trip per 5 min.
314 $server_type = Server::type();
315 $rewrite_capable = ( $server_type === Server::NGINX || $server_type === Server::APACHE || $server_type === Server::LITESPEED );
316 $rewrite_probe = null;
317 if ( $opts['cache_enabled'] && $rewrite_capable ) {
318 $probe = Cache::probe_static_rewrite();
319 $rewrite_probe = array(
320 'active' => (bool) ( $probe['active'] ?? false ),
321 'server_type' => $server_type,
322 'snippet' => Cache::nginx_snippet(), // null on non-nginx hosts
323 'topology' => Server::rewrite_topology(),
324 // A reverse proxy / CDN in front (X-Forwarded-* present) usually
325 // means the request-terminating nginx isn't user-editable on this
326 // host — the banner uses this to switch to honest messaging
327 // instead of dangling a snippet the user can't apply.
328 'behind_proxy' => Server::is_behind_proxy(),
329 );
330 }
331
332 return array(
333 'settings' => $opts,
334 'status' => array(
335 'enabled' => (bool) $opts['cache_enabled'],
336 'stats' => $stats,
337 'server' => array(
338 'type' => $server_type,
339 'gzip_mode' => Server::gzip_mode(),
340 'gzip_active' => Gzip::probe_active(),
341 'nginx_snippet' => Gzip::nginx_snippet(),
342 ),
343 'rewrite_probe' => $rewrite_probe,
344 // One consolidated nginx server-block snippet aggregating
345 // every enabled module's directives (Cache static-rewrite,
346 // BrowserCache headers, GZIP, …). Null on non-nginx hosts
347 // or when no module contributes directives. Replaces the
348 // per-module "paste this snippet" notices.
349 'nginx_server_block' => Cache::full_nginx_server_block(),
350 ),
351 // Registered Modules (Free + Pro). The React app discovers them
352 // here and renders one sidebar item + one panel per module that
353 // declares a settings schema. Hidden modules are filtered.
354 'modules' => self::modules_payload(),
355 );
356 }
357
358 /**
359 * Serialize every available Module for the React dashboard. Each entry
360 * carries enough to render: identity (slug + tier), UI metadata (label,
361 * icon, optional description), current settings, and the typed schema
362 * the panel uses to render controls.
363 *
364 * Modules that declare `hidden => true` in ui_metadata (e.g., engine
365 * modules with no user-facing settings) are skipped.
366 */
367 public static function modules_payload() {
368 if ( ! class_exists( 'XSpeed\\Module_Registry' ) ) {
369 return array();
370 }
371 $out = array();
372 foreach ( Module_Registry::available() as $slug => $module ) {
373 $meta = $module->ui_metadata();
374 if ( ! empty( $meta['hidden'] ) ) {
375 continue;
376 }
377 $schema = $module->settings_schema();
378 $custom_panel = $meta['custom_panel'] ?? null;
379 // Skip only when the module has neither a schema nor a custom
380 // panel — i.e., truly nothing to render in the dashboard.
381 if ( empty( $schema ) && empty( $custom_panel ) ) {
382 continue;
383 }
384 $entry = array(
385 'slug' => $slug,
386 'tier' => $module->tier(),
387 'version' => $module->version(),
388 'label' => $meta['label'] ?? ucfirst( $slug ),
389 'icon' => $meta['icon'] ?? 'Square',
390 'description' => $meta['description'] ?? '',
391 'settings' => Settings_Manager::get( $slug ),
392 'schema' => $schema,
393 'notices' => $module->ui_notices(),
394 'custom_panel' => $meta['custom_panel'] ?? null,
395 );
396
397 /**
398 * Last-mile descriptor filter. Lets Pro (or third-party
399 * extensions) override any field before the module is
400 * shipped to React. Primary use: xspeed-pro hooks this to
401 * swap `custom_panel` to LicenseLockedPanel for Pro modules
402 * when the license is invalid, so unlocked modules stay
403 * visible in the sidebar (good upsell UX) but the panel
404 * shows an activation prompt instead of the real surface.
405 */
406 $out[] = apply_filters( 'xspeed_module_descriptor', $entry, $module );
407 }
408 return $out;
409 }
410 }
411