` during the initial render — kills the * light→dark flash that happens when JS adds those classes after the * page has already painted. * * @return string 'dark' | 'light' */ public static function user_theme() { // 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. $raw = isset( $_COOKIE[ self::THEME_COOKIE ] ) ? wp_unslash( $_COOKIE[ self::THEME_COOKIE ] ) : ''; return 'dark' === sanitize_key( $raw ) ? 'dark' : 'light'; } public static function is_plugin_page() { $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; if ( ! $screen ) { return false; } // Toplevel page and any submenu (e.g., the onboarding wizard) share // the `xspeed` prefix in their screen base. return 0 === strpos( (string) $screen->base, 'toplevel_page_' . self::PAGE_SLUG ) || 0 === strpos( (string) $screen->base, self::PAGE_SLUG . '_page_' ) || 0 === strpos( (string) $screen->base, 'admin_page_' . Onboarding::PAGE_SLUG ); } public static function admin_body_class( $classes ) { if ( ! self::is_plugin_page() ) { return $classes; } $classes .= ' xspeed-page'; if ( 'dark' === self::user_theme() ) { $classes .= ' xspeed-dark'; } return $classes; } public function register_menu() { $brand = self::branding(); add_menu_page( $brand['name'], $brand['name'], 'manage_options', self::PAGE_SLUG, array( $this, 'render' ), self::menu_icon(), 80 ); // Rename the auto-generated first submenu (which inherits the // toplevel title "xSpeed") to "Dashboard" — keeps the same slug so // it stays the default landing page. add_submenu_page( self::PAGE_SLUG, /* translators: %s = brand name (xSpeed by default; agencies may white-label). */ sprintf( __( '%s Dashboard', 'xspeed' ), $brand['name'] ), __( 'Dashboard', 'xspeed' ), 'manage_options', self::PAGE_SLUG, array( $this, 'render' ) ); // Deep-link submenus — each points to the same dashboard page // with a section hash so React's App.tsx routing lands the // user inside the right module. WordPress's add_submenu_page // strips the hash, so we inject directly into $submenu where // it survives intact (the same trick Yoast / WooCommerce use // for their per-area shortcuts). global $submenu; $deep_links = self::deep_link_items(); foreach ( $deep_links as $hash => $label ) { $submenu[ self::PAGE_SLUG ][] = array( $label, 'manage_options', 'admin.php?page=' . self::PAGE_SLUG . '#' . $hash, ); } } /** * Section deep-links rendered under the xSpeed menu. Keep the list * short — one entry per top-level concern, not per module. Anything * over ~5 entries clutters the WP admin menu rail. * * @return array map of hash → label. */ private static function deep_link_items() { return array( 'cache' => __( 'Cache', 'xspeed' ), 'health' => __( 'Health', 'xspeed' ), 'minify' => __( 'Performance', 'xspeed' ), 'database' => __( 'Tools', 'xspeed' ), ); } /** * Pluggable branding for the dashboard chrome. xspeed-pro's * White-Label module hooks `xspeed_branding` to override these * values from saved settings. * * @return array{name:string,footer_credit:?string,hide_help_links:bool,logo_svg:?string} * @since 1.5.0 */ public static function branding() { $defaults = array( 'name' => 'xSpeed', 'footer_credit' => null, // null = show the default WPDeveloper credit. 'hide_help_links' => false, 'logo_svg' => null, // null = use the built-in brand mark. ); $out = apply_filters( 'xspeed_branding', $defaults ); if ( ! is_array( $out ) ) { return $defaults; } return array_merge( $defaults, $out ); } /** * URL of the SVG menu icon — the official xSpeed brand mark. Uses * fill="currentColor" which renders black in context; the inline * style below recolors it via CSS filter for the WP admin menu states. */ private static function menu_icon() { return XSPEED_URL . 'assets/icon.svg'; } public function render() { $dark = 'dark' === self::user_theme() ? ' dark' : ''; printf( '
', esc_attr( $dark ) ); } /** * Enqueue the stylesheet that recolors the menu icon to match the WP * admin color scheme. Loads on every admin page (not just the plugin's * page) because the menu icon is visible site-wide. */ public function enqueue_menu_styles() { wp_enqueue_style( 'xspeed-menu-icon', XSPEED_URL . 'assets/menu-icon.css', array(), XSPEED_VERSION ); } public function enqueue( $hook ) { if ( 'toplevel_page_' . self::PAGE_SLUG !== $hook ) { return; } $asset_js = XSPEED_DIR . 'assets/admin.js'; $asset_css = XSPEED_DIR . 'assets/admin.css'; if ( file_exists( $asset_js ) ) { // filemtime() cache-busts on every rebuild so a stable // VERSION constant never serves stale JS through browser // caches. wp_enqueue_script( 'xspeed-admin', XSPEED_URL . 'assets/admin.js', array( 'wp-api-fetch', 'wp-i18n' ), XSPEED_VERSION . '.' . filemtime( $asset_js ), true ); // Loads .mo files for the 'xspeed' text-domain into // window.wp.i18n so the React `__()` helper resolves. if ( function_exists( 'wp_set_script_translations' ) ) { wp_set_script_translations( 'xspeed-admin', 'xspeed', XSPEED_DIR . 'languages' ); } } if ( file_exists( $asset_css ) ) { wp_enqueue_style( 'xspeed-admin', XSPEED_URL . 'assets/admin.css', array(), XSPEED_VERSION . '.' . filemtime( $asset_css ) ); } /** * Fires after the Free dashboard bundle is enqueued, before its * config is localized. Pro hooks this to enqueue its own bundle * with `xspeed-admin` as a dependency, so its panel * registrations run after `window.XSpeedPro` is installed by * Free's main.tsx. * * @since 1.5.0 */ do_action( 'xspeed_admin_enqueue', $hook ); wp_localize_script( 'xspeed-admin', 'XSpeedConfig', array( 'restUrl' => esc_url_raw( rest_url( Rest_Api::NAMESPACE_V1 ) ), 'nonce' => wp_create_nonce( 'wp_rest' ), 'version' => XSPEED_VERSION, 'branding' => self::branding(), // 'pro' when xspeed-pro is active + speaks our API // version (see Tier_Registry); 'free' otherwise. // 'trial' reserved for future license-server work. 'tier' => class_exists( '\\XSpeed\\Tier_Registry' ) && Tier_Registry::pro_active() ? 'pro' : 'free', 'bootstrap' => self::bootstrap_payload(), ) ); } /** * Pre-rendered settings + status payload, baked into the page so the * React app can mount with real values instead of showing a loading state * while it waits for /settings and /status REST calls. */ private static function bootstrap_payload() { $opts = Settings::get(); $stats = Cache::get_stats(); // Static-rewrite probe state shipped to the React side so the // dashboard can show a persistent banner when nginx/Apache // hasn't been wired to bypass PHP yet. Cheap — Cache::probe… // is transient-throttled to one HTTP round-trip per 5 min. $server_type = Server::type(); $rewrite_capable = ( $server_type === Server::NGINX || $server_type === Server::APACHE || $server_type === Server::LITESPEED ); $rewrite_probe = null; if ( $opts['cache_enabled'] && $rewrite_capable ) { $probe = Cache::probe_static_rewrite(); $rewrite_probe = array( 'active' => (bool) ( $probe['active'] ?? false ), 'server_type' => $server_type, 'snippet' => Cache::nginx_snippet(), // null on non-nginx hosts ); } return array( 'settings' => $opts, 'status' => array( 'enabled' => (bool) $opts['cache_enabled'], 'stats' => $stats, 'server' => array( 'type' => $server_type, 'gzip_mode' => Server::gzip_mode(), 'gzip_active' => Gzip::probe_active(), 'nginx_snippet' => Gzip::nginx_snippet(), ), 'rewrite_probe' => $rewrite_probe, ), // Registered Modules (Free + Pro). The React app discovers them // here and renders one sidebar item + one panel per module that // declares a settings schema. Hidden modules are filtered. 'modules' => self::modules_payload(), ); } /** * Serialize every available Module for the React dashboard. Each entry * carries enough to render: identity (slug + tier), UI metadata (label, * icon, optional description), current settings, and the typed schema * the panel uses to render controls. * * Modules that declare `hidden => true` in ui_metadata (e.g., engine * modules with no user-facing settings) are skipped. */ private static function modules_payload() { if ( ! class_exists( 'XSpeed\\Module_Registry' ) ) { return array(); } $out = array(); foreach ( Module_Registry::available() as $slug => $module ) { $meta = $module->ui_metadata(); if ( ! empty( $meta['hidden'] ) ) { continue; } $schema = $module->settings_schema(); $custom_panel = $meta['custom_panel'] ?? null; // Skip only when the module has neither a schema nor a custom // panel — i.e., truly nothing to render in the dashboard. if ( empty( $schema ) && empty( $custom_panel ) ) { continue; } $entry = array( 'slug' => $slug, 'tier' => $module->tier(), 'version' => $module->version(), 'label' => $meta['label'] ?? ucfirst( $slug ), 'icon' => $meta['icon'] ?? 'Square', 'description' => $meta['description'] ?? '', 'settings' => Settings_Manager::get( $slug ), 'schema' => $schema, 'notices' => $module->ui_notices(), 'custom_panel' => $meta['custom_panel'] ?? null, ); /** * Last-mile descriptor filter. Lets Pro (or third-party * extensions) override any field before the module is * shipped to React. Primary use: xspeed-pro hooks this to * swap `custom_panel` to LicenseLockedPanel for Pro modules * when the license is invalid, so unlocked modules stay * visible in the sidebar (good upsell UX) but the panel * shows an activation prompt instead of the real surface. */ $out[] = apply_filters( 'xspeed_module_descriptor', $entry, $module ); } return $out; } }