` 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() {
// Prefer the ?page= slug: it's brand-independent. WordPress derives
// the submenu screen base from the *sanitized parent menu title*, so
// once White-Label renames the menu the base becomes e.g.
// "acmespeed_page_xspeed-onboarding" and any check anchored on
// self::PAGE_SLUG ("xspeed_page_…") silently stops matching — which
// dropped the xspeed-page / xspeed-dark body classes on the wizard and
// left it unstyled. Match the slug instead. (FBS-82222)
$page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only screen gate.
if ( self::PAGE_SLUG === $page || Onboarding::PAGE_SLUG === $page ) {
return true;
}
// Fallback for contexts where $_GET['page'] isn't set but the screen is
// available. The toplevel base is slug-based (stable); the submenu base
// is title-derived, so match on its slug SUFFIX rather than the prefix.
$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
if ( ! $screen ) {
return false;
}
$base = (string) $screen->base;
return 0 === strpos( $base, 'toplevel_page_' . self::PAGE_SLUG )
|| (bool) preg_match( '/_page_' . preg_quote( self::PAGE_SLUG, '/' ) . '($|-)/', $base );
}
public static function admin_body_class( $classes ) {
if ( ! self::is_plugin_page() ) {
return $classes;
}
$classes .= ' xspeed-page';
// Dashboard-only marker (NOT the onboarding wizard, which shares
// `xspeed-page` + the same admin.css but must scroll as a normal
// centered card). Layout rules that reshape the WP admin chrome — the
// sticky content column that pins the fixed-height dashboard while a
// tall admin menu scrolls — are scoped to `.xspeed-dashboard` so they
// never touch the wizard. Keyed on the ?page= slug (brand-independent).
$page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only screen gate.
if ( self::PAGE_SLUG === $page ) {
$classes .= ' xspeed-dashboard';
}
if ( 'dark' === self::user_theme() ) {
$classes .= ' xspeed-dark';
}
return $classes;
}
/**
* Prepend a "Settings" link to the plugin's action links on the Plugins
* screen, deep-linking to the xSpeed dashboard. Only users who can reach
* the settings page (`manage_options`, the same cap the menu uses) see it.
*
* @param array $links Existing action links (Deactivate, etc.).
* @return array
*/
public static function plugin_action_links( $links ) {
if ( ! current_user_can( 'manage_options' ) ) {
return $links;
}
$settings_link = sprintf(
'%2$s',
esc_url( admin_url( 'admin.php?page=' . self::PAGE_SLUG ) ),
esc_html__( 'Settings', 'xspeed' )
);
array_unshift( $links, $settings_link );
return $links;
}
public function register_menu() {
$brand = self::branding();
// Use the white-label logo for the admin menu icon when set, so the
// rebrand carries to the menu mark too — not just the title. Falls
// back to the built-in xSpeed SVG. (FBS-82222)
$menu_icon = ! empty( $brand['logo_svg'] ) ? $brand['logo_svg'] : self::menu_icon();
// Menu label + page
both read the resolved brand name
// ("xSpeed Cache" by default; a white-label brand is used verbatim).
// Positioned just after Appearance
// (WP core slot 60) — up with the site-management group, not buried
// down by Settings and not pinned to the very top.
$menu_label = self::menu_label( $brand );
add_menu_page(
$brand['name'],
$menu_label,
'manage_options',
self::PAGE_SLUG,
array( $this, 'render' ),
$menu_icon,
61
);
// 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 slugs that are always present in the /modules
// payload so the hash resolves on Free too: 'ai-provider' (AI group;
// the first AI slug — injected as a locked placeholder on Free via
// the upsell manifest, so it resolves even though the real module is
// Pro) and 'multisite' (Pro Add-ons; resolves to the real module on a
// licensed multisite). NOTE: must match a slug React actually
// renders — 'ai-privacy' was removed from the groups, so anchoring
// AI there opened nothing (FBS-82743 #2). (FBS-82096)
return array(
// Overview — the default landing screen. Not a module: it's a
// standalone top-level view React renders for the '#overview' hash
// (and for no hash at all). Pinned first, mirroring the pinned
// Overview row at the top of the in-app sidebar (Sidebar.tsx).
'overview' => __( 'Overview', 'xspeed' ),
'cache' => __( 'Cache', 'xspeed' ),
'minify' => __( 'Optimization', 'xspeed' ),
'cdn' => __( 'Network', 'xspeed' ),
'health' => __( 'Health', 'xspeed' ),
'database' => __( 'Tools', 'xspeed' ),
// AI & Agents is an accordion group; anchor to its first row. On
// Free 'ai-provider' resolves via its locked manifest placeholder.
// License / Branding / Multisite live under Settings (a Free
// container, always present, so the anchor resolves on Free too).
'ai-provider' => __( 'AI & Agents', 'xspeed' ),
'settings' => __( 'Settings', '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 Cache',
'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 );
}
/**
* Label for the WordPress admin sidebar menu entry — the resolved brand
* name verbatim ("xSpeed Cache" by default, or the white-label name).
*
* @param array{name:string} $brand Resolved branding array.
* @return string
*/
private static function menu_label( array $brand ) {
return isset( $brand['name'] ) ? (string) $brand['name'] : 'xSpeed Cache';
}
/**
* 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' : '';
// Pre-mount skeleton: the React bundle executes a beat after the page
// paints, so without this the mount div is an empty dark void while it
// loads. We render the xSpeed brand mark (pulsing) straight into the
// mount node in PHP; createRoot().render() REPLACES these children the
// instant React boots, so the skeleton disappears with no JS wiring.
// The mark is the bundled icon.svg; `dark:invert` (scoped in
// styles.css) keeps the currentColor mark visible on the dark shell.
printf(
'
%5$s ',
esc_attr( $dark ),
esc_attr__( 'Loading', 'xspeed' ),
esc_url( XSPEED_URL . 'assets/icon.svg' ),
esc_attr__( 'xSpeed', 'xspeed' ),
esc_html__( 'Loading…', 'xspeed' )
);
}
/**
* 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
);
// menu-icon.css recolors the built-in mark (fill=currentColor) to white
// via a brightness/invert filter. A white-label logo is a real image
// (often colored), so cancel the filter for it — otherwise the agency
// logo renders as a white silhouette. (FBS-82222)
$brand = self::branding();
if ( ! empty( $brand['logo_svg'] ) ) {
wp_add_inline_style(
'xspeed-menu-icon',
'#toplevel_page_' . self::PAGE_SLUG . ' .wp-menu-image img{filter:none;opacity:1}'
);
}
}
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';
// Needed for the media-library picker used by schema 'media' fields
// (e.g. the white-label brand logo). Loads window.wp.media.
wp_enqueue_media();
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(),
// Site's UTC offset in seconds — so datetime pickers (e.g. the
// Pro prewarm scheduler) align with WP's own post-scheduling,
// which is site-time, not the admin's browser-local time.
'gmtOffset' => (int) round( (float) get_option( 'gmt_offset', 0 ) * HOUR_IN_SECONDS ),
// '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',
// Three-state Pro status so gated UI can show the RIGHT message:
// 'not_installed' — Pro plugin not active → "Upgrade to Pro"
// 'unlicensed' — Pro active, no valid license → "Activate license"
// 'active' — Pro active + licensed → (modules unlocked)
// Free can't read Pro's license directly (Free never references
// Pro), so Pro filters this via `xspeed_pro_state`. Default:
// not_installed when Pro is absent; 'active' when Pro is present
// (Pro downgrades to 'unlicensed' when its license isn't valid).
'proState' => self::pro_state(),
// Setup Wizard URL, surfaced in the sidebar profile popover.
'wizardUrl' => admin_url( 'admin.php?page=' . Onboarding::PAGE_SLUG ),
'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.
*/
/**
* Three-state Pro status for the gated UI ('not_installed' | 'unlicensed'
* | 'active'). Free cannot read Pro's license (it never references Pro),
* so the authoritative value comes from the `xspeed_pro_state` filter that
* xspeed-pro hooks. The default here only distinguishes installed vs not —
* when Pro is active, Pro itself downgrades the value to 'unlicensed' if
* its license isn't valid.
*
* @return string
*/
private static function pro_state(): string {
$pro_present = class_exists( '\\XSpeed\\Tier_Registry' ) && Tier_Registry::pro_active();
$default = $pro_present ? 'active' : 'not_installed';
/**
* Filter: xspeed_pro_state
*
* Lets the Pro plugin report its real license state so Free's gated
* panels can show "Activate license" (Pro installed, unlicensed) vs
* "Upgrade to Pro" (Pro not installed).
*
* @param string $state One of 'not_installed' | 'unlicensed' | 'active'.
*/
$state = (string) apply_filters( 'xspeed_pro_state', $default );
return in_array( $state, array( 'not_installed', 'unlicensed', 'active' ), true ) ? $state : $default;
}
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. Cache-ONLY here (no $allow_probe
// arg) so the dashboard bootstrap never makes the loopback HTTP probe
// — that could add seconds to every admin page load on hosts that
// stall self-requests. The Health tab runs the live probe on demand;
// here we just surface whatever it last cached. (FBS-82142)
$server_type = Server::type();
// LiteSpeed serves hits via the PHP drop-in by design (its .htaccess
// can't add the HIT header or log a static hit), so the static-rewrite
// probe is N/A there — surfacing it would pop the "PHP fallback" nag
// for a setup working as designed. Only nginx + Apache probe.
$rewrite_capable = ( $server_type === Server::NGINX || $server_type === Server::APACHE );
$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,
// Separate Mobile Cache visibility (FBS-83145) — mirrors the
// /status block so the dashboard callout renders on first paint
// without waiting for a status re-fetch.
'mobile_separate' => array(
'enabled' => (bool) ( $opts['cache_enabled'] ? ( Settings_Manager::get( 'cache' )['mobile_separate'] ?? false ) : false ),
'blocking' => $rewrite_capable && 'mobile_separate' === Cache::static_rewrite_block_reason(),
'needs_review' => Cache::mobile_separate_needs_review(),
),
// 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(),
// xSpeed Hub connection snapshot so the Account panel + header chip
// render the correct connected/not-connected state on FIRST PAINT —
// no fetch, no "not connected → connected" flash. Null when the MCP
// module is unavailable (the panel then falls back to /mcp/hub).
'hub' => self::hub_payload(),
);
}
/**
* xSpeed Hub connection snapshot for the dashboard bootstrap. Guarded so the
* dashboard never hard-depends on the MCP module. Same shape as GET /mcp/hub.
*
* @return array|null
*/
private static function hub_payload() {
if ( ! class_exists( '\XSpeed\Modules\Mcp\Mcp_Hub' ) ) {
return null;
}
return \XSpeed\Modules\Mcp\Mcp_Hub::public_status();
}
/**
* 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'] ?? '',
// Short label for the module's own tab when it hosts a tabbed
// page (FBS-83633). Only set on host modules.
'tab_label' => $meta['tab_label'] ?? null,
'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;
}
}