PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.3
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.3
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
← All changes | includes/class-onboarding.php +156 -18 1.0.21.3.3 View file →
@@ -47,11 +47,17 @@
47 47 // Visible "Setup Wizard" submenu under xSpeed. Stays in the
48 48 // menu even after onboarding is complete so users can re-run
49 49 // the wizard any time (a fresh run sets
50 50 // xspeed_onboarding_complete = false again on completion).
51 + // Use the white-label brand in the page <title> so an agency's
52 + // rebrand carries through to the wizard tab/title, not just the
53 + // dashboard. (FBS white-label-onboarding)
54 + $brand = Admin::branding();
55 + /* translators: %s: brand name (xSpeed by default, or the white-label name). */
56 + $page_title = sprintf( __( '%s Setup Wizard', 'xspeed' ), $brand['name'] );
51 57 add_submenu_page(
52 58 Admin::PAGE_SLUG,
53 - __( 'xSpeed Setup Wizard', 'xspeed' ),
59 + $page_title,
54 60 __( 'Setup Wizard', 'xspeed' ),
55 61 'manage_options',
56 62 self::PAGE_SLUG,
57 63 array( $this, 'render' )
@@ -101,18 +107,19 @@
101 107 exit;
102 108 }
103 109
104 110 public function enqueue( $hook ) {
105 - // `add_submenu_page` registers this page's hook suffix as
106 - // "{parent_slug}_page_{slug}", but because we immediately call
107 - // `remove_submenu_page`, WordPress still loads it via the toplevel
108 - // page hook when the URL is hit directly. Match both shapes.
109 - $wanted = array(
110 - 'admin_page_' . self::PAGE_SLUG,
111 - Admin::PAGE_SLUG . '_page_' . self::PAGE_SLUG,
112 - 'toplevel_page_' . self::PAGE_SLUG,
113 - );
114 - if ( ! in_array( $hook, $wanted, true ) ) {
111 + unset( $hook );
112 + // Gate on the page SLUG, not the admin hook suffix. WordPress derives
113 + // the submenu hook from the *sanitized parent menu title*, so when the
114 + // White-Label module renames the menu (e.g. "AcmeSpeed") the hook
115 + // becomes "acmespeed_page_xspeed-onboarding" and any check built on
116 + // Admin::PAGE_SLUG ("xspeed_page_…") silently stops matching — the
117 + // wizard bundle then never enqueues and the page renders blank.
118 + // The ?page= slug is brand-independent, so match on that instead.
119 + // (FBS-82222)
120 + $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only screen gate, no state change.
121 + if ( self::PAGE_SLUG !== $page ) {
115 122 return;
116 123 }
117 124
118 125 $asset_js = XSPEED_DIR . 'assets/admin.js';
@@ -118,27 +125,50 @@
118 125 $asset_js = XSPEED_DIR . 'assets/admin.js';
119 126 $asset_css = XSPEED_DIR . 'assets/admin.css';
120 127
121 128 if ( file_exists( $asset_js ) ) {
129 + // filemtime() cache-busts on every rebuild (matches Admin::enqueue)
130 + // so a stable version between releases never serves a stale bundle
131 + // on the wizard — the connected/login state always reflects the
132 + // current build.
122 133 wp_enqueue_script(
123 134 'xspeed-admin',
124 135 XSPEED_URL . 'assets/admin.js',
125 - array( 'wp-api-fetch' ),
126 - XSPEED_VERSION,
136 + array( 'wp-api-fetch', 'wp-i18n' ),
137 + XSPEED_VERSION . '.' . filemtime( $asset_js ),
127 138 true
128 139 );
140 + // Load .mo translations into window.wp.i18n so the wizard's React
141 + // __() calls resolve (mirrors Admin::enqueue). Without this the
142 + // onboarding strings render untranslated even when a locale exists.
143 + if ( function_exists( 'wp_set_script_translations' ) ) {
144 + wp_set_script_translations( 'xspeed-admin', 'xspeed', XSPEED_DIR . 'languages' );
145 + }
129 146 }
147 + // Redesign v2 tokens + fonts (see Admin::enqueue) — the wizard shares
148 + // them so it matches the dashboard identity.
149 + $theme_css = XSPEED_DIR . 'assets/theme.css';
150 + if ( file_exists( $theme_css ) ) {
151 + wp_enqueue_style(
152 + 'xspeed-theme',
153 + XSPEED_URL . 'assets/theme.css',
154 + array(),
155 + XSPEED_VERSION . '.' . filemtime( $theme_css )
156 + );
157 + }
130 158 if ( file_exists( $asset_css ) ) {
131 159 wp_enqueue_style(
132 160 'xspeed-admin',
133 161 XSPEED_URL . 'assets/admin.css',
134 - array(),
135 - XSPEED_VERSION
162 + array( 'xspeed-theme' ),
163 + XSPEED_VERSION . '.' . filemtime( $asset_css )
136 164 );
137 165 }
138 166
139 - wp_localize_script(
140 - 'xspeed-admin',
167 + // Same type-preserving path as the dashboard — see
168 + // Admin::print_config(). wp_localize_script() would stringify every
169 + // scalar in this payload too. (#105)
170 + Admin::print_config(
141 171 'XSpeedConfig',
142 172 array(
143 173 'mode' => 'onboarding',
144 174 'restUrl' => esc_url_raw( rest_url( Rest_Api::NAMESPACE_V1 ) ),
@@ -143,12 +173,25 @@
143 173 'mode' => 'onboarding',
144 174 'restUrl' => esc_url_raw( rest_url( Rest_Api::NAMESPACE_V1 ) ),
145 175 'nonce' => wp_create_nonce( 'wp_rest' ),
146 176 'version' => XSPEED_VERSION,
177 + // White-label branding must reach the wizard too — without it
178 + // the onboarding chrome shows the default "xSpeed" even when an
179 + // agency has rebranded. (FBS white-label-onboarding)
180 + 'branding' => Admin::branding(),
147 181 'dashboardUrl' => admin_url( 'admin.php?page=' . Admin::PAGE_SLUG ),
148 182 'bootstrap' => array(
149 183 'settings' => Settings::get(),
184 + // Live values for every wizard toggle, so re-running the
185 + // wizard reflects the site instead of overwriting it.
186 + 'current' => self::current_choices(),
150 187 'env' => self::env_payload(),
188 + // xSpeed Hub connection snapshot for the wizard's first
189 + // "Connect your account" step. Guarded so core onboarding
190 + // never hard-depends on the MCP module — if it's absent the
191 + // step falls back to its own /mcp/hub fetch (and simply shows
192 + // the not-connected invite). See DESIGN.md §24.x.
193 + 'hub' => self::hub_payload(),
151 194 ),
152 195 )
153 196 );
154 197 }
@@ -153,8 +196,50 @@
153 196 );
154 197 }
155 198
156 199 /**
200 + * The site's CURRENT values for every toggle the wizard can write,
201 + * in the same shape as the wizard's OnboardingChoices.
202 + *
203 + * The wizard used to seed its toggles from hard-coded preset constants,
204 + * so re-running it on a configured site showed a fiction: options the
205 + * admin had deliberately switched on rendered as off, and Apply wrote
206 + * that fiction back, silently undoing their configuration. Nothing
207 + * warned them, and the completion screen still reported success.
208 + *
209 + * The wizard couldn't have done better on its own — the bootstrap only
210 + * carried Settings::get(), which is just cache_enabled. Every other
211 + * toggle lives in a per-module option the payload never included, so
212 + * this method is what makes "show the site as it actually is" possible.
213 + *
214 + * Pure read. Mirrors the keys apply() writes, so the two stay in step.
215 + *
216 + * @return array<string,bool|int>
217 + */
218 + public static function current_choices() {
219 + $minify = Settings_Manager::get( 'minify' );
220 + $gzip = Settings_Manager::get( 'gzip' );
221 + $cache = Settings_Manager::get( 'cache' );
222 + $lazy = Settings_Manager::get( 'lazy' );
223 + $browser = Settings_Manager::get( 'browser-cache' );
224 + $hints = Settings_Manager::get( 'resource-hints' );
225 + $settings = Settings::get();
226 +
227 + return array(
228 + 'cache_enabled' => ! empty( $settings['cache_enabled'] ),
229 + 'minify_html' => ! empty( $minify['minify_html'] ),
230 + 'minify_css' => ! empty( $minify['minify_css'] ),
231 + 'minify_js' => ! empty( $minify['minify_js'] ),
232 + 'defer_js' => ! empty( $minify['defer_js'] ),
233 + 'gzip_enabled' => ! empty( $gzip['gzip_enabled'] ),
234 + 'lazy_images' => ! empty( $lazy['lazy_images'] ),
235 + 'browser_cache' => ! empty( $browser['enabled'] ),
236 + 'resource_hints' => ! empty( $hints['enabled'] ),
237 + 'cache_expiry' => isset( $cache['cache_expiry'] ) ? absint( $cache['cache_expiry'] ) : \XSpeed\Modules\Cache\CacheModule::DEFAULT_EXPIRY_HOURS,
238 + );
239 + }
240 +
241 + /**
157 242 * Environment snapshot rendered as Step 1's health rows. Pure read —
158 243 * never writes to disk, never makes outbound requests.
159 244 */
160 245 public static function env_payload() {
@@ -163,8 +248,32 @@
163 248 // module's dashboard panel consume from there.
164 249 return Health::env_payload();
165 250 }
166 251
252 + /**
253 + * xSpeed Hub connection snapshot for the wizard's first step. Identical
254 + * shape to the MCP panel's `GET /mcp/hub` response so the Connect step and
255 + * the panel's Hub card share one contract. Returns null when the MCP module
256 + * is unavailable — the step then renders its own not-connected invite and
257 + * re-fetches live from /mcp/hub if the route exists.
258 + *
259 + * @return array<string,mixed>|null
260 + */
261 + public static function hub_payload() {
262 + if ( ! class_exists( '\XSpeed\Modules\Mcp\Mcp_Hub' ) ) {
263 + return null;
264 + }
265 + $status = \XSpeed\Modules\Mcp\Mcp_Hub::public_status();
266 + // Override attach_url so the Hub returns the user to the WIZARD (mid-flow)
267 + // after they approve — not the default dashboard. The `xspeed_connected`
268 + // marker lets the wizard show the connected state + auto-advance on
269 + // return. Requires the Hub to honor return_url; if it doesn't yet, the
270 + // tab-return reconcile in useHubConnect is the graceful fallback.
271 + $return = admin_url( 'admin.php?page=' . self::PAGE_SLUG . '&xspeed_connected=1' );
272 + $status['attach_url'] = \XSpeed\Modules\Mcp\Mcp_Hub::attach_url( $return );
273 + return $status;
274 + }
275 +
167 276 public function register_routes() {
168 277 register_rest_route(
169 278 Rest_Api::NAMESPACE_V1,
170 279 '/onboarding/apply',
@@ -250,11 +359,40 @@
250 359 'browser-cache',
251 360 array( 'enabled' => ! empty( $params['browser_cache'] ) )
252 361 );
253 362 }
363 + if ( array_key_exists( 'resource_hints', $params ) ) {
364 + Settings_Manager::update(
365 + 'resource-hints',
366 + array( 'enabled' => ! empty( $params['resource_hints'] ) )
367 + );
368 + }
254 369
370 + // Opt-in usage analytics. Only acted on when the key is present in the
371 + // payload (legacy onboarding-complete sites are left untouched). The
372 + // consent toggle defaults OFF in the wizard, so the common path is
373 + // usage_tracking=false → tracker stays dormant, no outbound HTTP.
374 + // (True since #437; before that the toggle shipped pre-checked and
375 + // this comment described an intent the UI did not implement. The
376 + // same consent is now also writable from Settings → Privacy & usage
377 + // data, via PrivacyModule, which routes through the same opt_in().)
378 + if ( array_key_exists( 'usage_tracking', $params ) ) {
379 + $tracker = Plugin::instance()->usage_tracker();
380 + if ( $tracker ) {
381 + $tracker->opt_in( ! empty( $params['usage_tracking'] ) );
382 + }
383 + }
384 +
255 385 $install_state = Cache::toggle( $want_cache );
256 - Settings::update( array( 'cache_enabled' => $install_state['enabled'] ) );
386 +
387 + // Recompute the unified nginx block AFTER cache_enabled is persisted.
388 + // Cache::toggle() computes it inline, before the Settings::update()
389 + // above writes cache_enabled — so the block in $install_state reflects
390 + // the PRE-apply state (CacheModule::nginx_directives() gates on
391 + // cache_enabled). Regenerate so the wizard's Done step shows the
392 + // snippet for the configuration the user just applied. Mirrors the
393 + // same fix in Rest_Api::toggle_cache().
394 + $install_state['nginx_server_block'] = Cache::full_nginx_server_block();
257 395
258 396 return rest_ensure_response(
259 397 array(
260 398 'settings' => Settings::get(),