| @@ -207,47 +207,41 @@ | ||
| 207 | 207 | } |
| 208 | 208 | } |
| 209 | 209 | |
| 210 | 210 | /** |
| 211 | - * Section deep-links rendered under the xSpeed menu. Mirrors the | |
| 212 | - * React sidebar's group manifest (see src/components/sidebarGroups.ts) | |
| 213 | - * so the WP admin rail reads as the same map as the in-app sidebar. | |
| 214 | - * Each entry points at the first module slug in that group; the | |
| 215 | - * React app's hash router lands the user on that module, which is | |
| 216 | - * the first row of that group's sidebar section — no manual scrolling. | |
| 211 | + * Section deep-links rendered under the xSpeed menu. | |
| 217 | 212 | * |
| 218 | - * If you add a group to React's SIDEBAR_GROUPS, add it here too. The | |
| 219 | - * two arrays are intentionally co-located in PR review (same change | |
| 220 | - * touches both) rather than DRY'd through a generated config file — | |
| 221 | - * this is the only PHP↔TS coupling and it's tiny. | |
| 213 | + * This is deliberately a SHORT LIST, not a mirror of React's | |
| 214 | + * SIDEBAR_GROUPS. It used to mirror all eight groups 1:1, which made the | |
| 215 | + * WP admin rail a second, competing copy of the in-app sidebar — the same | |
| 216 | + * map rendered twice, one of them permanently expanded and pushing every | |
| 217 | + * other plugin's menu down the page. | |
| 222 | 218 | * |
| 223 | - * @return array<string,string> map of first-module-hash → group label. | |
| 219 | + * What stays are the entry points a user navigates to from OUTSIDE the | |
| 220 | + * app: the dashboard itself, the two areas people arrive at with a | |
| 221 | + * specific errand (AI & agents to connect an assistant, Settings), and the | |
| 222 | + * wizard. Everything else — Cache, Optimization, Network, Health & | |
| 223 | + * insights, Tools — is one click away in the app's own sidebar, which is | |
| 224 | + * where in-app navigation belongs. | |
| 225 | + * | |
| 226 | + * So adding a group to React's SIDEBAR_GROUPS no longer means adding it | |
| 227 | + * here. The two lists are intentionally different lengths now. | |
| 228 | + * | |
| 229 | + * Setup Wizard is NOT in this array — it's a real submenu page registered | |
| 230 | + * by Onboarding::register_menu() on `admin_menu` at priority 20, so it | |
| 231 | + * lands after these entries. | |
| 232 | + * | |
| 233 | + * @return array<string,string> map of route hash → menu label. | |
| 224 | 234 | */ |
| 225 | 235 | private static function deep_link_items() { |
| 226 | - // Mirrors the dashboard sidebar groups (SIDEBAR_GROUPS in | |
| 227 | - // sidebarGroups.ts) 1:1, in the same order. Each key is the first | |
| 228 | - // module slug of that group (the anchor the submenu deep-links to). | |
| 229 | - // Keep these two lists in sync — they're the only PHP↔TS coupling. | |
| 230 | - // Anchors must be slugs that are always present in the /modules | |
| 231 | - // payload so the hash resolves on Free too: 'ai-provider' (AI group; | |
| 232 | - // the first AI slug — injected as a locked placeholder on Free via | |
| 233 | - // the upsell manifest, so it resolves even though the real module is | |
| 234 | - // Pro) and 'multisite' (Pro Add-ons; resolves to the real module on a | |
| 235 | - // licensed multisite). NOTE: must match a slug React actually | |
| 236 | - // renders — 'ai-privacy' was removed from the groups, so anchoring | |
| 237 | - // AI there opened nothing (FBS-82743 #2). (FBS-82096) | |
| 236 | + // Keys are route hashes (`/<group-id>`); the submenu loop prepends '#'. | |
| 237 | + // React (nav.ts parseRoute) resolves `#/<group-id>` to that landing; | |
| 238 | + // legacy `#slug` links still redirect, so old bookmarks keep working — | |
| 239 | + // including bookmarks to the groups no longer listed here. | |
| 238 | 240 | return array( |
| 239 | - 'cache' => __( 'Cache', 'xspeed' ), | |
| 240 | - 'minify' => __( 'Optimization', 'xspeed' ), | |
| 241 | - 'cdn' => __( 'Network', 'xspeed' ), | |
| 242 | - 'health' => __( 'Health', 'xspeed' ), | |
| 243 | - 'database' => __( 'Tools', 'xspeed' ), | |
| 244 | - // AI & Agents is an accordion group; anchor to its first row. On | |
| 245 | - // Free 'ai-provider' resolves via its locked manifest placeholder. | |
| 246 | - // License / Branding / Multisite live under Settings (a Free | |
| 247 | - // container, always present, so the anchor resolves on Free too). | |
| 248 | - 'ai-provider' => __( 'AI & Agents', 'xspeed' ), | |
| 249 | - 'settings' => __( 'Settings', 'xspeed' ), | |
| 241 | + '/overview' => __( 'Overview', 'xspeed' ), | |
| 242 | + '/ai-agents' => __( 'AI & agents', 'xspeed' ), | |
| 243 | + '/settings' => __( 'Settings', 'xspeed' ), | |
| 250 | 244 | ); |
| 251 | 245 | } |
| 252 | 246 | |
| 253 | 247 | /** |
| @@ -370,13 +364,25 @@ | ||
| 370 | 364 | ); |
| 371 | 365 | } |
| 372 | 366 | } |
| 373 | 367 | |
| 368 | + // Redesign v2 design tokens + self-hosted fonts. Hand-written (not | |
| 369 | + // Vite-bundled) so the @font-face url('./fonts/…') resolve relative to | |
| 370 | + // assets/. admin.css depends on it so the CSS vars are defined first. | |
| 371 | + $theme_css = XSPEED_DIR . 'assets/theme.css'; | |
| 372 | + if ( file_exists( $theme_css ) ) { | |
| 373 | + wp_enqueue_style( | |
| 374 | + 'xspeed-theme', | |
| 375 | + XSPEED_URL . 'assets/theme.css', | |
| 376 | + array(), | |
| 377 | + XSPEED_VERSION . '.' . filemtime( $theme_css ) | |
| 378 | + ); | |
| 379 | + } | |
| 374 | 380 | if ( file_exists( $asset_css ) ) { |
| 375 | 381 | wp_enqueue_style( |
| 376 | 382 | 'xspeed-admin', |
| 377 | 383 | XSPEED_URL . 'assets/admin.css', |
| 378 | - array(), | |
| 384 | + array( 'xspeed-theme' ), | |
| 379 | 385 | XSPEED_VERSION . '.' . filemtime( $asset_css ) |
| 380 | 386 | ); |
| 381 | 387 | } |
| 382 | 388 | |
| @@ -390,10 +396,23 @@ | ||
| 390 | 396 | * @since 1.5.0 |
| 391 | 397 | */ |
| 392 | 398 | do_action( 'xspeed_admin_enqueue', $hook ); |
| 393 | 399 | |
| 394 | - wp_localize_script( | |
| 395 | - 'xspeed-admin', | |
| 400 | + // NOT wp_localize_script(). WP_Scripts::localize() casts every scalar | |
| 401 | + // to a string (wp-includes/class-wp-scripts.php: `(string) $value`), | |
| 402 | + // so an int arrives in JS as "21600" and a bool as "1" or "". | |
| 403 | + // | |
| 404 | + // That silently broke the Pro prewarm scheduler: it guards with | |
| 405 | + // `typeof gmtOffset === 'number'`, which a string fails, so the site's | |
| 406 | + // UTC offset was treated as 0 and every one-off warm was scheduled | |
| 407 | + // against UTC instead of site time — hours late on any non-UTC site, | |
| 408 | + // under a label that confidently read "Site time (UTC)". | |
| 409 | + // | |
| 410 | + // wp_add_inline_script() with wp_json_encode() preserves types, so | |
| 411 | + // numbers stay numbers and booleans stay booleans. Worth doing beyond | |
| 412 | + // the one field: every future numeric or boolean config value would | |
| 413 | + // hit the same trap. (#105) | |
| 414 | + self::print_config( | |
| 396 | 415 | 'XSpeedConfig', |
| 397 | 416 | array( |
| 398 | 417 | 'restUrl' => esc_url_raw( rest_url( Rest_Api::NAMESPACE_V1 ) ), |
| 399 | 418 | 'nonce' => wp_create_nonce( 'wp_rest' ), |
| @@ -415,8 +434,10 @@ | ||
| 415 | 434 | // Pro), so Pro filters this via `xspeed_pro_state`. Default: |
| 416 | 435 | // not_installed when Pro is absent; 'active' when Pro is present |
| 417 | 436 | // (Pro downgrades to 'unlicensed' when its license isn't valid). |
| 418 | 437 | 'proState' => self::pro_state(), |
| 438 | + // Setup Wizard URL, surfaced in the sidebar profile popover. | |
| 439 | + 'wizardUrl' => admin_url( 'admin.php?page=' . Onboarding::PAGE_SLUG ), | |
| 419 | 440 | 'bootstrap' => self::bootstrap_payload(), |
| 420 | 441 | ) |
| 421 | 442 | ); |
| 422 | 443 | } |
| @@ -421,8 +442,36 @@ | ||
| 421 | 442 | ); |
| 422 | 443 | } |
| 423 | 444 | |
| 424 | 445 | /** |
| 446 | + * Emit a JS global for the admin bundle with types intact. | |
| 447 | + * | |
| 448 | + * The type-preserving replacement for wp_localize_script(), which | |
| 449 | + * stringifies every scalar. Attached to the `xspeed-admin` handle as a | |
| 450 | + * `before` script so it is defined by the time the bundle executes — | |
| 451 | + * exactly the ordering guarantee localize gave us. | |
| 452 | + * | |
| 453 | + * Shared by the dashboard and the onboarding wizard so neither can drift | |
| 454 | + * back to the stringifying path. | |
| 455 | + * | |
| 456 | + * @param string $var_name JS global to define. | |
| 457 | + * @param array $data Payload; encoded with wp_json_encode(). | |
| 458 | + */ | |
| 459 | + public static function print_config( string $var_name, array $data ): void { | |
| 460 | + $json = wp_json_encode( $data ); | |
| 461 | + if ( false === $json ) { | |
| 462 | + // Never emit a broken assignment — the bundle reads this global | |
| 463 | + // on mount and a syntax error here blanks the whole screen. | |
| 464 | + $json = '{}'; | |
| 465 | + } | |
| 466 | + wp_add_inline_script( | |
| 467 | + 'xspeed-admin', | |
| 468 | + 'var ' . $var_name . ' = ' . $json . ';', | |
| 469 | + 'before' | |
| 470 | + ); | |
| 471 | + } | |
| 472 | + | |
| 473 | + /** | |
| 425 | 474 | * Pre-rendered settings + status payload, baked into the page so the |
| 426 | 475 | * React app can mount with real values instead of showing a loading state |
| 427 | 476 | * while it waits for /settings and /status REST calls. |
| 428 | 477 | */ |
| @@ -511,17 +560,41 @@ | ||
| 511 | 560 | // BrowserCache headers, GZIP, …). Null on non-nginx hosts |
| 512 | 561 | // or when no module contributes directives. Replaces the |
| 513 | 562 | // per-module "paste this snippet" notices. |
| 514 | 563 | 'nginx_server_block' => Cache::full_nginx_server_block(), |
| 564 | + // Mirrors the /status block so the enable-time disclosure is | |
| 565 | + // correct on FIRST PAINT. Without it the top-bar switch can be | |
| 566 | + // clicked before a status fetch lands, and the one moment the | |
| 567 | + // warning exists for — a leftover drop-in about to be | |
| 568 | + // replaced — is exactly when it would be missing. | |
| 569 | + 'dropin' => Page_Cache_Detector::dropin_disclosure(), | |
| 515 | 570 | ), |
| 516 | 571 | // Registered Modules (Free + Pro). The React app discovers them |
| 517 | 572 | // here and renders one sidebar item + one panel per module that |
| 518 | 573 | // declares a settings schema. Hidden modules are filtered. |
| 519 | 574 | 'modules' => self::modules_payload(), |
| 575 | + // xSpeed Hub connection snapshot so the Account panel + header chip | |
| 576 | + // render the correct connected/not-connected state on FIRST PAINT — | |
| 577 | + // no fetch, no "not connected → connected" flash. Null when the MCP | |
| 578 | + // module is unavailable (the panel then falls back to /mcp/hub). | |
| 579 | + 'hub' => self::hub_payload(), | |
| 520 | 580 | ); |
| 521 | 581 | } |
| 522 | 582 | |
| 523 | 583 | /** |
| 584 | + * xSpeed Hub connection snapshot for the dashboard bootstrap. Guarded so the | |
| 585 | + * dashboard never hard-depends on the MCP module. Same shape as GET /mcp/hub. | |
| 586 | + * | |
| 587 | + * @return array<string,mixed>|null | |
| 588 | + */ | |
| 589 | + private static function hub_payload() { | |
| 590 | + if ( ! class_exists( '\XSpeed\Modules\Mcp\Mcp_Hub' ) ) { | |
| 591 | + return null; | |
| 592 | + } | |
| 593 | + return \XSpeed\Modules\Mcp\Mcp_Hub::public_status(); | |
| 594 | + } | |
| 595 | + | |
| 596 | + /** | |
| 524 | 597 | * Serialize every available Module for the React dashboard. Each entry |
| 525 | 598 | * carries enough to render: identity (slug + tier), UI metadata (label, |
| 526 | 599 | * icon, optional description), current settings, and the typed schema |
| 527 | 600 | * the panel uses to render controls. |
| @@ -545,12 +618,43 @@ | ||
| 545 | 618 | // panel — i.e., truly nothing to render in the dashboard. |
| 546 | 619 | if ( empty( $schema ) && empty( $custom_panel ) ) { |
| 547 | 620 | continue; |
| 548 | 621 | } |
| 622 | + $settings = Settings_Manager::get_public( $slug ); | |
| 623 | + | |
| 549 | 624 | $entry = array( |
| 550 | 625 | 'slug' => $slug, |
| 551 | 626 | 'tier' => $module->tier(), |
| 552 | 627 | 'version' => $module->version(), |
| 628 | + // Promoted from inside `settings` so the payload is | |
| 629 | + // self-describing. Consumers kept tripping on this — the Hub | |
| 630 | + // rendered every module "Inactive" until it learned to look | |
| 631 | + // inside the settings bag. `settings.enabled` is kept below | |
| 632 | + // for back-compat; this is the same value, not a second | |
| 633 | + // source of truth. Modules with no `enabled` key (status | |
| 634 | + // panels like Health) report null rather than a misleading | |
| 635 | + // false. (#146) | |
| 636 | + 'enabled' => array_key_exists( 'enabled', $settings ) | |
| 637 | + ? (bool) $settings['enabled'] | |
| 638 | + : null, | |
| 639 | + // Whether the module is actually DOING something, which is not | |
| 640 | + // the same question as `enabled` above. "On" has several shapes | |
| 641 | + // -- page caching lives in the global option, Minify and Lazy | |
| 642 | + // are on when any flag is set, MCP when it is connected -- so | |
| 643 | + // each module answers for itself via is_active(). Consumers | |
| 644 | + // that want "what is switched on?" (the sidebar's "N on" badge) | |
| 645 | + // must read THIS, not `enabled`, which only ever described the | |
| 646 | + // modules that happen to store that one key. null means the | |
| 647 | + // module has no meaningful on/off and should be excluded from | |
| 648 | + // any count rather than treated as off. (#363) | |
| 649 | + 'active' => $module->is_active(), | |
| 650 | + // One sentence explaining the line above, computed next to it | |
| 651 | + // so the two cannot disagree. The UI shows it behind an (i) | |
| 652 | + // beside the status pill: "On" is a bare assertion otherwise, | |
| 653 | + // and least obvious exactly where it matters -- Media | |
| 654 | + // Optimization reads On while its two most prominent switches | |
| 655 | + // are off, because three other flags are on. (#363) | |
| 656 | + 'active_reason' => $module->active_reason(), | |
| 553 | 657 | 'label' => $meta['label'] ?? ucfirst( $slug ), |
| 554 | 658 | 'icon' => $meta['icon'] ?? 'Square', |
| 555 | 659 | 'description' => $meta['description'] ?? '', |
| 556 | 660 | // Short label for the module's own tab when it hosts a tabbed |
| @@ -555,9 +659,19 @@ | ||
| 555 | 659 | 'description' => $meta['description'] ?? '', |
| 556 | 660 | // Short label for the module's own tab when it hosts a tabbed |
| 557 | 661 | // page (FBS-83633). Only set on host modules. |
| 558 | 662 | 'tab_label' => $meta['tab_label'] ?? null, |
| 559 | - 'settings' => Settings_Manager::get( $slug ), | |
| 663 | + // Public view: real values except secret fields, which are masked. | |
| 664 | + // The dashboard bundle localizes this into page HTML, so a raw | |
| 665 | + // credential here would be readable from view-source. (#115) | |
| 666 | + 'settings' => $settings, | |
| 667 | + // Where each value actually came from: a wp-config.php constant, | |
| 668 | + // the option row, or the schema default. The panel renders a | |
| 669 | + // constant-sourced field read-only and names the constant, so it | |
| 670 | + // can never present an editable box over a value the site is not | |
| 671 | + // using. Every module gets this, not just the ones that declare | |
| 672 | + // constants today. (#398) | |
| 673 | + 'setting_origins' => Settings_Manager::origins( $slug ), | |
| 560 | 674 | 'schema' => $schema, |
| 561 | 675 | 'notices' => $module->ui_notices(), |
| 562 | 676 | 'custom_panel' => $meta['custom_panel'] ?? null, |
| 563 | 677 | ); |