| @@ -10,15 +10,17 @@ | ||
| 10 | 10 | use YayMailScoped\YayCommerce\AdminShell\Menu\ExternalPluginMenuAdapter; |
| 11 | 11 | use YayMailScoped\YayCommerce\AdminShell\Menu\PluginSubmenu; |
| 12 | 12 | use YayMailScoped\YayCommerce\AdminShell\Menu\MenuSuppressor; |
| 13 | 13 | use YayMailScoped\YayCommerce\AdminShell\Menu\PagesRouter; |
| 14 | +use YayMailScoped\YayCommerce\AdminShell\Menu\SubmenuPositioner; | |
| 14 | 15 | use YayMailScoped\YayCommerce\AdminShell\Menu\TopLevelMenu; |
| 15 | 16 | use YayMailScoped\YayCommerce\AdminShell\Pages\RecommendedPluginsPage; |
| 16 | 17 | use YayMailScoped\YayCommerce\AdminShell\Registry\AddonBridge; |
| 17 | 18 | use YayMailScoped\YayCommerce\AdminShell\Registry\LegacyBridge; |
| 18 | 19 | use YayMailScoped\YayCommerce\AdminShell\Registry\LicenseRegistry; |
| 20 | +use YayMailScoped\YayCommerce\AdminShell\Support\AdminContext; | |
| 19 | 21 | use YayMailScoped\YayCommerce\AdminShell\Support\Constants; |
| 20 | -\defined('ABSPATH') || exit; | |
| 22 | +defined('ABSPATH') || exit; | |
| 21 | 23 | /** |
| 22 | 24 | * Public facade — entry point for consuming plugins. |
| 23 | 25 | * |
| 24 | 26 | * Version election: when multiple scoped copies coexist, the highest |
| @@ -27,9 +29,9 @@ | ||
| 27 | 29 | */ |
| 28 | 30 | class AdminShell |
| 29 | 31 | { |
| 30 | 32 | /** Package version — used for cross-scope version election. */ |
| 31 | - const VERSION = '2.6.1'; | |
| 33 | + const VERSION = '2.8.5'; | |
| 32 | 34 | private static ?self $instance = null; |
| 33 | 35 | private static bool $booted = \false; |
| 34 | 36 | private static array $enabled_slugs = []; |
| 35 | 37 | /** The scoped prefix for THIS copy (derived from namespace). */ |
| @@ -45,9 +47,9 @@ | ||
| 45 | 47 | * Each scoped copy calls boot(). All register their version in a shared |
| 46 | 48 | * global. Actual menu/page registration is deferred to admin_menu where |
| 47 | 49 | * only the highest version runs. |
| 48 | 50 | */ |
| 49 | - public static function boot() : void | |
| 51 | + public static function boot(): void | |
| 50 | 52 | { |
| 51 | 53 | if (self::$booted) { |
| 52 | 54 | return; |
| 53 | 55 | } |
| @@ -56,19 +58,39 @@ | ||
| 56 | 58 | $instance = self::get_instance(); |
| 57 | 59 | // Derive our scoped prefix from the namespace (e.g. "YayMailScoped\YayCommerce\AdminShell" → "YayMailScoped") |
| 58 | 60 | $ns = __NAMESPACE__; |
| 59 | 61 | // YayCommerce\AdminShell or ScopedPrefix\YayCommerce\AdminShell |
| 60 | - $parts = \explode('\\', $ns); | |
| 61 | - self::$prefix = \count($parts) > 2 ? $parts[0] : 'default'; | |
| 62 | + $parts = explode('\\', $ns); | |
| 63 | + self::$prefix = count($parts) > 2 ? $parts[0] : 'default'; | |
| 62 | 64 | // Register this version in the shared global for cross-scope election |
| 63 | 65 | if (!isset($GLOBALS['yaycommerce_admin_shell_versions'])) { |
| 64 | 66 | $GLOBALS['yaycommerce_admin_shell_versions'] = []; |
| 65 | 67 | } |
| 66 | 68 | $GLOBALS['yaycommerce_admin_shell_versions'][self::$prefix] = ['version' => self::VERSION, 'registry' => $instance->registry, 'boot_cb' => [static::class, 'do_shell_registration']]; |
| 67 | - // Register the version election — only once (first copy to call boot sets it up) | |
| 68 | - if (1 === \count($GLOBALS['yaycommerce_admin_shell_versions'])) { | |
| 69 | - \add_action('admin_menu', [static::class, 'elect_version'], 8); | |
| 69 | + // Register the version election — only once (first copy to call boot sets it up). | |
| 70 | + // Bound to both admin_menu and network_admin_menu so election runs in the | |
| 71 | + // Multisite Network Admin too; only the firing hook's election actually runs. | |
| 72 | + if (1 === count($GLOBALS['yaycommerce_admin_shell_versions'])) { | |
| 73 | + AdminContext::bind_menu([static::class, 'elect_version'], 8); | |
| 74 | + // bind_menu() already wired BOTH hooks here, so mark the network election | |
| 75 | + // as done. This is load-bearing: it stops the safety net below from adding | |
| 76 | + // a SECOND network binding (elect_version is NOT idempotent — a double | |
| 77 | + // binding would run do_shell_registration twice). Only one copy ever sees | |
| 78 | + // count==1, so this path wires network at most once. | |
| 79 | + $GLOBALS['yaycommerce_network_election_wired'] = \true; | |
| 70 | 80 | } |
| 81 | + // Network Admin safety net for MIXED-VERSION installs. Older copies (≤2.6.x) | |
| 82 | + // wire the election to admin_menu ONLY — they predate network support — and the | |
| 83 | + // first-boot guard above lets whichever copy loads first (often an old one, by | |
| 84 | + // plugin folder order) own the wiring. So network_admin_menu would never get an | |
| 85 | + // election binding even when a newer copy is present. This block is NOT gated by | |
| 86 | + // that guard: any 2.7.1+ copy wires the network election exactly once (dedicated | |
| 87 | + // flag), independent of load order, so Network Admin works whenever ≥1 updated | |
| 88 | + // plugin is active. elect_version still elects the highest version as the winner. | |
| 89 | + if (empty($GLOBALS['yaycommerce_network_election_wired'])) { | |
| 90 | + $GLOBALS['yaycommerce_network_election_wired'] = \true; | |
| 91 | + add_action('network_admin_menu', [static::class, 'elect_version'], 8); | |
| 92 | + } | |
| 71 | 93 | // Legacy bridge — reads yaycommerce_licensing_plugins filter. |
| 72 | 94 | // Runs for ALL versions (uses global WP hooks, contributes to any winning registry). |
| 73 | 95 | $legacy_bridge = new LegacyBridge($instance->registry); |
| 74 | 96 | $legacy_bridge->init(); |
| @@ -81,15 +103,15 @@ | ||
| 81 | 103 | $GLOBALS['yaycommerce_ajax_handlers_registered'] = self::VERSION; |
| 82 | 104 | RecommendedPluginsPage::get_instance(); |
| 83 | 105 | } |
| 84 | 106 | } |
| 85 | - \do_action('yaycommerce_admin_shell_booted', $instance); | |
| 107 | + do_action('yaycommerce_admin_shell_booted', $instance); | |
| 86 | 108 | } |
| 87 | 109 | /** |
| 88 | 110 | * Version election — picks the highest version and runs its shell registration. |
| 89 | 111 | * Called once at admin_menu priority 8 (before TopLevelMenu at 9). |
| 90 | 112 | */ |
| 91 | - public static function elect_version() : void | |
| 113 | + public static function elect_version(): void | |
| 92 | 114 | { |
| 93 | 115 | $versions = $GLOBALS['yaycommerce_admin_shell_versions'] ?? []; |
| 94 | 116 | if (empty($versions)) { |
| 95 | 117 | return; |
| @@ -97,9 +119,9 @@ | ||
| 97 | 119 | // Find highest version |
| 98 | 120 | $winner_prefix = ''; |
| 99 | 121 | $winner_ver = '0.0.0'; |
| 100 | 122 | foreach ($versions as $prefix => $data) { |
| 101 | - if (\version_compare($data['version'], $winner_ver, '>')) { | |
| 123 | + if (version_compare($data['version'], $winner_ver, '>')) { | |
| 102 | 124 | $winner_ver = $data['version']; |
| 103 | 125 | $winner_prefix = $prefix; |
| 104 | 126 | } |
| 105 | 127 | } |
| @@ -104,16 +126,16 @@ | ||
| 104 | 126 | } |
| 105 | 127 | } |
| 106 | 128 | // Call the winner's registration — may be a different scoped class |
| 107 | 129 | $winner = $versions[$winner_prefix]; |
| 108 | - if (isset($winner['boot_cb']) && \is_callable($winner['boot_cb'])) { | |
| 109 | - \call_user_func($winner['boot_cb']); | |
| 130 | + if (isset($winner['boot_cb']) && is_callable($winner['boot_cb'])) { | |
| 131 | + call_user_func($winner['boot_cb']); | |
| 110 | 132 | } |
| 111 | 133 | } |
| 112 | 134 | /** |
| 113 | 135 | * Register menus/pages — only called by the winning version. |
| 114 | 136 | */ |
| 115 | - public static function do_shell_registration() : void | |
| 137 | + public static function do_shell_registration(): void | |
| 116 | 138 | { |
| 117 | 139 | $instance = self::get_instance(); |
| 118 | 140 | // Merge all registries into the winner's registry |
| 119 | 141 | foreach ($GLOBALS['yaycommerce_admin_shell_versions'] ?? [] as $prefix => $data) { |
| @@ -131,8 +153,12 @@ | ||
| 131 | 153 | $top_menu = new TopLevelMenu(); |
| 132 | 154 | $top_menu->init(); |
| 133 | 155 | $router = new PagesRouter($instance->registry); |
| 134 | 156 | $router->init(); |
| 157 | + // Order all submenus by declared position — runs once, only for the | |
| 158 | + // winning version, so a single authority reorders every plugin's submenu. | |
| 159 | + $positioner = new SubmenuPositioner(); | |
| 160 | + $positioner->init(); | |
| 135 | 161 | } |
| 136 | 162 | /** |
| 137 | 163 | * Register a plugin with the admin shell. |
| 138 | 164 | * Auto-detects pro vs lite via instanceof. |
| @@ -137,16 +163,28 @@ | ||
| 137 | 163 | * Register a plugin with the admin shell. |
| 138 | 164 | * Auto-detects pro vs lite via instanceof. |
| 139 | 165 | * Runs for ALL versions (not version-gated). |
| 140 | 166 | */ |
| 141 | - public static function register_plugin(PluginMenuAdapter $adapter) : void | |
| 167 | + public static function register_plugin(PluginMenuAdapter $adapter): void | |
| 142 | 168 | { |
| 143 | 169 | self::validate_adapter($adapter); |
| 170 | + // Publish this plugin's intended submenu position into a shared, | |
| 171 | + // cross-scope map (keyed by menu slug). The version-elected winner reads | |
| 172 | + // this in SubmenuPositioner to order ALL submenus after registration. | |
| 173 | + // Request-scoped: not pruned (globals don't persist between requests), | |
| 174 | + // and stale/unknown slugs are harmless — reorder() sorts them last. | |
| 175 | + $menu_slug = $adapter->get_menu_slug(); | |
| 176 | + if (!empty($menu_slug)) { | |
| 177 | + if (!isset($GLOBALS[SubmenuPositioner::POSITION_KEY])) { | |
| 178 | + $GLOBALS[SubmenuPositioner::POSITION_KEY] = []; | |
| 179 | + } | |
| 180 | + $GLOBALS[SubmenuPositioner::POSITION_KEY][$menu_slug] = $adapter->get_settings_page_position(); | |
| 181 | + } | |
| 144 | 182 | // Submenu registration — per-plugin, all versions |
| 145 | 183 | $submenu = new PluginSubmenu($adapter); |
| 146 | 184 | $submenu->init(); |
| 147 | 185 | // Action links + row meta — per-plugin, all versions |
| 148 | - if (\is_admin()) { | |
| 186 | + if (is_admin()) { | |
| 149 | 187 | self::register_plugin_links($adapter); |
| 150 | 188 | } |
| 151 | 189 | // License subsystem — per-plugin, all versions |
| 152 | 190 | if ($adapter instanceof LicenseConfigAdapter) { |
| @@ -167,9 +205,9 @@ | ||
| 167 | 205 | * not appear under the shared YayCommerce menu. |
| 168 | 206 | * |
| 169 | 207 | * @param array{parent_menu: string, menu_title: string, menu_capability: string, menu_slug: string} $config |
| 170 | 208 | */ |
| 171 | - public static function register_external_plugin_menu(array $config) : void | |
| 209 | + public static function register_external_plugin_menu(array $config): void | |
| 172 | 210 | { |
| 173 | 211 | $adapter = new ExternalPluginMenuAdapter($config); |
| 174 | 212 | $adapter->init(); |
| 175 | 213 | } |
| @@ -175,38 +213,38 @@ | ||
| 175 | 213 | } |
| 176 | 214 | /** |
| 177 | 215 | * Validate adapter values upfront. |
| 178 | 216 | */ |
| 179 | - private static function validate_adapter(PluginMenuAdapter $adapter) : void | |
| 217 | + private static function validate_adapter(PluginMenuAdapter $adapter): void | |
| 180 | 218 | { |
| 181 | 219 | $menu_title = $adapter->get_menu_title(); |
| 182 | 220 | if (empty($menu_title)) { |
| 183 | - \trigger_error('[YayCommerce AdminShell] get_menu_title() must return a non-empty string.', \E_USER_WARNING); | |
| 221 | + trigger_error('[YayCommerce AdminShell] get_menu_title() must return a non-empty string.', \E_USER_WARNING); | |
| 184 | 222 | } |
| 185 | 223 | $basename = $adapter->get_plugin_basename(); |
| 186 | 224 | if (empty($basename)) { |
| 187 | - \trigger_error('[YayCommerce AdminShell] get_plugin_basename() must return a non-empty string.', \E_USER_WARNING); | |
| 225 | + trigger_error('[YayCommerce AdminShell] get_plugin_basename() must return a non-empty string.', \E_USER_WARNING); | |
| 188 | 226 | } |
| 189 | 227 | $callback = $adapter->get_settings_page_callback(); |
| 190 | - if (null !== $callback && !\is_callable($callback)) { | |
| 191 | - \trigger_error('[YayCommerce AdminShell] get_settings_page_callback() returned a non-callable value.', \E_USER_WARNING); | |
| 228 | + if (null !== $callback && !is_callable($callback)) { | |
| 229 | + trigger_error('[YayCommerce AdminShell] get_settings_page_callback() returned a non-callable value.', \E_USER_WARNING); | |
| 192 | 230 | } |
| 193 | 231 | if ($adapter instanceof LicenseConfigAdapter) { |
| 194 | 232 | $slug = $adapter->get_plugin_slug(); |
| 195 | 233 | if (empty($slug)) { |
| 196 | - \trigger_error('[YayCommerce AdminShell] get_plugin_slug() must return a non-empty string.', \E_USER_WARNING); | |
| 234 | + trigger_error('[YayCommerce AdminShell] get_plugin_slug() must return a non-empty string.', \E_USER_WARNING); | |
| 197 | 235 | } |
| 198 | 236 | $item_id = $adapter->get_item_id(); |
| 199 | 237 | if ($item_id <= 0) { |
| 200 | - \trigger_error('[YayCommerce AdminShell] get_item_id() must return a positive integer.', \E_USER_WARNING); | |
| 238 | + trigger_error('[YayCommerce AdminShell] get_item_id() must return a positive integer.', \E_USER_WARNING); | |
| 201 | 239 | } |
| 202 | 240 | $store_url = $adapter->get_store_url(); |
| 203 | 241 | if (empty($store_url)) { |
| 204 | - \trigger_error('[YayCommerce AdminShell] get_store_url() must return a non-empty URL.', \E_USER_WARNING); | |
| 242 | + trigger_error('[YayCommerce AdminShell] get_store_url() must return a non-empty URL.', \E_USER_WARNING); | |
| 205 | 243 | } |
| 206 | 244 | $plugin_file = $adapter->get_plugin_file(); |
| 207 | 245 | if (empty($plugin_file)) { |
| 208 | - \trigger_error('[YayCommerce AdminShell] get_plugin_file() must return a non-empty path.', \E_USER_WARNING); | |
| 246 | + trigger_error('[YayCommerce AdminShell] get_plugin_file() must return a non-empty path.', \E_USER_WARNING); | |
| 209 | 247 | } |
| 210 | 248 | } |
| 211 | 249 | } |
| 212 | 250 | /** |
| @@ -212,9 +250,9 @@ | ||
| 212 | 250 | /** |
| 213 | 251 | * Enable the license subsystem for a plugin. |
| 214 | 252 | * Runs for ALL versions (not version-gated). |
| 215 | 253 | */ |
| 216 | - public static function enable_license(LicenseConfigAdapter $adapter) : void | |
| 254 | + public static function enable_license(LicenseConfigAdapter $adapter): void | |
| 217 | 255 | { |
| 218 | 256 | $slug = $adapter->get_plugin_slug(); |
| 219 | 257 | if (isset(self::$enabled_slugs[$slug])) { |
| 220 | 258 | return; |
| @@ -223,38 +261,38 @@ | ||
| 223 | 261 | $instance = self::get_instance(); |
| 224 | 262 | new LicenseHandler($adapter); |
| 225 | 263 | $info = PluginInfoFactory::from_adapter($adapter); |
| 226 | 264 | $instance->registry->register($info); |
| 227 | - \do_action('yaycommerce_admin_shell_license_enabled', $adapter); | |
| 265 | + do_action('yaycommerce_admin_shell_license_enabled', $adapter); | |
| 228 | 266 | } |
| 229 | 267 | /** |
| 230 | 268 | * Register plugin action links + row meta. |
| 231 | 269 | */ |
| 232 | - private static function register_plugin_links(PluginMenuAdapter $adapter) : void | |
| 270 | + private static function register_plugin_links(PluginMenuAdapter $adapter): void | |
| 233 | 271 | { |
| 234 | 272 | $basename = $adapter->get_plugin_basename(); |
| 235 | - \add_filter('plugin_action_links_' . $basename, function (array $links) use($adapter) { | |
| 273 | + add_filter('plugin_action_links_' . $basename, function (array $links) use ($adapter) { | |
| 236 | 274 | $new = []; |
| 237 | 275 | $menu_slug = $adapter->get_menu_slug(); |
| 238 | 276 | if (!empty($menu_slug)) { |
| 239 | - $url = \admin_url('admin.php?page=' . $menu_slug); | |
| 240 | - $new['settings'] = '<a href="' . \esc_url($url) . '">' . \esc_html($adapter->get_settings_label()) . '</a>'; | |
| 277 | + $url = admin_url('admin.php?page=' . $menu_slug); | |
| 278 | + $new['settings'] = '<a href="' . esc_url($url) . '">' . esc_html($adapter->get_settings_label()) . '</a>'; | |
| 241 | 279 | } |
| 242 | 280 | $pro_url = $adapter->get_pro_url(); |
| 243 | 281 | if (!empty($pro_url)) { |
| 244 | - $new['go-pro'] = '<a href="' . \esc_url($pro_url) . '" target="_blank" style="color:#00a32a;font-weight:700;">' . \esc_html__('Go Pro', 'yaycommerce') . '</a>'; | |
| 282 | + $new['go-pro'] = '<a href="' . esc_url($pro_url) . '" target="_blank" style="color:#00a32a;font-weight:700;">' . esc_html__('Go Pro', 'yaycommerce') . '</a>'; | |
| 245 | 283 | } |
| 246 | - return \array_merge($new, $links); | |
| 284 | + return array_merge($new, $links); | |
| 247 | 285 | }); |
| 248 | - \add_filter('plugin_row_meta', function (array $meta, string $file) use($adapter, $basename) { | |
| 286 | + add_filter('plugin_row_meta', function (array $meta, string $file) use ($adapter, $basename) { | |
| 249 | 287 | if ($file !== $basename) { |
| 250 | 288 | return $meta; |
| 251 | 289 | } |
| 252 | 290 | $docs_url = $adapter->get_docs_url(); |
| 253 | 291 | if (!empty($docs_url)) { |
| 254 | - $meta[] = '<a href="' . \esc_url($docs_url) . '" target="_blank">' . \esc_html__('Docs', 'yaycommerce') . '</a>'; | |
| 292 | + $meta[] = '<a href="' . esc_url($docs_url) . '" target="_blank">' . esc_html__('Docs', 'yaycommerce') . '</a>'; | |
| 255 | 293 | } |
| 256 | - $meta[] = '<a href="https://yaycommerce.com/support" target="_blank">' . \esc_html__('Support', 'yaycommerce') . '</a>'; | |
| 294 | + $meta[] = '<a href="https://yaycommerce.com/support" target="_blank">' . esc_html__('Support', 'yaycommerce') . '</a>'; | |
| 257 | 295 | return $meta; |
| 258 | 296 | }, 10, 2); |
| 259 | 297 | } |
| 260 | 298 | /** |
| @@ -259,13 +297,13 @@ | ||
| 259 | 297 | } |
| 260 | 298 | /** |
| 261 | 299 | * Return the shared registry. |
| 262 | 300 | */ |
| 263 | - public static function registry() : LicenseRegistry | |
| 301 | + public static function registry(): LicenseRegistry | |
| 264 | 302 | { |
| 265 | 303 | return self::get_instance()->registry; |
| 266 | 304 | } |
| 267 | - private static function get_instance() : self | |
| 305 | + private static function get_instance(): self | |
| 268 | 306 | { |
| 269 | 307 | if (null === self::$instance) { |
| 270 | 308 | self::$instance = new self(); |
| 271 | 309 | } |
| @@ -273,9 +311,9 @@ | ||
| 273 | 311 | } |
| 274 | 312 | /** |
| 275 | 313 | * Reset state — for unit tests only. |
| 276 | 314 | */ |
| 277 | - public static function reset() : void | |
| 315 | + public static function reset(): void | |
| 278 | 316 | { |
| 279 | 317 | self::$instance = null; |
| 280 | 318 | self::$booted = \false; |
| 281 | 319 | self::$enabled_slugs = []; |
| @@ -281,6 +319,8 @@ | ||
| 281 | 319 | self::$enabled_slugs = []; |
| 282 | 320 | self::$prefix = ''; |
| 283 | 321 | unset($GLOBALS['yaycommerce_admin_shell_versions']); |
| 284 | 322 | unset($GLOBALS['yaycommerce_ajax_handlers_registered']); |
| 323 | + unset($GLOBALS['yaycommerce_network_election_wired']); | |
| 324 | + unset($GLOBALS[SubmenuPositioner::POSITION_KEY]); | |
| 285 | 325 | } |
| 286 | 326 | } |