` 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 ); // Drop WP's auto-generated duplicate first submenu (which // inherits the toplevel "xSpeed" title). The group deep-links // below replace it — keeping it would render a redundant // "xSpeed" / "Dashboard" row that just re-links to the same // page as the toplevel entry. global $submenu; // $submenu may not yet be populated for this slug; the // `remove_submenu_page` call covers either case. remove_submenu_page( self::PAGE_SLUG, self::PAGE_SLUG ); // 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. Mirrors the * React sidebar's group manifest (see src/components/sidebarGroups.ts) * so the WP admin rail reads as the same map as the in-app sidebar. * Each entry points at the first module slug in that group; the * React app's hash router lands the user on that module, which is * the first row of that group's sidebar section — no manual scrolling. * * If you add a group to React's SIDEBAR_GROUPS, add it here too. The * two arrays are intentionally co-located in PR review (same change * touches both) rather than DRY'd through a generated config file — * this is the only PHP↔TS coupling and it's tiny. * * @return array map of first-module-hash → group label. */ private static function deep_link_items() { // Mirrors the dashboard sidebar groups (SIDEBAR_GROUPS in // sidebarGroups.ts) 1:1, in the same order. Each key is the first // module slug of that group (the anchor the submenu deep-links to). // Keep these two lists in sync — they're the only PHP↔TS coupling. // Anchors must be Free, always-visible module slugs so the submenu // works without Pro: 'ai-privacy' (AI group) and 'multisite' (the // Pro Add-ons group placeholder; resolves to the real module on a // licensed multisite). (FBS-82096) return array( 'cache' => __( 'Cache', 'xspeed' ), 'minify' => __( 'Performance', 'xspeed' ), 'cdn' => __( 'Network', 'xspeed' ), 'health' => __( 'Insights', 'xspeed' ), 'database' => __( 'Tools', 'xspeed' ), 'ai-privacy' => __( 'AI', 'xspeed' ), 'multisite' => __( 'Pro Add-ons', '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 'topology' => Server::rewrite_topology(), // A reverse proxy / CDN in front (X-Forwarded-* present) usually // means the request-terminating nginx isn't user-editable on this // host — the banner uses this to switch to honest messaging // instead of dangling a snippet the user can't apply. 'behind_proxy' => Server::is_behind_proxy(), ); } 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, // One consolidated nginx server-block snippet aggregating // every enabled module's directives (Cache static-rewrite, // BrowserCache headers, GZIP, …). Null on non-nginx hosts // or when no module contributes directives. Replaces the // per-module "paste this snippet" notices. 'nginx_server_block' => Cache::full_nginx_server_block(), ), // 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. */ public 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; } }