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

335 lines 10.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 }
24
25 /**
26 * Server-side theme detection from the cookie written by useTheme. Used
27 * to emit the `.dark` class on the React mount node and the
28 * `xspeed-dark` class on `<body>` during the initial render — kills the
29 * light→dark flash that happens when JS adds those classes after the
30 * page has already painted.
31 *
32 * @return string 'dark' | 'light'
33 */
34 public static function user_theme() {
35 // 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.
36 $raw = isset( $_COOKIE[ self::THEME_COOKIE ] ) ? wp_unslash( $_COOKIE[ self::THEME_COOKIE ] ) : '';
37 return 'dark' === sanitize_key( $raw ) ? 'dark' : 'light';
38 }
39
40 public static function is_plugin_page() {
41 $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
42 if ( ! $screen ) {
43 return false;
44 }
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 );
50 }
51
52 public static function admin_body_class( $classes ) {
53 if ( ! self::is_plugin_page() ) {
54 return $classes;
55 }
56 $classes .= ' xspeed-page';
57 if ( 'dark' === self::user_theme() ) {
58 $classes .= ' xspeed-dark';
59 }
60 return $classes;
61 }
62
63 public function register_menu() {
64 $brand = self::branding();
65 add_menu_page(
66 $brand['name'],
67 $brand['name'],
68 'manage_options',
69 self::PAGE_SLUG,
70 array( $this, 'render' ),
71 self::menu_icon(),
72 80
73 );
74
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 );
87
88 // Deep-link submenus — each points to the same dashboard page
89 // with a section hash so React's App.tsx routing lands the
90 // user inside the right module. WordPress's add_submenu_page
91 // strips the hash, so we inject directly into $submenu where
92 // it survives intact (the same trick Yoast / WooCommerce use
93 // for their per-area shortcuts).
94 global $submenu;
95 $deep_links = self::deep_link_items();
96 foreach ( $deep_links as $hash => $label ) {
97 $submenu[ self::PAGE_SLUG ][] = array(
98 $label,
99 'manage_options',
100 'admin.php?page=' . self::PAGE_SLUG . '#' . $hash,
101 );
102 }
103 }
104
105 /**
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.
109 *
110 * @return array<string,string> map of hash → label.
111 */
112 private static function deep_link_items() {
113 return array(
114 'cache' => __( 'Cache', 'xspeed' ),
115 'health' => __( 'Health', 'xspeed' ),
116 'minify' => __( 'Performance', 'xspeed' ),
117 'database' => __( 'Tools', 'xspeed' ),
118 );
119 }
120
121 /**
122 * Pluggable branding for the dashboard chrome. xspeed-pro's
123 * White-Label module hooks `xspeed_branding` to override these
124 * values from saved settings.
125 *
126 * @return array{name:string,footer_credit:?string,hide_help_links:bool,logo_svg:?string}
127 * @since 1.5.0
128 */
129 public static function branding() {
130 $defaults = array(
131 'name' => 'xSpeed',
132 'footer_credit' => null, // null = show the default WPDeveloper credit.
133 'hide_help_links' => false,
134 'logo_svg' => null, // null = use the built-in brand mark.
135 );
136 $out = apply_filters( 'xspeed_branding', $defaults );
137 if ( ! is_array( $out ) ) {
138 return $defaults;
139 }
140 return array_merge( $defaults, $out );
141 }
142
143 /**
144 * URL of the SVG menu icon — the official xSpeed brand mark. Uses
145 * fill="currentColor" which renders black in <img> context; the inline
146 * style below recolors it via CSS filter for the WP admin menu states.
147 */
148 private static function menu_icon() {
149 return XSPEED_URL . 'assets/icon.svg';
150 }
151
152 public function render() {
153 $dark = 'dark' === self::user_theme() ? ' dark' : '';
154 printf( '<div id="xspeed-app" class="xspeed-root%s"></div>', esc_attr( $dark ) );
155 }
156
157 /**
158 * Enqueue the stylesheet that recolors the menu icon to match the WP
159 * admin color scheme. Loads on every admin page (not just the plugin's
160 * page) because the menu icon is visible site-wide.
161 */
162 public function enqueue_menu_styles() {
163 wp_enqueue_style(
164 'xspeed-menu-icon',
165 XSPEED_URL . 'assets/menu-icon.css',
166 array(),
167 XSPEED_VERSION
168 );
169 }
170
171 public function enqueue( $hook ) {
172 if ( 'toplevel_page_' . self::PAGE_SLUG !== $hook ) {
173 return;
174 }
175
176 $asset_js = XSPEED_DIR . 'assets/admin.js';
177 $asset_css = XSPEED_DIR . 'assets/admin.css';
178
179 if ( file_exists( $asset_js ) ) {
180 // filemtime() cache-busts on every rebuild so a stable
181 // VERSION constant never serves stale JS through browser
182 // caches.
183 wp_enqueue_script(
184 'xspeed-admin',
185 XSPEED_URL . 'assets/admin.js',
186 array( 'wp-api-fetch', 'wp-i18n' ),
187 XSPEED_VERSION . '.' . filemtime( $asset_js ),
188 true
189 );
190 // Loads .mo files for the 'xspeed' text-domain into
191 // window.wp.i18n so the React `__()` helper resolves.
192 if ( function_exists( 'wp_set_script_translations' ) ) {
193 wp_set_script_translations(
194 'xspeed-admin',
195 'xspeed',
196 XSPEED_DIR . 'languages'
197 );
198 }
199 }
200
201 if ( file_exists( $asset_css ) ) {
202 wp_enqueue_style(
203 'xspeed-admin',
204 XSPEED_URL . 'assets/admin.css',
205 array(),
206 XSPEED_VERSION . '.' . filemtime( $asset_css )
207 );
208 }
209
210 /**
211 * Fires after the Free dashboard bundle is enqueued, before its
212 * config is localized. Pro hooks this to enqueue its own bundle
213 * with `xspeed-admin` as a dependency, so its panel
214 * registrations run after `window.XSpeedPro` is installed by
215 * Free's main.tsx.
216 *
217 * @since 1.5.0
218 */
219 do_action( 'xspeed_admin_enqueue', $hook );
220
221 wp_localize_script(
222 'xspeed-admin',
223 'XSpeedConfig',
224 array(
225 'restUrl' => esc_url_raw( rest_url( Rest_Api::NAMESPACE_V1 ) ),
226 'nonce' => wp_create_nonce( 'wp_rest' ),
227 'version' => XSPEED_VERSION,
228 'branding' => self::branding(),
229 // 'pro' when xspeed-pro is active + speaks our API
230 // version (see Tier_Registry); 'free' otherwise.
231 // 'trial' reserved for future license-server work.
232 'tier' => class_exists( '\\XSpeed\\Tier_Registry' ) && Tier_Registry::pro_active() ? 'pro' : 'free',
233 'bootstrap' => self::bootstrap_payload(),
234 )
235 );
236 }
237
238 /**
239 * Pre-rendered settings + status payload, baked into the page so the
240 * React app can mount with real values instead of showing a loading state
241 * while it waits for /settings and /status REST calls.
242 */
243 private static function bootstrap_payload() {
244 $opts = Settings::get();
245 $stats = Cache::get_stats();
246 // Static-rewrite probe state shipped to the React side so the
247 // 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.
250 $server_type = Server::type();
251 $rewrite_capable = ( $server_type === Server::NGINX || $server_type === Server::APACHE || $server_type === Server::LITESPEED );
252 $rewrite_probe = null;
253 if ( $opts['cache_enabled'] && $rewrite_capable ) {
254 $probe = Cache::probe_static_rewrite();
255 $rewrite_probe = array(
256 'active' => (bool) ( $probe['active'] ?? false ),
257 'server_type' => $server_type,
258 'snippet' => Cache::nginx_snippet(), // null on non-nginx hosts
259 );
260 }
261
262 return array(
263 'settings' => $opts,
264 'status' => array(
265 'enabled' => (bool) $opts['cache_enabled'],
266 'stats' => $stats,
267 'server' => array(
268 'type' => $server_type,
269 'gzip_mode' => Server::gzip_mode(),
270 'gzip_active' => Gzip::probe_active(),
271 'nginx_snippet' => Gzip::nginx_snippet(),
272 ),
273 'rewrite_probe' => $rewrite_probe,
274 ),
275 // Registered Modules (Free + Pro). The React app discovers them
276 // here and renders one sidebar item + one panel per module that
277 // declares a settings schema. Hidden modules are filtered.
278 'modules' => self::modules_payload(),
279 );
280 }
281
282 /**
283 * Serialize every available Module for the React dashboard. Each entry
284 * carries enough to render: identity (slug + tier), UI metadata (label,
285 * icon, optional description), current settings, and the typed schema
286 * the panel uses to render controls.
287 *
288 * Modules that declare `hidden => true` in ui_metadata (e.g., engine
289 * modules with no user-facing settings) are skipped.
290 */
291 private static function modules_payload() {
292 if ( ! class_exists( 'XSpeed\\Module_Registry' ) ) {
293 return array();
294 }
295 $out = array();
296 foreach ( Module_Registry::available() as $slug => $module ) {
297 $meta = $module->ui_metadata();
298 if ( ! empty( $meta['hidden'] ) ) {
299 continue;
300 }
301 $schema = $module->settings_schema();
302 $custom_panel = $meta['custom_panel'] ?? null;
303 // Skip only when the module has neither a schema nor a custom
304 // panel — i.e., truly nothing to render in the dashboard.
305 if ( empty( $schema ) && empty( $custom_panel ) ) {
306 continue;
307 }
308 $entry = array(
309 'slug' => $slug,
310 'tier' => $module->tier(),
311 'version' => $module->version(),
312 'label' => $meta['label'] ?? ucfirst( $slug ),
313 'icon' => $meta['icon'] ?? 'Square',
314 'description' => $meta['description'] ?? '',
315 'settings' => Settings_Manager::get( $slug ),
316 'schema' => $schema,
317 'notices' => $module->ui_notices(),
318 'custom_panel' => $meta['custom_panel'] ?? null,
319 );
320
321 /**
322 * Last-mile descriptor filter. Lets Pro (or third-party
323 * extensions) override any field before the module is
324 * shipped to React. Primary use: xspeed-pro hooks this to
325 * swap `custom_panel` to LicenseLockedPanel for Pro modules
326 * when the license is invalid, so unlocked modules stay
327 * visible in the sidebar (good upsell UX) but the panel
328 * shows an activation prompt instead of the real surface.
329 */
330 $out[] = apply_filters( 'xspeed_module_descriptor', $entry, $module );
331 }
332 return $out;
333 }
334 }
335