PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.2
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.2
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
← All changes | includes/class-metasync-admin-navigation.php +287 -135 2.6.8 → 2.7.2 View file →
@@ -20,8 +20,107 @@
20 20
21 21 const CACHE_KEY = 'metasync_admin_bar_status';
22 22
23 23 /**
24 + * Menu item keys that retain a WordPress admin sidebar row in short mode.
25 + *
26 + * Everything else is registered with an empty parent slug
27 + * (add_submenu_page('', ...)) so the page stays reachable at its
28 + * existing URL but does not claim a sidebar row. The in-page sidebar
29 + * (render_sidenav) continues to show every item.
30 + */
31 + const WP_MENU_KEEP_LIST = [
32 + 'dashboard', // Dashboard
33 + 'seo_controls', // Indexation Control
34 + 'redirections', // Redirections
35 + 'monitor_404', // 404 Monitor
36 + 'xml_sitemap', // XML Sitemap
37 + 'robots_txt', // Robots.txt
38 + 'schema_markup', // Schema Markup
39 + 'seo_health', // SEO Health
40 + 'general', // Settings
41 + ];
42 +
43 + /**
44 + * Whether the Settings page (general) is reachable for the current user.
45 + *
46 + * Settings is the floor — the top-level "Search Atlas" menu item is
47 + * registered with the Settings callback, so it must always point at a
48 + * page the user is permitted to see. When Settings is hidden by access
49 + * control, the top-level callback falls back to the first reachable
50 + * keep-list page (see resolve_top_level_callback()).
51 + *
52 + * @return bool
53 + */
54 + public static function is_settings_reachable()
55 + {
56 + return Metasync_Access_Control::user_can_access('hide_settings');
57 + }
58 +
59 + /**
60 + * Resolve the callback the top-level menu item should use.
61 + *
62 + * The top-level "Search Atlas" menu item is the floor — the plugin must
63 + * always present at least one working entry point. When Settings is
64 + * reachable it is the floor (its callback also renders the Settings
65 + * page). When Settings is hidden, fall back to the first reachable
66 + * keep-list page callback so the parent never leads to a permission
67 + * error or blank page.
68 + *
69 + * @param Metasync_Admin $admin The admin instance whose callbacks are used.
70 + * @return array WordPress-style callback array ([$admin, 'method']).
71 + */
72 + public static function resolve_top_level_callback($admin)
73 + {
74 + if (self::is_settings_reachable()) {
75 + return array($admin, 'create_admin_settings_page');
76 + }
77 +
78 + // Fall back to the first reachable keep-list item callback. The
79 + // keep-list is ordered by priority (Dashboard, Indexation Control,
80 + // Redirections, 404 Monitor, XML Sitemap, Robots.txt, SEO Health,
81 + // Settings). We reuse get_available_menu_items() so access-control
82 + // and connection-state gates are respected identically to the
83 + // sidebar.
84 + $available = self::instance()->get_available_menu_items();
85 + foreach (self::WP_MENU_KEEP_LIST as $key) {
86 + if (isset($available[$key])) {
87 + return array($admin, $available[$key]['callback']);
88 + }
89 + }
90 +
91 + // No keep-list item is reachable — keep Settings as the last resort
92 + // so the menu never leads to a blank page. Settings itself handles
93 + // its own access-control rendering.
94 + return array($admin, 'create_admin_settings_page');
95 + }
96 +
97 + /**
98 + * The ordered keep-list of menu item keys that always retain a
99 + * WordPress admin sidebar row (in short mode).
100 + *
101 + * @return string[]
102 + */
103 + public static function get_wp_menu_keep_list()
104 + {
105 + return self::WP_MENU_KEEP_LIST;
106 + }
107 +
108 + /**
109 + * Whether a given menu item key retains a WordPress admin sidebar row.
110 + *
111 + * Only keep-list items retain a WordPress sidebar row. All other pages
112 + * remain registered and available from the in-page navigation.
113 + *
114 + * @param string $item_key Menu item key from get_available_menu_items().
115 + * @return bool
116 + */
117 + public static function item_keeps_wp_sidebar_row($item_key)
118 + {
119 + return in_array($item_key, self::WP_MENU_KEEP_LIST, true);
120 + }
121 +
122 + /**
24 123 * Get singleton instance.
25 124 *
26 125 * @return self
27 126 */
@@ -34,8 +133,24 @@
34 133 }
35 134
36 135 private function __construct() {}
37 136
137 + /**
138 + * Whether the Dashboard is hidden by the "Hide Dashboard" general setting
139 + * (hide_dashboard_framework).
140 + *
141 + * When enabled this removes the Dashboard from the WordPress admin submenu
142 + * and from the in-plugin SEO feature navigation, matching the setting's
143 + * description "Hide the main dashboard from the WordPress admin menu".
144 + *
145 + * @return bool
146 + */
147 + public static function is_dashboard_hidden_by_framework()
148 + {
149 + $general_options = Metasync::get_option('general') ?? [];
150 + return filter_var($general_options['hide_dashboard_framework'] ?? false, FILTER_VALIDATE_BOOLEAN);
151 + }
152 +
38 153 // ------------------------------------------------------------------
39 154 // Logo resolution helper
40 155 // ------------------------------------------------------------------
41 156
@@ -75,9 +190,9 @@
75 190 $data['show_logo'] = true;
76 191 $data['url'] = $single;
77 192 } elseif (!$is_whitelabel) {
78 193 $data['show_logo'] = true;
79 - $data['url'] = Metasync::HOMEPAGE_DOMAIN . '/wp-content/uploads/2023/12/white.svg';
194 + $data['url'] = plugin_dir_url(dirname(__FILE__)) . 'assets/images/searchatlas-logo.svg';
80 195 $data['is_default'] = true;
81 196 }
82 197 }
83 198
@@ -107,10 +222,11 @@
107 222 $logo = $this->resolve_logo_data();
108 223
109 224 $current_theme = get_option('metasync_theme', 'dark');
110 225 $general_settings = Metasync::get_option('general');
111 - $searchatlas_api_key = isset($general_settings['searchatlas_api_key']) ? $general_settings['searchatlas_api_key'] : '';
112 - $is_integrated = !empty($searchatlas_api_key);
226 + // derive the badge from confirmed heartbeat health, not from
227 + // mere API-key presence (which stays set after Search Atlas revokes it).
228 + $badge = Metasync_Heartbeat_Manager::instance()->get_connection_badge($general_settings);
113 229 ?>
114 230 <div class="metasync-header" data-current-theme="<?php echo esc_attr($current_theme); ?>">
115 231 <div class="metasync-header-left">
116 232 <?php if ($logo['show_logo'] && $logo['use_dual']): ?>
@@ -124,11 +240,11 @@
124 240 </div>
125 241 <?php endif; ?>
126 242 </div>
127 243 <div class="metasync-header-right">
128 - <div class="metasync-status <?php echo $is_integrated ? 'connected' : 'disconnected'; ?>">
244 + <div class="metasync-status <?php echo esc_attr($badge['class']); ?>">
129 245 <span class="status-dot"></span>
130 - <span class="status-text"><?php echo $is_integrated ? 'Connected' : 'Not Connected'; ?></span>
246 + <span class="status-text"><?php echo esc_html($badge['text']); ?></span>
131 247 </div>
132 248 <button type="button" class="metasync-theme-toggle" onclick="toggleMetasyncTheme()" title="Toggle theme">
133 249 <span class="theme-icon-light">&#9728;</span>
134 250 <span class="theme-icon-dark">&#9790;</span>
@@ -150,9 +266,8 @@
150 266 $menu_items = [];
151 267 $menu_icons = [
152 268 'dashboard' => 'dashboard',
153 269 'seo_controls' => 'search',
154 - 'optimal_settings' => 'superhero-alt',
155 270 'instant_index' => 'performance',
156 271 'google_console' => 'chart-area',
157 272 'compatibility' => 'admin-tools',
158 273 'sync_log' => 'list-view',
@@ -163,9 +278,9 @@
163 278 'report_issue' => 'sos',
164 279 'general' => 'admin-settings',
165 280 ];
166 281
167 - if (empty($whitelabel_settings['hide_dashboard'])) {
282 + if (empty($whitelabel_settings['hide_dashboard']) && !self::is_dashboard_hidden_by_framework()) {
168 283 $menu_items['dashboard'] = ['title' => 'Dashboard', 'slug_suffix' => '-dashboard'];
169 284 }
170 285
171 286 if (empty($whitelabel_settings['hide_indexation_control'])) {
@@ -170,17 +285,9 @@
170 285
171 286 if (empty($whitelabel_settings['hide_indexation_control'])) {
172 287 $menu_items['seo_controls'] = ['title' => 'Indexation Control', 'slug_suffix' => '-seo-controls'];
173 288 }
174 -
175 - if ($general_options['enable_optimal_settings'] ?? false) {
176 - $menu_items['optimal_settings'] = ['title' => 'Optimal Settings', 'slug_suffix' => '-optimal-settings'];
177 - }
178 -
179 - if ($general_options['enable_googleinstantindex'] ?? false) {
180 - $menu_items['instant_index'] = ['title' => 'Instant Indexing', 'slug_suffix' => '-instant-index'];
181 - }
182 -
289 +
183 290 if ($general_options['enable_google_console'] ?? false) {
184 291 $menu_items['google_console'] = ['title' => 'Google Console', 'slug_suffix' => '-google-console'];
185 292 }
186 293
@@ -206,9 +313,9 @@
206 313 <div class="metasync-nav-tabs">
207 314 <div class="metasync-nav-left">
208 315 <?php foreach ($menu_items as $key => $menu_item):
209 316 $is_active = ($current_page === $key);
210 - $icon = $menu_icons[$key] ?? 'admin-generic';
317 + $icon = $menu_icons[$key];
211 318 $page_url = '?page=' . $page_slug . $menu_item['slug_suffix'];
212 319 ?>
213 320 <a href="<?php echo esc_url($page_url); ?>" class="metasync-nav-tab <?php echo $is_active ? 'active' : ''; ?>">
214 321 <span class="tab-icon"><span class="dashicons dashicons-<?php echo esc_attr($icon); ?>"></span></span>
@@ -373,10 +480,10 @@
373 480 $menu_items = [];
374 481
375 482 // === SEO FEATURES GROUP ===
376 483
377 - // Dashboard (check access control)
378 - if (Metasync_Access_Control::user_can_access('hide_dashboard')) {
484 + // Dashboard (check access control + "Hide Dashboard" general setting)
485 + if (Metasync_Access_Control::user_can_access('hide_dashboard') && !self::is_dashboard_hidden_by_framework()) {
379 486 $menu_items['dashboard'] = [
380 487 'title' => 'Dashboard',
381 488 'slug_suffix' => '-dashboard',
382 489 'callback' => 'create_admin_dashboard_iframe',
@@ -395,20 +502,8 @@
395 502 'group' => 'seo'
396 503 ];
397 504 }
398 505
399 - // Instant Indexing - setting now stored in seo_controls (moved from Settings to Indexation Control)
400 - $seo_controls = Metasync::get_option('seo_controls');
401 - if ($seo_controls['enable_googleinstantindex'] ?? false) {
402 - $menu_items['instant_index'] = [
403 - 'title' => 'Instant Indexing',
404 - 'slug_suffix' => '-instant-index',
405 - 'callback' => 'create_admin_google_instant_index_page',
406 - 'internal_nav' => 'Instant Indexing',
407 - 'group' => 'seo'
408 - ];
409 - }
410 -
411 506 if ($general_options['enable_google_console'] ?? false) {
412 507 $menu_items['google_console'] = [
413 508 'title' => 'Google Console',
414 509 'slug_suffix' => '-google-console',
@@ -556,8 +651,17 @@
556 651 'internal_nav' => 'Code Minification',
557 652 'group' => 'plugin'
558 653 ];
559 654
655 + // Media Optimization
656 + $menu_items['media_optimization'] = [
657 + 'title' => 'Media Optimization',
658 + 'slug_suffix' => '-media-optimization',
659 + 'callback' => 'create_admin_media_optimization_page',
660 + 'internal_nav' => 'Media Optimization',
661 + 'group' => 'plugin'
662 + ];
663 +
560 664 // Custom HTML Pages (check access control)
561 665 if (Metasync_Access_Control::user_can_access('hide_custom_pages')) {
562 666 $menu_items['custom_pages'] = [
563 667 'title' => 'Custom Pages',
@@ -567,18 +671,8 @@
567 671 'group' => 'plugin'
568 672 ];
569 673 }
570 674
571 - if ($general_options['enable_optimal_settings'] ?? false) {
572 - $menu_items['optimal_settings'] = [
573 - 'title' => 'Optimal Settings',
574 - 'slug_suffix' => '-optimal-settings',
575 - 'callback' => 'create_admin_optimal_settings_page',
576 - 'internal_nav' => 'Optimal Settings',
577 - 'group' => 'plugin'
578 - ];
579 - }
580 -
581 675 // Compatibility page (check access control)
582 676 if (Metasync_Access_Control::user_can_access('hide_compatibility')) {
583 677 $menu_items['compatibility'] = [
584 678 'title' => 'Compatibility',
@@ -637,9 +731,11 @@
637 731 $data = Metasync::get_option('general');
638 732 $plugin_name = Metasync::get_effective_plugin_name();
639 733 $menu_name = $plugin_name;
640 734 $menu_title = $plugin_name;
641 - $menu_slug = !isset($data['white_label_plugin_menu_slug']) || $data['white_label_plugin_menu_slug'] == "" ? Metasync_Admin::$page_slug : $data['white_label_plugin_menu_slug'];
735 + // Sanitize so a URL-shaped legacy value still yields a valid WP menu slug
736 + $menu_slug = !isset($data['white_label_plugin_menu_slug']) || $data['white_label_plugin_menu_slug'] == "" ? Metasync_Admin::$page_slug : sanitize_title($data['white_label_plugin_menu_slug']);
737 + $menu_slug = $menu_slug === '' ? Metasync_Admin::$page_slug : $menu_slug;
642 738 $menu_icon = !isset($data['white_label_plugin_menu_icon']) || $data['white_label_plugin_menu_icon'] == "" ? 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzIiIGhlaWdodD0iMzQiIHZpZXdCb3g9IjAgMCAzMiAzNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPGcgY2xpcC1wYXRoPSJ1cmwoI2NsaXAwXzE4NzlfMTcyNzcpIj4KPHBhdGggZD0iTTI5LjAyMTUgMi4xNjc2NkwzMC4wMTAxIDIuMTIyNDFMMjkuMTYyNyA4LjcyNzA1TDI5LjExNTcgOC44MTc0M0w1LjI5NTA0IDMzLjI0NTJMNC43MzAxMiAzMy4yOTA1TDQuMDcxMDQgMzMuOTY5MUgxLjk1MjYyTDIuMjM1MDggMzIuMjk1M0wyLjA5Mzg0IDMyLjM0MDZMMi4xODggMzEuNjYySDEuNjIzMDhDMS41NzYgMzEuNjYyIDEuNDgxODUgMzEuNjYyIDEuNDgxODUgMzEuNjE2N0MxLjQzNDc4IDMxLjU3MTYgMS4zODc3IDMxLjQ4MSAxLjM4NzcgMzEuNDM1OEwyLjUxNzU0IDI1LjEwMjdDMi41MTc1NCAyNS4wNTc1IDIuNTY0NiAyNS4wMTIyIDIuNTY0NiAyNS4wMTIyTDI2LjE5NjkgMC42Mjk2MDdDMjYuMjQ0IDAuNTg0MzY2IDI2LjI5MTEgMC41MzkxMjUgMjYuMzM4MSAwLjUzOTEyNUgyOC4zNjI1QzI4LjQwOTUgMC40OTM4ODQgMjguNDU2NiAwLjUzOTEyNSAyOC41MDM3IDAuNTg0MzY2QzI4LjU1MDcgMC42Mjk2MDcgMjguNTk3OSAwLjcyMDA3MSAyOC41OTc5IDAuNzY1MzExTDI4LjUwMzcgMS4zNTMzOUgyOS4xMTU3TDI5LjAyMTUgMi4xNjc2NlpNMS45MDU1NCAzMS4yMDk2SDIuMjgyMTZMMy4yMjM2OCAyNS43ODEySDMuMjcwNzZMNi44MDE1IDIyLjI1MjhMNi44NDg1MSAyMi4yOTgxTDIwLjIxODMgOC40NTU1N0wyNi45MDMxIDEuMzUzMzlIMjguMDMyOUwyOC4wNzk5IDAuOTkxNDk5SDI2LjQ3OTNMMi45ODgzIDI1LjIzODRMMS45MDU1NCAzMS4yMDk2Wk0yOC42OTE5IDguNTQ1OTVMMjkuNDQ1MiAyLjYyMDAxSDI4Ljk3NDVMMjguMzE1MyA3Ljc3Njk2TDI4LjI2ODMgNy44MjIyNEwyOC4xMjcxIDcuOTU3ODlMMjcuOTM4NyA5LjMxNTExTDI4LjY5MTkgOC41NDU5NVoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGQ9Ik0zMS41NTQ0IDE4LjE5MzdDMzEuNzM0OSAxOC42NjYyIDMxLjgwMjggMTkuMjA2NCAzMS42ODk4IDE5Ljc2OUwyOS42NTgyIDMyLjEwMTNIMjkuMDkzOUwyOS4wMjYyIDMyLjQzODhIMjUuOTMzNkwyNi4wNjkxIDMxLjYwNjJIMjYuMDAxM0wyNi4wNjkxIDMxLjI2ODZIMjUuNzk4MUMyNS43NTMgMzEuMjY4NiAyNS43MzA1IDMxLjI2ODYgMjUuNzA3OSAzMS4yNDYxQzI1LjY4NTIgMzEuMjIzNyAyNS42ODUyIDMxLjE3ODYgMjUuNjg1MiAzMS4xNTZMMjYuMzYyNCAyNy4zOThIMTguNDE2N0wxNy40MjM0IDMyLjA3ODdIMTYuODM2NUwxNi43Njg3IDMyLjQxNjNIMTMuNjk4OEwxMy45MDE5IDMxLjU4MzZIMTMuODM0M0wxMy45MDE5IDMxLjI2ODZIMTMuNjMxQzEzLjYwODQgMzEuMjY4NiAxMy41NjMzIDMxLjI2ODYgMTMuNTQwOCAzMS4yNDYxQzEzLjU0MDggMzEuMjAxMSAxMy41MTgyIDMxLjE3ODYgMTMuNTQwOCAzMS4xMzM2TDE2LjM2MjQgMTguOTEzOEMxNi40OTc5IDE4LjM1MTIgMTYuNzQ2MiAxNy44MzM2IDE3LjEyOTkgMTcuMzM4NUMxNy40OTEyIDE2Ljg2NiAxNy45NDI2IDE2LjQ4MzUgMTguNDYxOCAxNi4yMTM0QzE4Ljk4MSAxNS45MjA3IDE5LjUwMDIgMTUuNzg1OCAyMC4wNDE5IDE1Ljc4NThIMjguNTk3MkMyOS4xMzkgMTUuNzg1OCAyOS42MTMxIDE1LjkyMDcgMzAuMDE5MyAxNi4yMTM0QzMwLjM1NzkgMTYuNDYwOSAzMC42Mjg5IDE2Ljc5ODUgMzAuODA5NSAxNy4xODFDMzEuMTI1NSAxNy40NTExIDMxLjM5NjMgMTcuNzg4NiAzMS41NTQ0IDE4LjE5MzdaTTI3LjQ5MTIgMjMuMzY5N0wyOC4wNTU1IDIwLjI2NDFDMjguMDMyOSAyMC4yNDE1IDI4LjAzMjkgMjAuMjE5MSAyOC4wMTA0IDIwLjIxOTFIMjcuODk3NUwyNy4zNzgzIDIzLjA5OTZDMjcuMzc4MyAyMy4xNDQ3IDI3LjMxMDYgMjMuMTg5NiAyNy4yNjU1IDIzLjE4OTZIMTkuMzQyMUwxOS4yOTcgMjMuMzY5N0gyNy40OTEyWk0xOS4wOTM5IDIzLjE4OTZIMTguODQ1NUwxOC44MjI5IDIzLjM2OTdIMTkuMDcxM0wxOS4wOTM5IDIzLjE4OTZaTTE5LjUwMDIgMjAuMjY0MUwxOC45MTMzIDIyLjk2NDZIMTkuMTYxNUwxOS43NDg0IDIwLjIxOTFIMTkuNTQ1M0MxOS41NDUzIDIwLjIxOTEgMTkuNTIyNyAyMC4yNDE1IDE5LjUwMDIgMjAuMjY0MVpNMjcuNjcxOCAyMC4yMTkxSDE5Ljk3NDJMMTkuMzg3MiAyMi45NjQ2SDI3LjE3NTFMMjcuNjcxOCAyMC4yMTkxWk0xMy43ODkgMzEuMDQzNkgxMy45NDdMMTUuMDk4NCAyNi4xMTUySDE1LjE2NkwxNi43MjM2IDE5LjI5NjRDMTYuODU5MSAxOC43NTYzIDE3LjEwNzMgMTguMjM4OCAxNy40Njg1IDE3Ljc2NjFDMTcuODI5NiAxNy4zMTYxIDE4LjI4MTIgMTYuOTMzNCAxOC43Nzc4IDE2LjY2MzNDMTkuMDI2MSAxNi41Mjg0IDE5LjI3NDUgMTYuNDM4NCAxOS41MjI3IDE2LjM0ODNMMTkuNTAwMiAxNi4zMDM0QzE5Ljc0ODQgMTYuMjEzNCAyMC4wMTkzIDE2LjE5MDggMjAuMjkwMyAxNi4xOTA4SDI4Ljg2ODJDMjkuMjI5NCAxNi4xOTA4IDI5LjU5MDUgMTYuMjU4MyAyOS44ODQgMTYuMzkzNEMyOS41MjI5IDE2LjEyMzMgMjkuMDkzOSAxNi4wMTA4IDI4LjU5NzIgMTYuMDEwOEgyMC4wNDE5QzE5LjU0NTMgMTYuMDEwOCAxOS4wNDg2IDE2LjE0NTkgMTguNTc0NyAxNi4zOTM0QzE4LjA3OCAxNi42NjMzIDE3LjY0OTEgMTcuMDIzNSAxNy4zMTA0IDE3LjQ5NkMxNi45NDkzIDE3Ljk0NjIgMTYuNzAxIDE4LjQ0MTIgMTYuNTg4MSAxOC45NTg5TDEzLjc4OSAzMS4wNDM2Wk0xNy4yMjAyIDMxLjg1MzdMMTguMTkxIDI3LjM5OEgxNy45NDI2TDE3LjAxNzEgMzEuNTgzNkgxNi45NDkzTDE2LjkwNDIgMzEuODUzN0gxNy4yMjAyWk0yNS45MzM2IDMxLjA0MzZIMjYuMTE0MkwyNi43Njg5IDI3LjM5OEgyNi41ODgzTDI1LjkzMzYgMzEuMDQzNlpNMzEuNDg2NyAxOS43NDY0QzMxLjU3NjkgMTkuMjA2NCAzMS41MDkzIDE4LjcxMTMgMzEuMzI4NyAxOC4yNjEyQzMxLjI4MzYgMTguMTQ4OCAzMS4yMTU4IDE4LjAzNjIgMzEuMTQ4MSAxNy45MjM2QzMxLjI4MzYgMTguMzUxMiAzMS4zMjg3IDE4LjgwMTQgMzEuMjM4MyAxOS4yOTY0TDMwLjA4NzIgMjYuMTE1MkwzMC4xNTQ4IDI2LjEzNzdMMjkuMjUxOSAzMS42MDYySDI5LjE4NDNMMjkuMTM5IDMxLjg3NjNIMjkuNDU1TDMxLjQ4NjcgMTkuNzQ2NFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGQ9Ik0xNy42ODQ3IDIuNDI3MTdIMTcuNjYxOEMxNy44NjgyIDIuODk5MTIgMTcuOTE0IDMuNDM4NDkgMTcuODIyMiA0LjAwMDMzTDE3LjU5MjkgNS4zOTM3SDE3LjA0MjNMMTYuOTczNSA1LjczMDhIMTMuOTY4NEwxNC4xMjkxIDQuODk5MjhIMTQuMDM3M0wxNC4xMDYyIDQuNTg0NjRIMTMuODUzOEMxMy44MDggNC41ODQ2NCAxMy43ODUxIDQuNTYyMTcgMTMuNzYyIDQuNTM5NjlDMTMuNzM5MSA0LjUxNzIzIDEzLjczOTEgNC40OTQ3NSAxMy43MzkxIDQuNDQ5OEg1LjkxNjg2TDUuNTQ5ODcgNi4wOTAzOUgxMy41Nzg1QzE0LjEyOTEgNi4wOTAzOSAxNC42MzM3IDYuMjQ3NzEgMTUuMDQ2NyA2LjUzOTg3QzE1LjM2NzggNi43NDIxMyAxNS41OTcxIDcuMDU2NzcgMTUuODAzNiA3LjM3MTQxQzE1Ljg0OTUgNy40Mzg4OSAxNS44NzI0IDcuNDgzODIgMTUuOTE4NCA3LjU1MTEyQzE2LjIxNjYgNy43OTgzMiAxNi40Njg4IDguMTEyOTkgMTYuNjI5NSA4LjQ5NTE0QzE2LjgzNTkgOC45NjY5OCAxNi45MDQ4IDkuNTA2NDcgMTYuNzkwMSAxMC4wOTA3TDE2LjI4NTMgMTMuMTY5NkMxNi4xOTM1IDEzLjczMTUgMTUuOTQxMyAxNC4yNDg0IDE1LjU3NDMgMTQuNzQyOEMxNS4yMDcyIDE1LjIxNDcgMTQuNzQ4NCAxNS41OTY4IDE0LjIyMDggMTUuODg4OUMxNC4xNTIgMTUuOTExNSAxNC4wNjAyIDE1Ljk1NjQgMTMuOTkxNSAxNS45Nzg4QzEzLjg3NjcgMTYuMDY4OCAxMy43NjIgMTYuMTU4NyAxMy42MjQ1IDE2LjIyNkMxMy4wOTY4IDE2LjQ5NTcgMTIuNTY5MiAxNi42NTMxIDExLjk5NTcgMTYuNjUzMUgyLjYzNjYxQzIuMDg2MDcgMTYuNjUzMSAxLjU4MTQxIDE2LjQ5NTcgMS4xNjg1IDE2LjIyNkMwLjc1NTYxIDE1Ljk1NjQgMC40ODAzMzEgMTUuNTc0MyAwLjMxOTc1IDE1LjEyNDhDMC4xODIxMiAxNC43NjUyIDAuMTU5MTg3IDE0LjQwNTggMC4xODIxMTkgMTQuMDAxMkMwLjE4MjExOSAxMy45NTYxIDAuMTM2MjU0IDEzLjkzMzggMC4xMzYyNTQgMTMuODg4OEMtMC4wMjQzMjYxIDEzLjQxNjggLTAuMDQ3MjU4MSAxMi44Nzc1IDAuMDkwMzcyNSAxMi4zMTU2TDAuMzg4NTgzIDExLjAxMjJDMC4zODg1ODMgMTAuOTY3MyAwLjQ1NzM5OCAxMC45MjIzIDAuNTAzMjY0IDEwLjkyMjNIMy41NTQxN0MzLjU3NzExIDEwLjkyMjMgMy42MDAwNCAxMC45NDQ3IDMuNjIyOTkgMTAuOTY3M0MzLjY0NTkyIDEwLjk4OTYgMy42Njg4NyAxMS4wMzQ2IDMuNjQ1OTIgMTEuMDU3MUwzLjYwMDA0IDExLjMyNjlIMy44OTgyNUwzLjgwNjUgMTEuNzMxNEg0LjMxMTE2TDQuMjE5MzkgMTIuMjAzMkgxMi4yNzFDMTIuMjcxIDEyLjIwMzIgMTIuMjcxIDEyLjIwMzIgMTIuMjkzOSAxMi4xODA4QzEyLjI5MzkgMTIuMTU4MyAxMi4zMTY4IDEyLjE1ODMgMTIuMzE2OCAxMi4xNTgzTDEyLjU5MjEgMTAuNTYyN0gzLjk5MDAyQzMuNDM5NDggMTAuNTYyNyAyLjk1Nzc1IDEwLjQyNzggMi41Njc3OSAxMC4xMzU2QzIuMTU0ODggOS44NjU5IDEuODc5NjIgOS41MDY0NyAxLjcxOTA0IDkuMDM0NDZDMS42MDQzNCA4LjY5NzQxIDEuNTU4NDYgOC4zMzc4MSAxLjYwNDM0IDcuOTMzMjhDMS42MDQzNCA3Ljg4ODM1IDEuNTU4NDYgNy44NjU4IDEuNTU4NDYgNy44MjA4N0MxLjM5NzkgNy4zNDg4NiAxLjM3NDk3IDYuODA5NTYgMS41MTI2IDYuMjI1MjNMMi4yNDY2NSAzLjE0NjM0QzIuMzg0MjggMi41ODQ0OSAyLjYzNjYxIDIuMDY3NTggMy4wMDM2MyAxLjU3MzE2QzMuMzcwNjYgMS4xMDEyMiAzLjgyOTQ0IDAuNzE5MTUyIDQuMzU3MDQgMC40NDk0NzZDNC44ODQ2MyAwLjE1NzMxOSA1LjQxMjI0IDAgNS45NjI4MyAwSDE0LjcwMjZDMTUuMjMwMSAwIDE1LjcxMTggMC4xNTczMTkgMTYuMTI0OCAwLjQ0OTQ3NkMxNi40NDU5IDAuNjc0MjA2IDE2LjY3NTMgMC45NjYzOCAxNi44NTg4IDEuMzAzNDhDMTYuOTA0OCAxLjM0ODQzIDE2LjkyNzcgMS4zOTMzNyAxNi45NzM1IDEuNDYwOEMxNy4yNzE4IDEuNzMwNDggMTcuNTI0IDIuMDIyNjQgMTcuNjg0NyAyLjQyNzE3Wk0zLjY0NTkyIDEyLjU4NTRWMTIuNjA3OEgzLjg5ODI1TDMuOTIxMiAxMi40MjhIMy42Njg4N0wzLjY0NTkyIDEyLjU2MjhDMy42MjI5OSAxMi41NjI4IDMuNjIyOTkgMTIuNTg1NCAzLjY0NTkyIDEyLjU4NTRaTTQuOTk5MzMgNi41NjIzNUg1LjIwNTc4TDUuMjc0NjEgNi4zMTUxNEg0Ljk1MzQ1TDQuOTA3NTggNi40NDk5N0M0LjkwNzU4IDYuNDk0OTIgNC45MDc1OCA2LjUxNzQgNC45MzA1MSA2LjUzOTg3QzQuOTUzNDUgNi41NjIzNSA0Ljk3NjQgNi41NjIzNSA0Ljk5OTMzIDYuNTYyMzVaTTEuNzQxOTcgNi4yNzAxN0MxLjY1MDIzIDYuNjc0NyAxLjY1MDIzIDcuMDU2NzcgMS43MTkwNCA3LjM5Mzc5TDEuNzQxOTcgNy4yMzY1NUMxLjc2NDkyIDcuMDExODEgMS43ODc4NiA2LjgwOTU2IDEuODMzNzQgNi41ODQ4MUwyLjU0NDg0IDMuNTI4MzlDMi42ODI0OSAyLjk2NjU0IDIuOTM0ODIgMi40NDk2NSAzLjMwMTg1IDEuOTc3NjlDMy4zNDc3MSAxLjkzMjc0IDMuMzcwNjYgMS44ODc4IDMuMzkzNTkgMS44NDI4NUwzLjQ2MjQxIDEuOTEwMjhDMy44MDY1IDEuNDgzMjcgNC4xOTY0NiAxLjE0NjE2IDQuNjc4MTkgMC44OTg5NTNDNS4xODI4NCAwLjYyOTI2IDUuNjg3NSAwLjQ5NDQyMyA2LjIxNTA2IDAuNDk0NDIzSDE0Ljk3NzlDMTUuMTg0MyAwLjQ5NDQyMyAxNS4zOTA3IDAuNTE2OTA0IDE1LjU5NzEgMC41NjE4NUwxNS42MiAwLjQ5NDQyM0MxNS43MzQ5IDAuNTE2OTA0IDE1Ljg0OTUgMC41NjE4NSAxNS45NjQyIDAuNjA2Nzk2QzE1LjU5NzEgMC4zNTk1ODUgMTUuMTg0MyAwLjI0NzIxMSAxNC43MDI2IDAuMjQ3MjExSDUuOTYyODNDNS40NTgxMiAwLjI0NzIxMSA0Ljk1MzQ1IDAuMzU5NTg0IDQuNDcxNzQgMC42MjkyNkMzLjk2NzA3IDAuODk4OTUzIDMuNTU0MTcgMS4yNTg1NCAzLjE4NzE1IDEuNzA4MDFDMi44NDMwNSAyLjE3OTk1IDIuNTkwNzIgMi42NzQzOCAyLjQ3NjAzIDMuMTkxMjhMMS43NDE5NyA2LjI3MDE3Wk0xMi4yNzEgMTIuNDI4SDQuMTczNTNMNC4xMjc2NSAxMi42MDc4SDcuNzI5MVYxMi42NzUySDEyLjU2OTJDMTIuNTkyMSAxMi42NzUyIDEyLjYxNSAxMi42NTI3IDEyLjY2MSAxMi42MzAzQzEyLjY4MzkgMTIuNjA3OCAxMi43MDY4IDEyLjU2MjggMTIuNzA2OCAxMi41NDAzTDEyLjg0NDUgMTEuODIxMkwxMy4wNTEgMTAuNjc1QzEzLjA3MzkgMTAuNjMgMTMuMDUxIDEwLjYwNzcgMTMuMDI4MSAxMC41ODUxQzEzLjAwNSAxMC41NjI3IDEyLjk4MjEgMTAuNTYyNyAxMi45NTkyIDEwLjU2MjdIMTIuODQ0NUwxMi41MjMzIDEyLjIwMzJDMTIuNTIzMyAxMi4yNDgyIDEyLjUwMDQgMTIuMzE1NiAxMi40MzE3IDEyLjM2MDZDMTIuMzg1NyAxMi40MDU1IDEyLjMxNjggMTIuNDI4IDEyLjI3MSAxMi40MjhaTTQuMDM1OSAxMS45NTZIMy43NjA2MkwzLjcxNDc0IDEyLjIwMzJIMy45NjcwN0w0LjAzNTkgMTEuOTU2Wk0wLjI5NjgxNyAxMi4zNjA2QzAuMjA1MDY5IDEyLjc2NTEgMC4yMDUwNyAxMy4xNjk2IDAuMjczODg2IDEzLjUyOTJMMC4zMTk3NSAxMy4zMDQ0QzAuMzE5NzUgMTMuMTAyMSAwLjM0MjcgMTIuODk5OSAwLjQxMTUxNiAxMi42NzUyTDAuNzA5NzI3IDExLjMyNjlIMy4zNzA2NkwzLjM5MzU5IDExLjE0N0gwLjU5NTAyOUwwLjI5NjgxNyAxMi4zNjA2Wk0xNi41ODM1IDEwLjA0NThDMTYuNjc1MyA5LjUwNjQ3IDE2LjYwNjYgOS4wMTE5MSAxNi40MjMgOC41ODVDMTYuNDAwMSA4LjU0MDA3IDE2LjM3NzEgOC40OTUxNCAxNi4zNTQyIDguNDUwMjFDMTYuNDAwMSA4LjY1MjQ4IDE2LjQyMyA4Ljg1NDc0IDE2LjQyMyA5LjA3OTM5QzE2LjQyMyA5LjI1OTI3IDE2LjQyMyA5LjQzODk5IDE2LjM3NzEgOS42MTg3TDE1Ljg3MjQgMTIuNjk3NkMxNS44MjY2IDEyLjkyMjQgMTUuNzU3OCAxMy4xMjQ3IDE1LjY4ODkgMTMuMzI3TDE1LjY0MzEgMTMuNTk2N0MxNS41NTE0IDE0LjA2ODUgMTUuMzQ0OSAxNC41MTggMTUuMDQ2NyAxNC45NDUxQzE1LjE2MTQgMTQuODMyNyAxNS4yOTkgMTQuNzIwMyAxNS4zOTA3IDE0LjYwOEMxNS43MzQ5IDE0LjE1ODQgMTUuOTY0MiAxMy42NDE2IDE2LjA1NiAxMy4xMjQ3TDE2LjU4MzUgMTAuMDQ1OFpNMTMuNTc4NSA2LjMxNTE0SDUuNTAzOTlMNS40NTgxMiA2LjU2MjM1SDEwLjA5MTdWNi40OTQ5MkgxMy44NTM4QzE0LjI0MzcgNi40OTQ5MiAxNC41ODc5IDYuNTYyMzUgMTQuOTA5IDYuNzE5NjVDMTQuNTE5IDYuNDQ5OTcgMTQuMDgzMyA2LjMxNTE0IDEzLjU3ODUgNi4zMTUxNFpNNS4zMjA0NyA2LjA5MDM5TDUuNjg3NSA0LjQ0OThINS40NTgxMkM1LjQzNTE3IDQuNDQ5OCA1LjQxMjI0IDQuNDcyMjggNS4zODkyOSA0LjQ5NDc1QzUuMzQzNDIgNC41MTcyMyA1LjM0MzQyIDQuNTYyMTggNS4zMjA0NyA0LjU4NDY0TDQuOTk5MzMgNi4wOTAzOUg1LjMyMDQ3Wk0xNy41OTI5IDMuOTc3ODZDMTcuNjg0NyAzLjQzODQ5IDE3LjYzODcgMi45NDQwNyAxNy40NTUyIDIuNDk0NTlDMTcuNDU1MiAyLjQ3MjExIDE3LjQwOTQgMi40NDk2NSAxNy40MDk0IDIuNDA0NjhDMTcuNDc4MyAyLjc2NDI3IDE3LjUwMTEgMy4xNDYzNCAxNy40MzIzIDMuNTUwODVMMTcuMjAzIDQuODk5MjhIMTcuMTM0MUwxNy4wODgzIDUuMTY4OTdIMTcuNDA5NEwxNy41OTI5IDMuOTc3ODZaIiBmaWxsPSJ3aGl0ZSIvPgo8L2c+CjxkZWZzPgo8Y2xpcFBhdGggaWQ9ImNsaXAwXzE4NzlfMTcyNzciPgo8cmVjdCB3aWR0aD0iMzEuNzQ0OSIgaGVpZ2h0PSIzNCIgZmlsbD0id2hpdGUiLz4KPC9jbGlwUGF0aD4KPC9kZWZzPgo8L3N2Zz4K' : $data['white_label_plugin_menu_icon'];
643 739
644 740 // Use 'read' capability since actual access is controlled by current_user_has_plugin_access() check above
645 741 $menu_capability = 'read';
@@ -649,15 +745,19 @@
649 745 global $menu;
650 746 $menu['100'] = array('', 'read', 'separator-metasync-before', '', 'wp-menu-separator');
651 747 }, 5);
652 748
653 - // Main menu page at position 100.1 — bottom of the menu after all standard items
749 + // Main menu page at position 100.1 — bottom of the menu after all standard items.
750 + // The top-level callback is the floor: when Settings is reachable it renders
751 + // Settings; when Settings is hidden it falls back to the first reachable
752 + // keep-list page so the menu never leads to a blank page.
753 + $top_level_callback = self::resolve_top_level_callback($admin);
654 754 add_menu_page(
655 755 $menu_name,
656 756 $menu_title,
657 757 $menu_capability,
658 758 $menu_slug,
659 - array($admin, 'create_admin_settings_page'),
759 + $top_level_callback,
660 760 $menu_icon,
661 761 '100.1'
662 762 );
663 763
@@ -671,114 +771,157 @@
671 771
672 772 // ── Connect slug ──────────────────────────────────────────────────
673 773 $connect_slug = $menu_slug . '-connect';
674 774
675 - // Dashboard
676 - if (Metasync_Access_Control::user_can_access('hide_dashboard')) {
677 - add_submenu_page($menu_slug, 'Dashboard', 'Dashboard', $menu_capability, $menu_slug . '-dashboard', array($admin, 'create_admin_dashboard_iframe'));
775 + // Keep a slug-to-title map so hidden pages can still provide WordPress
776 + // with a browser-tab title when they are registered without a parent.
777 + $admin_page_titles = array();
778 +
779 + // Local helper: register a submenu page under the plugin parent slug
780 + // when the item keeps its WordPress admin sidebar row, or under an
781 + // empty parent slug (hidden from the sidebar but still reachable)
782 + // otherwise. The Settings page (general) is registered against the
783 + // bare $menu_slug so it doubles as the parent menu callback.
784 + $register_submenu = function($item_key, $page_title, $menu_title, $capability, $menu_page_slug, $callback) use ($menu_slug, &$admin_page_titles) {
785 + $admin_page_titles[$menu_page_slug] = $page_title;
786 + $parent = self::item_keeps_wp_sidebar_row($item_key) ? $menu_slug : '';
787 + add_submenu_page($parent, $page_title, $menu_title, $capability, $menu_page_slug, $callback);
788 + };
789 +
790 + // Dashboard (check access control + "Hide Dashboard" general setting)
791 + if (Metasync_Access_Control::user_can_access('hide_dashboard') && !self::is_dashboard_hidden_by_framework()) {
792 + $register_submenu('dashboard', 'Dashboard', 'Dashboard', $menu_capability, $menu_slug . '-dashboard', array($admin, 'create_admin_dashboard_iframe'));
678 793 }
679 794
680 795 // Indexation Control
681 796 if (Metasync_Access_Control::user_can_access('hide_indexation_control')) {
682 - add_submenu_page($menu_slug, 'Indexation Control', 'Indexation Control', $menu_capability, $menu_slug . '-seo-controls', array($admin, 'create_admin_seo_controls_page'));
797 + $register_submenu('seo_controls', 'Indexation Control', 'Indexation Control', $menu_capability, $menu_slug . '-seo-controls', array($admin, 'create_admin_seo_controls_page'));
683 798 }
684 799
685 800 // 404 Monitor
686 - add_submenu_page($menu_slug, '404 Monitor', '404 Monitor', $menu_capability, $menu_slug . '-404-monitor', array($admin, 'create_admin_404_monitor_page'));
801 + $register_submenu('monitor_404', '404 Monitor', '404 Monitor', $menu_capability, $menu_slug . '-404-monitor', array($admin, 'create_admin_404_monitor_page'));
687 802
688 803 // Redirections
689 804 if (Metasync_Access_Control::user_can_access('hide_redirections')) {
690 - add_submenu_page($menu_slug, 'Redirections', 'Redirections', $menu_capability, $menu_slug . '-redirections', array($admin, 'create_admin_redirections_page'));
805 + $register_submenu('redirections', 'Redirections', 'Redirections', $menu_capability, $menu_slug . '-redirections', array($admin, 'create_admin_redirections_page'));
691 806 }
692 807
693 808 // XML Sitemap
694 809 if (Metasync_Access_Control::user_can_access('hide_xml_sitemap')) {
695 - add_submenu_page($menu_slug, 'XML Sitemap', 'XML Sitemap', $menu_capability, $menu_slug . '-xml-sitemap', array($admin, 'create_admin_xml_sitemap_page'));
810 + $register_submenu('xml_sitemap', 'XML Sitemap', 'XML Sitemap', $menu_capability, $menu_slug . '-xml-sitemap', array($admin, 'create_admin_xml_sitemap_page'));
696 811 }
697 812
698 813 // Robots.txt
699 814 if (Metasync_Access_Control::user_can_access('hide_robots')) {
700 - add_submenu_page($menu_slug, 'Robots.txt', 'Robots.txt', $menu_capability, $menu_slug . '-robots-txt', array($admin, 'create_admin_robots_txt_page'));
815 + $register_submenu('robots_txt', 'Robots.txt', 'Robots.txt', $menu_capability, $menu_slug . '-robots-txt', array($admin, 'create_admin_robots_txt_page'));
701 816 }
702 817
703 - // Site Verification
704 - add_submenu_page($menu_slug, 'Site Verification', 'Site Verification', $menu_capability, $menu_slug . '-search-engine-verify', array($admin, 'create_admin_search_engine_verification_page'));
818 + // Site Verification (in-page sidebar only in short mode)
819 + $register_submenu('site_verification', 'Site Verification', 'Site Verification', $menu_capability, $menu_slug . '-search-engine-verify', array($admin, 'create_admin_search_engine_verification_page'));
705 820
706 - // Instant Indexing (conditional)
707 - if ($seo_controls['enable_googleinstantindex'] ?? false) {
708 - add_submenu_page($menu_slug, 'Instant Indexing', 'Instant Indexing', $menu_capability, $menu_slug . '-instant-index', array($admin, 'create_admin_google_instant_index_page'));
709 - }
710 -
711 821 // Google Console (conditional)
712 822 if ($general_options['enable_google_console'] ?? false) {
713 - add_submenu_page($menu_slug, 'Google Console', 'Google Console', $menu_capability, $menu_slug . '-google-console', array($admin, 'create_admin_google_console_page'));
823 + $register_submenu('google_console', 'Google Console', 'Google Console', $menu_capability, $menu_slug . '-google-console', array($admin, 'create_admin_google_console_page'));
714 824 }
715 825
716 826 // Bing Console (conditional)
717 827 if ($seo_controls['enable_binginstantindex'] ?? false) {
718 - add_submenu_page($menu_slug, 'Bing Console', 'Bing Console', $menu_capability, $menu_slug . '-bing-console', array($admin, 'create_admin_bing_console_page'));
828 + $register_submenu('bing_console', 'Bing Console', 'Bing Console', $menu_capability, $menu_slug . '-bing-console', array($admin, 'create_admin_bing_console_page'));
719 829 }
720 830
721 831 // Schema Markup
722 - add_submenu_page($menu_slug, 'Schema Markup', 'Schema Markup', $menu_capability, $menu_slug . '-schema-markup', array($admin, 'create_admin_schema_markup_page'));
832 + $register_submenu('schema_markup', 'Schema Markup', 'Schema Markup', $menu_capability, $menu_slug . '-schema-markup', array($admin, 'create_admin_schema_markup_page'));
723 833
724 834 // Import SEO Data
725 835 if (Metasync_Access_Control::user_can_access('hide_import_seo')) {
726 - add_submenu_page($menu_slug, 'Import SEO Data', 'Import SEO Data', $menu_capability, $menu_slug . '-import-external', array($admin, 'render_import_external_data_page'));
836 + $register_submenu('import_seo', 'Import SEO Data', 'Import SEO Data', $menu_capability, $menu_slug . '-import-external', array($admin, 'render_import_external_data_page'));
727 837 }
728 838
729 - // Settings
839 + // Settings — registered against the bare $menu_slug (parent menu floor).
840 + // It always keeps its sidebar row so the plugin always has at least
841 + // one working entry point (see is_settings_reachable()).
730 842 if (Metasync_Access_Control::user_can_access('hide_settings')) {
731 - add_submenu_page($menu_slug, 'Settings', 'Settings', $menu_capability, $menu_slug, array($admin, 'create_admin_settings_page'));
843 + $register_submenu('general', 'Settings', 'Settings', $menu_capability, $menu_slug, array($admin, 'create_admin_settings_page'));
732 844 }
733 845
734 - // Local Business
735 - add_submenu_page($menu_slug, 'Local Business', 'Local Business', $menu_capability, $menu_slug . '-local-business', array($admin, 'create_admin_local_business_page'));
846 + // Local Business (in-page sidebar only in short mode)
847 + $register_submenu('local_business', 'Local Business', 'Local Business', $menu_capability, $menu_slug . '-local-business', array($admin, 'create_admin_local_business_page'));
736 848
737 - // Breadcrumbs
738 - add_submenu_page($menu_slug, 'Breadcrumbs', 'Breadcrumbs', $menu_capability, $menu_slug . '-breadcrumbs', array($admin, 'create_admin_breadcrumbs_page'));
849 + // Breadcrumbs (in-page sidebar only in short mode)
850 + $register_submenu('breadcrumbs', 'Breadcrumbs', 'Breadcrumbs', $menu_capability, $menu_slug . '-breadcrumbs', array($admin, 'create_admin_breadcrumbs_page'));
739 851
740 - // Code Snippets
741 - add_submenu_page($menu_slug, 'Code Snippets', 'Code Snippets', $menu_capability, $menu_slug . '-code-snippets', array($admin, 'create_admin_code_snippets_page'));
852 + // Code Snippets (in-page sidebar only in short mode)
853 + $register_submenu('code_snippets', 'Code Snippets', 'Code Snippets', $menu_capability, $menu_slug . '-code-snippets', array($admin, 'create_admin_code_snippets_page'));
742 854
743 - // Code Minification
744 - add_submenu_page($menu_slug, 'Code Minification', 'Code Minification', $menu_capability, $menu_slug . '-code-minification', array($admin, 'create_admin_code_minification_page'));
855 + // Code Minification (in-page sidebar only in short mode)
856 + $register_submenu('code_minification', 'Code Minification', 'Code Minification', $menu_capability, $menu_slug . '-code-minification', array($admin, 'create_admin_code_minification_page'));
745 857
858 + // Media Optimization (in-page sidebar only in short mode)
859 + $register_submenu('media_optimization', 'Media Optimization', 'Media Optimization', $menu_capability, $menu_slug . '-media-optimization', array($admin, 'create_admin_media_optimization_page'));
860 +
746 861 // Custom Pages
747 862 if (Metasync_Access_Control::user_can_access('hide_custom_pages')) {
748 - add_submenu_page($menu_slug, 'Custom Pages', 'Custom Pages', $menu_capability, $menu_slug . '-custom-pages', array($admin, 'create_admin_custom_pages_page'));
863 + $register_submenu('custom_pages', 'Custom Pages', 'Custom Pages', $menu_capability, $menu_slug . '-custom-pages', array($admin, 'create_admin_custom_pages_page'));
749 864 }
750 865
751 - // Bot Statistics
752 - add_submenu_page($menu_slug, 'Bot Statistics', 'Bot Statistics', $menu_capability, $menu_slug . '-bot-statistics', array($admin, 'create_admin_bot_statistics_page'));
866 + // Bot Statistics (in-page sidebar only in short mode)
867 + $register_submenu('bot_statistics', 'Bot Statistics', 'Bot Statistics', $menu_capability, $menu_slug . '-bot-statistics', array($admin, 'create_admin_bot_statistics_page'));
753 868
754 869 // SEO Health dashboard
755 - add_submenu_page($menu_slug, 'SEO Health', 'SEO Health', $menu_capability, $menu_slug . '-seo-health', array($admin, 'create_admin_seo_health_page'));
870 + $register_submenu('seo_health', 'SEO Health', 'SEO Health', $menu_capability, $menu_slug . '-seo-health', array($admin, 'create_admin_seo_health_page'));
756 871
757 872 // Compatibility
758 873 if (Metasync_Access_Control::user_can_access('hide_compatibility')) {
759 - add_submenu_page($menu_slug, 'Compatibility', 'Compatibility', $menu_capability, $menu_slug . '-compatibility', array($admin, 'create_admin_compatibility_page'));
874 + $register_submenu('compatibility', 'Compatibility', 'Compatibility', $menu_capability, $menu_slug . '-compatibility', array($admin, 'create_admin_compatibility_page'));
760 875 }
761 876
762 877 // Changes Log
763 878 if (Metasync_Access_Control::user_can_access('hide_sync_log')) {
764 - add_submenu_page($menu_slug, 'Changes Log', 'Changes Log', $menu_capability, $menu_slug . '-sync-log', array($admin, 'create_admin_sync_log_page'));
879 + $register_submenu('sync_log', 'Changes Log', 'Changes Log', $menu_capability, $menu_slug . '-sync-log', array($admin, 'create_admin_sync_log_page'));
765 880 }
766 881
767 882 // Report Issue
768 883 if (Metasync_Access_Control::user_can_access('hide_report_issue')) {
769 - add_submenu_page($menu_slug, 'Report Issue', 'Report Issue', $menu_capability, $menu_slug . '-report-issue', array($admin, 'create_admin_report_issue_page'));
884 + $register_submenu('report_issue', 'Report Issue', 'Report Issue', $menu_capability, $menu_slug . '-report-issue', array($admin, 'create_admin_report_issue_page'));
770 885 }
771 886
772 887 // ── Connect CTA (shown when not authenticated) ────────────────────
888 + // Kept as a sidebar row so an unconnected site always has a visible
889 + // entry point alongside the top-level menu item.
773 890 if (!$is_fully_connected) {
774 - add_submenu_page($menu_slug, 'Connect to SearchAtlas', 'Connect to SearchAtlas', $menu_capability, $connect_slug, array($admin, 'create_admin_settings_page'));
891 + $connect_label = sprintf('Connect to %s', Metasync::get_effective_plugin_name());
892 + add_submenu_page($menu_slug, $connect_label, $connect_label, $menu_capability, $connect_slug, array($admin, 'create_admin_settings_page'));
775 893 }
776 894
777 895 // ── Hidden pages (no sidebar entry needed) ────────────────────────
896 + $admin_page_titles[$menu_slug . '-setup-wizard'] = 'Setup Wizard';
778 897 add_submenu_page('', 'Setup Wizard', 'Setup Wizard', $menu_capability, $menu_slug . '-setup-wizard', array($admin->setup_wizard, 'render_wizard_page'));
779 898
899 + // WordPress cannot resolve titles for pages registered under an empty
900 + // parent, even though those pages remain reachable by URL. Restore the
901 + // standard admin title format for our registered pages only.
902 + add_filter('admin_title', function($admin_title, $title) use (&$admin_page_titles) {
903 + $current_page = isset($_GET['page']) && is_string($_GET['page']) // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page slug for the admin title
904 + ? sanitize_text_field(wp_unslash($_GET['page'])) // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page slug for the admin title
905 + : '';
780 906
907 + if (empty($title) && isset($admin_page_titles[$current_page])) {
908 + $site_title = is_network_admin() || is_user_admin()
909 + ? get_network()->site_name
910 + : get_bloginfo('name');
911 +
912 + $admin_title = sprintf(
913 + /* translators: 1: Admin page title, 2: Site or network title. */
914 + __('%1$s &lsaquo; %2$s &#8212; WordPress', 'metasync'),
915 + $admin_page_titles[$current_page],
916 + $site_title
917 + );
918 + }
919 +
920 + return $admin_title;
921 + }, 10, 2);
922 +
923 +
781 924 // Rename auto-generated first submenu from plugin name to "Settings" and reorder
782 925 add_action('admin_menu', function() use ($menu_slug) {
783 926 global $submenu;
784 927 if (!isset($submenu[$menu_slug])) {
@@ -808,9 +951,8 @@
808 951 'dashboard' => 'dashboard',
809 952 'compatibility' => 'admin-tools',
810 953 'sync_log' => 'list-view',
811 954 'seo_controls' => 'search',
812 - 'optimal_settings' => 'superhero-alt',
813 955 'instant_index' => 'performance',
814 956 'google_console' => 'chart-area',
815 957 'bing_console' => 'chart-bar',
816 958 'redirections' => 'undo',
@@ -850,9 +992,9 @@
850 992 <?php
851 993 // Dashboard tab (standalone)
852 994 if (isset($seo_items['dashboard'])) {
853 995 $is_active = ($current_page === 'dashboard');
854 - $icon = $menu_icons['dashboard'] ?? 'admin-generic';
996 + $icon = $menu_icons['dashboard'];
855 997 $page_url = '?page=' . $page_slug . $seo_items['dashboard']['slug_suffix'];
856 998 ?>
857 999 <a href="<?php echo esc_url($page_url); ?>" class="metasync-nav-tab <?php echo $is_active ? 'active' : ''; ?>">
858 1000 <span class="tab-icon"><span class="dashicons dashicons-<?php echo esc_attr($icon); ?>"></span></span>
@@ -1233,9 +1375,9 @@
1233 1375 $status_title_header = 'Not Synced - Heartbeat API not responding or unreachable';
1234 1376 $status_text_header = 'Not Synced';
1235 1377 }
1236 1378 ?>
1237 - <div class="metasync-integration-status <?php echo $status_class_header; ?>"
1379 + <div class="metasync-integration-status <?php echo esc_attr($status_class_header); ?>"
1238 1380 title="<?php echo esc_attr($status_title_header); ?>">
1239 1381 <span class="status-indicator"></span>
1240 1382 <span class="status-text"><?php echo esc_html($status_text_header); ?></span>
1241 1383 </div>
@@ -1315,10 +1457,10 @@
1315 1457
1316 1458 <?php
1317 1459 $page_slug = Metasync_Admin::$page_slug;
1318 1460 $is_plugin_page = (
1319 - (isset($_GET['page']) && strpos($_GET['page'], $page_slug) !== false) ||
1320 - (isset($_GET['page']) && strpos($_GET['page'], 'searchatlas') !== false)
1461 + (isset($_GET['page']) && strpos($_GET['page'], $page_slug) !== false) || // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check for an inline config script
1462 + (isset($_GET['page']) && strpos($_GET['page'], 'searchatlas') !== false) // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check for an inline config script
1321 1463 );
1322 1464 if ($is_plugin_page):
1323 1465 ?>
1324 1466 <script type="text/javascript">
@@ -1495,18 +1637,20 @@
1495 1637 * @param string $description Optional subtitle shown below the title.
1496 1638 */
1497 1639 public function render_layout_open($page_title = '', $current_page = '', $description = '')
1498 1640 {
1499 - $theme = esc_attr(get_option('metasync_theme', 'dark'));
1641 + $theme = get_option('metasync_theme', 'dark');
1500 1642 $plugin_name = Metasync::get_effective_plugin_name();
1501 1643 $general = Metasync::get_option('general') ?? [];
1502 1644 $is_connected = Metasync_Heartbeat_Manager::instance()->is_heartbeat_connected($general);
1645 + // badge reflects confirmed heartbeat health (connected / stale /
1646 + // disconnected), not raw API-key presence.
1647 + $badge = Metasync_Heartbeat_Manager::instance()->get_connection_badge($general);
1503 1648 $logo = $this->resolve_logo_data();
1504 1649
1505 1650 $current_theme = get_option('metasync_theme', 'dark');
1506 - $api_key = $general['searchatlas_api_key'] ?? '';
1507 1651 ?>
1508 - <div class="wrap metasync-dashboard-wrap" data-theme="<?php echo $theme; ?>">
1652 + <div class="wrap metasync-dashboard-wrap" data-theme="<?php echo esc_attr($theme); ?>">
1509 1653
1510 1654 <?php
1511 1655 // Inject whitelabel color palette overrides
1512 1656 $wl_settings = Metasync::get_whitelabel_settings();
@@ -1536,8 +1680,9 @@
1536 1680 $vars .= '--' . esc_attr($grad_var) . ':linear-gradient(135deg,' . esc_attr($from) . ' 0%,' . esc_attr($to) . ' 100%);';
1537 1681 }
1538 1682 }
1539 1683 if ($vars) {
1684 + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- CSS custom properties inside a raw <style> element; every value is a regex-validated hex color and HTML entities are not decoded in raw text elements
1540 1685 echo $selector . '{' . $vars . '}';
1541 1686 }
1542 1687 }
1543 1688 }
@@ -1557,11 +1702,11 @@
1557 1702 <strong style="font-size:15px;color:var(--dashboard-text-primary);"><?php echo esc_html($plugin_name); ?></strong>
1558 1703 <?php endif; ?>
1559 1704 </div>
1560 1705 <div class="metasync-header-compact-right">
1561 - <div class="metasync-status <?php echo !empty($api_key) ? 'connected' : 'disconnected'; ?>">
1706 + <div class="metasync-status <?php echo esc_attr($badge['class']); ?>">
1562 1707 <span class="status-dot"></span>
1563 - <span class="status-text"><?php echo !empty($api_key) ? 'Connected' : 'Not Connected'; ?></span>
1708 + <span class="status-text"><?php echo esc_html($badge['text']); ?></span>
1564 1709 </div>
1565 1710 <button type="button" class="metasync-theme-toggle" onclick="toggleMetasyncTheme()" title="Toggle theme">
1566 1711 <span class="theme-icon-light">&#9728;</span>
1567 1712 <span class="theme-icon-dark">&#9790;</span>
@@ -1578,8 +1723,17 @@
1578 1723 </aside>
1579 1724
1580 1725 <!-- Main content -->
1581 1726 <main class="metasync-layout-main">
1727 + <?php
1728 + // Anchor for WP core's notice relocation (common.js). Without it, core
1729 + // moves admin notices after the first .wrap h1 only on DOM-ready, after
1730 + // the server has already painted them at the top of #wpbody-content —
1731 + // causing a flash above the header and layout shift. Providing
1732 + // an explicit .wp-header-end inside this (FOUC-hidden) wrap makes core
1733 + // relocate notices here deterministically, so they fade in with the page.
1734 + ?>
1735 + <hr class="wp-header-end">
1582 1736 <?php if ($page_title || $description): ?>
1583 1737 <div class="metasync-page-header">
1584 1738 <?php if ($page_title): ?>
1585 1739 <h1><?php echo esc_html($page_title); ?></h1>
@@ -1603,15 +1757,19 @@
1603 1757 {
1604 1758 ?>
1605 1759 </main><!-- /.metasync-layout-main -->
1606 1760
1607 - <?php if ($show_promo):
1608 - $general = Metasync::get_option('general') ?? [];
1609 - $is_connected = Metasync_Heartbeat_Manager::instance()->is_heartbeat_connected($general);
1761 + <?php
1762 + // The sidebar exists to get an unconnected site connected. Once the
1763 + // site is linked it has nothing further to offer, so the whole
1764 + // column is dropped and the grid reflows to two columns.
1765 + $show_promo = $show_promo && ! Metasync_Heartbeat_Manager::instance()
1766 + ->is_heartbeat_connected(Metasync::get_option('general') ?? []);
1767 + if ($show_promo):
1610 1768 ?>
1611 1769 <!-- Right promo sidebar -->
1612 1770 <aside class="metasync-layout-promo">
1613 - <?php $this->render_promo_sidebar($is_connected); ?>
1771 + <?php $this->render_promo_sidebar(); ?>
1614 1772 </aside>
1615 1773 <?php endif; ?>
1616 1774
1617 1775 </div><!-- /.metasync-layout -->
@@ -1648,9 +1806,8 @@
1648 1806 'breadcrumbs' => 'menu',
1649 1807 'code_snippets' => 'editor-code',
1650 1808 'code_minification'=> 'media-code',
1651 1809 'custom_pages' => 'admin-page',
1652 - 'optimal_settings' => 'superhero-alt',
1653 1810 'compatibility' => 'admin-tools',
1654 1811 'sync_log' => 'list-view',
1655 1812 'bot_statistics' => 'visibility',
1656 1813 'report_issue' => 'sos',
@@ -1666,11 +1823,20 @@
1666 1823 } else {
1667 1824 $plugin_items[$key] = $item;
1668 1825 }
1669 1826 }
1827 +
1828 + $total_items = count($seo_items) + count($plugin_items);
1829 +
1670 1830 ?>
1671 1831 <nav class="metasync-sidenav">
1672 1832
1833 + <?php if ($total_items === 0): ?>
1834 + <div class="metasync-sidenav-empty">
1835 + <?php esc_html_e('No pages are available.', 'metasync'); ?>
1836 + </div>
1837 + <?php else: ?>
1838 +
1673 1839 <!-- SEO Features group -->
1674 1840 <div class="metasync-sidenav-group">
1675 1841 <div class="metasync-sidenav-group-title">SEO Features</div>
1676 1842 <ul>
@@ -1676,12 +1842,12 @@
1676 1842 <ul>
1677 1843 <?php foreach ($seo_items as $key => $item):
1678 1844 $is_active = ($current_page === $key);
1679 1845 $icon = $icons[$key] ?? 'admin-generic';
1680 - $url = esc_url(admin_url('admin.php?page=' . $page_slug . $item['slug_suffix']));
1846 + $url = admin_url('admin.php?page=' . $page_slug . $item['slug_suffix']);
1681 1847 ?>
1682 1848 <li class="<?php echo $is_active ? 'metasync-sidenav-active' : ''; ?>">
1683 - <a href="<?php echo $url; ?>">
1849 + <a href="<?php echo esc_url($url); ?>">
1684 1850 <span class="metasync-sidenav-icon"><span class="dashicons dashicons-<?php echo esc_attr($icon); ?>"></span></span>
1685 1851 <?php echo esc_html($item['title']); ?>
1686 1852 </a>
1687 1853 </li>
@@ -1696,12 +1862,12 @@
1696 1862 <?php foreach ($plugin_items as $key => $item):
1697 1863 if ($key === 'report_issue') continue;
1698 1864 $is_active = ($current_page === $key);
1699 1865 $icon = $icons[$key] ?? 'admin-generic';
1700 - $url = esc_url(admin_url('admin.php?page=' . $page_slug . $item['slug_suffix']));
1866 + $url = admin_url('admin.php?page=' . $page_slug . $item['slug_suffix']);
1701 1867 ?>
1702 1868 <li class="<?php echo $is_active ? 'metasync-sidenav-active' : ''; ?>">
1703 - <a href="<?php echo $url; ?>">
1869 + <a href="<?php echo esc_url($url); ?>">
1704 1870 <span class="metasync-sidenav-icon"><span class="dashicons dashicons-<?php echo esc_attr($icon); ?>"></span></span>
1705 1871 <?php echo esc_html($item['title']); ?>
1706 1872 </a>
1707 1873 </li>
@@ -1736,8 +1902,10 @@
1736 1902 </ul>
1737 1903 </div>
1738 1904 <?php endif; ?>
1739 1905
1906 + <?php endif; ?>
1907 +
1740 1908 </nav>
1741 1909 <?php
1742 1910 }
1743 1911
@@ -1743,17 +1911,22 @@
1743 1911
1744 1912 /**
1745 1913 * Render the right promotional sidebar.
1746 1914 *
1747 - * @param bool $is_connected Whether the plugin is authenticated.
1915 + * Only reached while the site is unconnected; the caller drops the
1916 + * whole column once the plugin is linked.
1748 1917 */
1749 - public function render_promo_sidebar($is_connected = false)
1918 + public function render_promo_sidebar()
1750 1919 {
1751 1920 $plugin_name = Metasync::get_effective_plugin_name();
1752 1921 $otto_name = Metasync::get_whitelabel_otto_name();
1753 - $settings_url = esc_url(admin_url('admin.php?page=' . Metasync_Admin::$page_slug));
1922 + $settings_url = admin_url('admin.php?page=' . Metasync_Admin::$page_slug);
1754 1923 $homepage = Metasync::HOMEPAGE_DOMAIN;
1755 - $is_default_brand = ( $plugin_name === 'Search Atlas' );
1924 + $docs_url = Metasync::DOCUMENTATION_DOMAIN;
1925 + // These links point at Search Atlas properties, so they only belong
1926 + // here while the plugin runs under its own name AND branding — a
1927 + // reseller can leave the name untouched and still be whitelabeled.
1928 + $is_default_brand = ( $plugin_name === 'Search Atlas' ) && ! Metasync::is_whitelabel_enabled();
1756 1929 $whitelabel = Metasync::get_whitelabel_settings();
1757 1930 $custom_links = isset($whitelabel['quick_links']) && is_array($whitelabel['quick_links'])
1758 1931 ? array_filter($whitelabel['quick_links'], function($l) { return !empty($l['url']); })
1759 1932 : [];
@@ -1758,9 +1931,8 @@
1758 1931 ? array_filter($whitelabel['quick_links'], function($l) { return !empty($l['url']); })
1759 1932 : [];
1760 1933 ?>
1761 1934
1762 - <?php if (!$is_connected): ?>
1763 1935 <!-- Connect CTA card -->
1764 1936 <div class="metasync-promo-card metasync-promo-card--connect">
1765 1937 <div class="metasync-promo-card-header">
1766 1938 <div class="metasync-promo-card-icon">
@@ -1776,40 +1948,12 @@
1776 1948 <li><span class="promo-check">&#10003;</span> Real-time keyword tracking</li>
1777 1949 <li><span class="promo-check">&#10003;</span> Automated schema markup</li>
1778 1950 <li><span class="promo-check">&#10003;</span> Instant Google indexing</li>
1779 1951 </ul>
1780 - <a href="<?php echo $settings_url; ?>" class="metasync-promo-btn metasync-promo-btn--primary">
1952 + <a href="<?php echo esc_url($settings_url); ?>" class="metasync-promo-btn metasync-promo-btn--primary">
1781 1953 Connect Now
1782 1954 </a>
1783 1955 </div>
1784 - <?php else: ?>
1785 - <!-- Connected — feature highlights -->
1786 - <div class="metasync-promo-card metasync-promo-card--accent">
1787 - <div class="metasync-promo-card-header">
1788 - <div class="metasync-promo-card-icon">
1789 - <span class="dashicons dashicons-performance"></span>
1790 - </div>
1791 - <div>
1792 - <h3><?php echo esc_html($otto_name); ?> Active</h3>
1793 - </div>
1794 - </div>
1795 - <p class="metasync-promo-tagline">Your site is connected and <?php echo esc_html($otto_name); ?> is optimizing pages automatically.</p>
1796 - <ul class="metasync-promo-benefits">
1797 - <li><span class="promo-check">&#10003;</span> Schema markup auto-applied</li>
1798 - <li><span class="promo-check">&#10003;</span> Meta titles &amp; descriptions optimized</li>
1799 - <li><span class="promo-check">&#10003;</span> Internal linking suggestions active</li>
1800 - </ul>
1801 - <?php if ($is_default_brand): ?>
1802 - <a href="<?php echo esc_url($homepage); ?>" target="_blank" rel="noopener" class="metasync-promo-btn metasync-promo-btn--outline">
1803 - View <?php echo esc_html($plugin_name); ?> Dashboard
1804 - </a>
1805 - <?php elseif (!empty($whitelabel['domain'])): ?>
1806 - <a href="<?php echo esc_url($whitelabel['domain']); ?>" target="_blank" rel="noopener" class="metasync-promo-btn metasync-promo-btn--outline">
1807 - View <?php echo esc_html($plugin_name); ?> Dashboard
1808 - </a>
1809 - <?php endif; ?>
1810 - </div>
1811 - <?php endif; ?>
1812 1956
1813 1957 <?php
1814 1958 // Quick Links: show default links for default brand, custom links if whitelabeled + provided, hide if whitelabeled + none
1815 1959 $show_quick_links = $is_default_brand || !empty($custom_links);
@@ -1819,12 +1963,20 @@
1819 1963 <div class="metasync-promo-card">
1820 1964 <h3 style="margin:0 0 12px;font-size:13px;font-weight:700;color:var(--dashboard-text-primary);">Quick Links</h3>
1821 1965 <ul class="metasync-promo-links">
1822 1966 <?php if ($is_default_brand): ?>
1967 + <li><a href="<?php echo esc_url($docs_url); ?>" target="_blank" rel="noopener"><span class="dashicons dashicons-book-alt"></span> Documentation</a></li>
1823 1968 <li><a href="<?php echo esc_url($homepage . '/blog/'); ?>" target="_blank" rel="noopener"><span class="dashicons dashicons-rss"></span> SEO Blog</a></li>
1824 - <li><a href="<?php echo esc_url($homepage . '/academy/'); ?>" target="_blank" rel="noopener"><span class="dashicons dashicons-welcome-learn-more"></span> SEO Academy</a></li>
1969 + <li><a href="<?php echo esc_url($homepage . '/contact/'); ?>" target="_blank" rel="noopener"><span class="dashicons dashicons-email-alt"></span> Contact Support</a></li>
1825 1970 <li><a href="<?php echo esc_url(admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-setup-wizard')); ?>"><span class="dashicons dashicons-admin-customizer"></span> Setup Wizard</a></li>
1971 + <?php
1972 + // Mirrors the gate that decides whether the page is registered
1973 + // at all. Without it the link outlives the page for any user
1974 + // the access rules hide it from, and leads nowhere.
1975 + if (Metasync_Access_Control::user_can_access('hide_report_issue')):
1976 + ?>
1826 1977 <li><a href="<?php echo esc_url(admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-report-issue')); ?>"><span class="dashicons dashicons-sos"></span> Report Issue</a></li>
1978 + <?php endif; ?>
1827 1979 <?php else: ?>
1828 1980 <?php foreach ($custom_links as $link): ?>
1829 1981 <li><a href="<?php echo esc_url($link['url']); ?>" <?php echo !empty($link['external']) ? 'target="_blank" rel="noopener"' : ''; ?>><span class="dashicons dashicons-admin-links"></span> <?php echo esc_html($link['label'] ?: $link['url']); ?></a></li>
1830 1982 <?php endforeach; ?>