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

402 lines 14.2 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 return array(
167 'cache' => __( 'Cache', 'xspeed' ),
168 'minify' => __( 'Performance', 'xspeed' ),
169 'cdn' => __( 'Network', 'xspeed' ),
170 'health' => __( 'Insights', 'xspeed' ),
171 'database' => __( 'Tools', 'xspeed' ),
172 'multisite' => __( 'Pro Add-ons', 'xspeed' ),
173 );
174 }
175
176 /**
177 * Pluggable branding for the dashboard chrome. xspeed-pro's
178 * White-Label module hooks `xspeed_branding` to override these
179 * values from saved settings.
180 *
181 * @return array{name:string,footer_credit:?string,hide_help_links:bool,logo_svg:?string}
182 * @since 1.5.0
183 */
184 public static function branding() {
185 $defaults = array(
186 'name' => 'xSpeed',
187 'footer_credit' => null, // null = show the default WPDeveloper credit.
188 'hide_help_links' => false,
189 'logo_svg' => null, // null = use the built-in brand mark.
190 );
191 $out = apply_filters( 'xspeed_branding', $defaults );
192 if ( ! is_array( $out ) ) {
193 return $defaults;
194 }
195 return array_merge( $defaults, $out );
196 }
197
198 /**
199 * URL of the SVG menu icon — the official xSpeed brand mark. Uses
200 * fill="currentColor" which renders black in <img> context; the inline
201 * style below recolors it via CSS filter for the WP admin menu states.
202 */
203 private static function menu_icon() {
204 return XSPEED_URL . 'assets/icon.svg';
205 }
206
207 public function render() {
208 $dark = 'dark' === self::user_theme() ? ' dark' : '';
209 printf( '<div id="xspeed-app" class="xspeed-root%s"></div>', esc_attr( $dark ) );
210 }
211
212 /**
213 * Enqueue the stylesheet that recolors the menu icon to match the WP
214 * admin color scheme. Loads on every admin page (not just the plugin's
215 * page) because the menu icon is visible site-wide.
216 */
217 public function enqueue_menu_styles() {
218 wp_enqueue_style(
219 'xspeed-menu-icon',
220 XSPEED_URL . 'assets/menu-icon.css',
221 array(),
222 XSPEED_VERSION
223 );
224 }
225
226 public function enqueue( $hook ) {
227 if ( 'toplevel_page_' . self::PAGE_SLUG !== $hook ) {
228 return;
229 }
230
231 $asset_js = XSPEED_DIR . 'assets/admin.js';
232 $asset_css = XSPEED_DIR . 'assets/admin.css';
233
234 if ( file_exists( $asset_js ) ) {
235 // filemtime() cache-busts on every rebuild so a stable
236 // VERSION constant never serves stale JS through browser
237 // caches.
238 wp_enqueue_script(
239 'xspeed-admin',
240 XSPEED_URL . 'assets/admin.js',
241 array( 'wp-api-fetch', 'wp-i18n' ),
242 XSPEED_VERSION . '.' . filemtime( $asset_js ),
243 true
244 );
245 // Loads .mo files for the 'xspeed' text-domain into
246 // window.wp.i18n so the React `__()` helper resolves.
247 if ( function_exists( 'wp_set_script_translations' ) ) {
248 wp_set_script_translations(
249 'xspeed-admin',
250 'xspeed',
251 XSPEED_DIR . 'languages'
252 );
253 }
254 }
255
256 if ( file_exists( $asset_css ) ) {
257 wp_enqueue_style(
258 'xspeed-admin',
259 XSPEED_URL . 'assets/admin.css',
260 array(),
261 XSPEED_VERSION . '.' . filemtime( $asset_css )
262 );
263 }
264
265 /**
266 * Fires after the Free dashboard bundle is enqueued, before its
267 * config is localized. Pro hooks this to enqueue its own bundle
268 * with `xspeed-admin` as a dependency, so its panel
269 * registrations run after `window.XSpeedPro` is installed by
270 * Free's main.tsx.
271 *
272 * @since 1.5.0
273 */
274 do_action( 'xspeed_admin_enqueue', $hook );
275
276 wp_localize_script(
277 'xspeed-admin',
278 'XSpeedConfig',
279 array(
280 'restUrl' => esc_url_raw( rest_url( Rest_Api::NAMESPACE_V1 ) ),
281 'nonce' => wp_create_nonce( 'wp_rest' ),
282 'version' => XSPEED_VERSION,
283 'branding' => self::branding(),
284 // 'pro' when xspeed-pro is active + speaks our API
285 // version (see Tier_Registry); 'free' otherwise.
286 // 'trial' reserved for future license-server work.
287 'tier' => class_exists( '\\XSpeed\\Tier_Registry' ) && Tier_Registry::pro_active() ? 'pro' : 'free',
288 'bootstrap' => self::bootstrap_payload(),
289 )
290 );
291 }
292
293 /**
294 * Pre-rendered settings + status payload, baked into the page so the
295 * React app can mount with real values instead of showing a loading state
296 * while it waits for /settings and /status REST calls.
297 */
298 private static function bootstrap_payload() {
299 $opts = Settings::get();
300 $stats = Cache::get_stats();
301 // Static-rewrite probe state shipped to the React side so the
302 // dashboard can show a persistent banner when nginx/Apache
303 // hasn't been wired to bypass PHP yet. Cheap — Cache::probe…
304 // is transient-throttled to one HTTP round-trip per 5 min.
305 $server_type = Server::type();
306 $rewrite_capable = ( $server_type === Server::NGINX || $server_type === Server::APACHE || $server_type === Server::LITESPEED );
307 $rewrite_probe = null;
308 if ( $opts['cache_enabled'] && $rewrite_capable ) {
309 $probe = Cache::probe_static_rewrite();
310 $rewrite_probe = array(
311 'active' => (bool) ( $probe['active'] ?? false ),
312 'server_type' => $server_type,
313 'snippet' => Cache::nginx_snippet(), // null on non-nginx hosts
314 'topology' => Server::rewrite_topology(),
315 // A reverse proxy / CDN in front (X-Forwarded-* present) usually
316 // means the request-terminating nginx isn't user-editable on this
317 // host — the banner uses this to switch to honest messaging
318 // instead of dangling a snippet the user can't apply.
319 'behind_proxy' => Server::is_behind_proxy(),
320 );
321 }
322
323 return array(
324 'settings' => $opts,
325 'status' => array(
326 'enabled' => (bool) $opts['cache_enabled'],
327 'stats' => $stats,
328 'server' => array(
329 'type' => $server_type,
330 'gzip_mode' => Server::gzip_mode(),
331 'gzip_active' => Gzip::probe_active(),
332 'nginx_snippet' => Gzip::nginx_snippet(),
333 ),
334 'rewrite_probe' => $rewrite_probe,
335 // One consolidated nginx server-block snippet aggregating
336 // every enabled module's directives (Cache static-rewrite,
337 // BrowserCache headers, GZIP, …). Null on non-nginx hosts
338 // or when no module contributes directives. Replaces the
339 // per-module "paste this snippet" notices.
340 'nginx_server_block' => Cache::full_nginx_server_block(),
341 ),
342 // Registered Modules (Free + Pro). The React app discovers them
343 // here and renders one sidebar item + one panel per module that
344 // declares a settings schema. Hidden modules are filtered.
345 'modules' => self::modules_payload(),
346 );
347 }
348
349 /**
350 * Serialize every available Module for the React dashboard. Each entry
351 * carries enough to render: identity (slug + tier), UI metadata (label,
352 * icon, optional description), current settings, and the typed schema
353 * the panel uses to render controls.
354 *
355 * Modules that declare `hidden => true` in ui_metadata (e.g., engine
356 * modules with no user-facing settings) are skipped.
357 */
358 private static function modules_payload() {
359 if ( ! class_exists( 'XSpeed\\Module_Registry' ) ) {
360 return array();
361 }
362 $out = array();
363 foreach ( Module_Registry::available() as $slug => $module ) {
364 $meta = $module->ui_metadata();
365 if ( ! empty( $meta['hidden'] ) ) {
366 continue;
367 }
368 $schema = $module->settings_schema();
369 $custom_panel = $meta['custom_panel'] ?? null;
370 // Skip only when the module has neither a schema nor a custom
371 // panel — i.e., truly nothing to render in the dashboard.
372 if ( empty( $schema ) && empty( $custom_panel ) ) {
373 continue;
374 }
375 $entry = array(
376 'slug' => $slug,
377 'tier' => $module->tier(),
378 'version' => $module->version(),
379 'label' => $meta['label'] ?? ucfirst( $slug ),
380 'icon' => $meta['icon'] ?? 'Square',
381 'description' => $meta['description'] ?? '',
382 'settings' => Settings_Manager::get( $slug ),
383 'schema' => $schema,
384 'notices' => $module->ui_notices(),
385 'custom_panel' => $meta['custom_panel'] ?? null,
386 );
387
388 /**
389 * Last-mile descriptor filter. Lets Pro (or third-party
390 * extensions) override any field before the module is
391 * shipped to React. Primary use: xspeed-pro hooks this to
392 * swap `custom_panel` to LicenseLockedPanel for Pro modules
393 * when the license is invalid, so unlocked modules stay
394 * visible in the sidebar (good upsell UX) but the panel
395 * shows an activation prompt instead of the real surface.
396 */
397 $out[] = apply_filters( 'xspeed_module_descriptor', $entry, $module );
398 }
399 return $out;
400 }
401 }
402