` 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.
*
* This is deliberately a SHORT LIST, not a mirror of React's
* SIDEBAR_GROUPS. It used to mirror all eight groups 1:1, which made the
* WP admin rail a second, competing copy of the in-app sidebar — the same
* map rendered twice, one of them permanently expanded and pushing every
* other plugin's menu down the page.
*
* What stays are the entry points a user navigates to from OUTSIDE the
* app: the dashboard itself, the two areas people arrive at with a
* specific errand (AI & agents to connect an assistant, Settings), and the
* wizard. Everything else — Cache, Optimization, Network, Health &
* insights, Tools — is one click away in the app's own sidebar, which is
* where in-app navigation belongs.
*
* So adding a group to React's SIDEBAR_GROUPS no longer means adding it
* here. The two lists are intentionally different lengths now.
*
* Setup Wizard is NOT in this array — it's a real submenu page registered
* by Onboarding::register_menu() on `admin_menu` at priority 20, so it
* lands after these entries.
*
* @return array map of route hash → menu label.
*/
private static function deep_link_items() {
// Keys are route hashes (`/`); the submenu loop prepends '#'.
// React (nav.ts parseRoute) resolves `#/` to that landing;
// legacy `#slug` links still redirect, so old bookmarks keep working —
// including bookmarks to the groups no longer listed here.
return array(
'/overview' => __( 'Overview', 'xspeed' ),
'/ai-agents' => __( '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'
);
}
}
// Redesign v2 design tokens + self-hosted fonts. Hand-written (not
// Vite-bundled) so the @font-face url('./fonts/…') resolve relative to
// assets/. admin.css depends on it so the CSS vars are defined first.
$theme_css = XSPEED_DIR . 'assets/theme.css';
if ( file_exists( $theme_css ) ) {
wp_enqueue_style(
'xspeed-theme',
XSPEED_URL . 'assets/theme.css',
array(),
XSPEED_VERSION . '.' . filemtime( $theme_css )
);
}
if ( file_exists( $asset_css ) ) {
wp_enqueue_style(
'xspeed-admin',
XSPEED_URL . 'assets/admin.css',
array( 'xspeed-theme' ),
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 );
// NOT wp_localize_script(). WP_Scripts::localize() casts every scalar
// to a string (wp-includes/class-wp-scripts.php: `(string) $value`),
// so an int arrives in JS as "21600" and a bool as "1" or "".
//
// That silently broke the Pro prewarm scheduler: it guards with
// `typeof gmtOffset === 'number'`, which a string fails, so the site's
// UTC offset was treated as 0 and every one-off warm was scheduled
// against UTC instead of site time — hours late on any non-UTC site,
// under a label that confidently read "Site time (UTC)".
//
// wp_add_inline_script() with wp_json_encode() preserves types, so
// numbers stay numbers and booleans stay booleans. Worth doing beyond
// the one field: every future numeric or boolean config value would
// hit the same trap. (#105)
self::print_config(
'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(),
)
);
}
/**
* Emit a JS global for the admin bundle with types intact.
*
* The type-preserving replacement for wp_localize_script(), which
* stringifies every scalar. Attached to the `xspeed-admin` handle as a
* `before` script so it is defined by the time the bundle executes —
* exactly the ordering guarantee localize gave us.
*
* Shared by the dashboard and the onboarding wizard so neither can drift
* back to the stringifying path.
*
* @param string $var_name JS global to define.
* @param array $data Payload; encoded with wp_json_encode().
*/
public static function print_config( string $var_name, array $data ): void {
$json = wp_json_encode( $data );
if ( false === $json ) {
// Never emit a broken assignment — the bundle reads this global
// on mount and a syntax error here blanks the whole screen.
$json = '{}';
}
wp_add_inline_script(
'xspeed-admin',
'var ' . $var_name . ' = ' . $json . ';',
'before'
);
}
/**
* 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(),
// Mirrors the /status block so the enable-time disclosure is
// correct on FIRST PAINT. Without it the top-bar switch can be
// clicked before a status fetch lands, and the one moment the
// warning exists for — a leftover drop-in about to be
// replaced — is exactly when it would be missing.
'dropin' => Page_Cache_Detector::dropin_disclosure(),
),
// 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;
}
$settings = Settings_Manager::get_public( $slug );
$entry = array(
'slug' => $slug,
'tier' => $module->tier(),
'version' => $module->version(),
// Promoted from inside `settings` so the payload is
// self-describing. Consumers kept tripping on this — the Hub
// rendered every module "Inactive" until it learned to look
// inside the settings bag. `settings.enabled` is kept below
// for back-compat; this is the same value, not a second
// source of truth. Modules with no `enabled` key (status
// panels like Health) report null rather than a misleading
// false. (#146)
'enabled' => array_key_exists( 'enabled', $settings )
? (bool) $settings['enabled']
: null,
// Whether the module is actually DOING something, which is not
// the same question as `enabled` above. "On" has several shapes
// -- page caching lives in the global option, Minify and Lazy
// are on when any flag is set, MCP when it is connected -- so
// each module answers for itself via is_active(). Consumers
// that want "what is switched on?" (the sidebar's "N on" badge)
// must read THIS, not `enabled`, which only ever described the
// modules that happen to store that one key. null means the
// module has no meaningful on/off and should be excluded from
// any count rather than treated as off. (#363)
'active' => $module->is_active(),
// One sentence explaining the line above, computed next to it
// so the two cannot disagree. The UI shows it behind an (i)
// beside the status pill: "On" is a bare assertion otherwise,
// and least obvious exactly where it matters -- Media
// Optimization reads On while its two most prominent switches
// are off, because three other flags are on. (#363)
'active_reason' => $module->active_reason(),
'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,
// Public view: real values except secret fields, which are masked.
// The dashboard bundle localizes this into page HTML, so a raw
// credential here would be readable from view-source. (#115)
'settings' => $settings,
// Where each value actually came from: a wp-config.php constant,
// the option row, or the schema default. The panel renders a
// constant-sourced field read-only and names the constant, so it
// can never present an editable box over a value the site is not
// using. Every module gets this, not just the ones that declare
// constants today. (#398)
'setting_origins' => Settings_Manager::origins( $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;
}
}