| 1 |
<?php |
| 2 |
/** |
| 3 |
* OpenStation — payload building helpers. |
| 4 |
* |
| 5 |
* Dock-item construction, native-window payload assembly, menu |
| 6 |
* payload (the data the shell shows in the dock + on bootstrap), |
| 7 |
* and the script/style handle resolvers used by the live-refresh |
| 8 |
* and lazy-load paths. |
| 9 |
* |
| 10 |
* Extracted from the 1,609-LOC `helpers.php` during the |
| 11 |
* architecture-0.8.1 PHP slicing (phase 6). Behaviour is |
| 12 |
* unchanged: every function name is identical and every WP filter |
| 13 |
* still fires with the same shape — PHP looks function references |
| 14 |
* up by name at hook-fire time, so existing callers continue to |
| 15 |
* resolve regardless of which file owns the definition. |
| 16 |
* |
| 17 |
* @package OpenStation |
| 18 |
*/ |
| 19 |
|
| 20 |
defined( 'ABSPATH' ) || exit; |
| 21 |
|
| 22 |
|
| 23 |
/** |
| 24 |
* Builds the dock items array from the admin menu data. |
| 25 |
* |
| 26 |
* Iterates through the global $menu and $submenu arrays, filters out |
| 27 |
* separators and items the current user can't access, and returns a |
| 28 |
* clean array of dock items ready for JSON serialization. |
| 29 |
* |
| 30 |
* @return array[] Array of dock item arrays, each containing: |
| 31 |
* id, title, icon, url, badge, submenu. |
| 32 |
*/ |
| 33 |
function openstation_build_dock_items() { |
| 34 |
global $menu, $submenu; |
| 35 |
|
| 36 |
if ( empty( $menu ) ) { |
| 37 |
return array(); |
| 38 |
} |
| 39 |
|
| 40 |
$items = array(); |
| 41 |
|
| 42 |
foreach ( $menu as $item ) { |
| 43 |
// Skip separators. |
| 44 |
if ( ! empty( $item[4] ) && false !== strpos( $item[4], 'wp-menu-separator' ) ) { |
| 45 |
continue; |
| 46 |
} |
| 47 |
|
| 48 |
// Skip items without a slug. |
| 49 |
if ( empty( $item[2] ) ) { |
| 50 |
continue; |
| 51 |
} |
| 52 |
|
| 53 |
// Check capability. |
| 54 |
if ( ! empty( $item[1] ) && ! current_user_can( $item[1] ) ) { |
| 55 |
continue; |
| 56 |
} |
| 57 |
|
| 58 |
// Skip menus something took out of the classic sidebar. A dock |
| 59 |
// that shows what wp-admin hides isn't a faithful mirror of the |
| 60 |
// menu, and on WordPress.com it double-renders every entry |
| 61 |
// Jetpack replaced with a Calypso link. |
| 62 |
if ( openstation_menu_item_is_hidden( $item ) ) { |
| 63 |
continue; |
| 64 |
} |
| 65 |
|
| 66 |
$title = openstation_menu_item_title( $item[0] ); |
| 67 |
|
| 68 |
// Extract badge count from the title HTML. |
| 69 |
$badge = 0; |
| 70 |
if ( preg_match( '/class="(?:update-plugins|awaiting-mod)[^"]*count-(\d+)"/', $item[0], $matches ) ) { |
| 71 |
$badge = (int) $matches[1]; |
| 72 |
} |
| 73 |
|
| 74 |
// The Plugins menu badge in `wp-admin/menu.php` is built from |
| 75 |
// `count( $update_plugins->response )` — a raw transient count |
| 76 |
// that can include orphan rows (deleted plugin files, entries |
| 77 |
// injected by third-party update servers for plugins that |
| 78 |
// aren't installed locally). Our Plugins window's "Update |
| 79 |
// available" filter only counts updates whose key intersects |
| 80 |
// `get_plugins()`, because every row in the window comes from |
| 81 |
// REST `/wp/v2/plugins` which iterates `get_plugins()`. |
| 82 |
// Recompute the dock badge from the same intersection so the |
| 83 |
// dock count always agrees with what the window shows (GH#258). |
| 84 |
if ( |
| 85 |
'plugins.php' === $item[2] && |
| 86 |
! is_multisite() && |
| 87 |
function_exists( 'openstation_plugins_window_count_visible_updates' ) |
| 88 |
) { |
| 89 |
$badge = openstation_plugins_window_count_visible_updates(); |
| 90 |
} |
| 91 |
|
| 92 |
// Determine the icon. Menu entries can set `$item[6]` to anything |
| 93 |
// — a dashicon class, a remote URL, a data:URI, 'none', or 'div' |
| 94 |
// — so normalize before we serialize it for the shell JS. |
| 95 |
// |
| 96 |
// A blanked value falls back to whatever the row carried before |
| 97 |
// anything on `admin_menu` rewrote it, which is how plugin |
| 98 |
// artwork survives Jetpack's SVG-to-stylesheet move on |
| 99 |
// WordPress.com — see `openstation_snapshot_menu_icons()`. |
| 100 |
$raw_icon = (string) ( $item[6] ?? '' ); |
| 101 |
if ( '' === $raw_icon || 'none' === $raw_icon || 'div' === $raw_icon ) { |
| 102 |
$snapshot = openstation_menu_icon_snapshot(); |
| 103 |
if ( isset( $snapshot[ $item[2] ] ) ) { |
| 104 |
$raw_icon = $snapshot[ $item[2] ]; |
| 105 |
} |
| 106 |
} |
| 107 |
$icon = openstation_sanitize_dock_icon( $raw_icon ); |
| 108 |
|
| 109 |
// Build the full URL for the menu item. |
| 110 |
// |
| 111 |
// `$parent_url` is the slug-derived URL (`admin.php?page=<slug>` |
| 112 |
// for plugin pages, the file path for Core ones). It's the |
| 113 |
// reference value the self-link strip below compares against. |
| 114 |
// The effective `$url` we ship to the shell can be rewritten |
| 115 |
// further down to the first visible submenu's URL — see the |
| 116 |
// note after the loop. |
| 117 |
$parent_url = openstation_menu_item_url( $item[2] ); |
| 118 |
$parent_external = openstation_menu_item_is_external( $parent_url ); |
| 119 |
|
| 120 |
// A menu owned by a regular plugin is allowed to keep off-site |
| 121 |
// children — a docs or support link under a plugin's own menu is |
| 122 |
// a normal thing to ship, and the flyout marks it as leaving the |
| 123 |
// site. Everything else drops them: a Core menu whose child was |
| 124 |
// repointed off-site (WordPress.com does this to Appearance → |
| 125 |
// Themes) gets its wp-admin original back instead, below. |
| 126 |
$plugin_file = openstation_resolve_menu_plugin_file( $item[2] ); |
| 127 |
$allow_external_subs = null !== $plugin_file && ! $parent_external; |
| 128 |
|
| 129 |
// Build submenu items. |
| 130 |
// |
| 131 |
// WordPress auto-prepends a self-link entry to every parent |
| 132 |
// menu's `$submenu[$slug]` (the first child shares the parent's |
| 133 |
// slug + URL — that's what `add_menu_page()` generates so the |
| 134 |
// admin UI can render a clickable parent in the sidebar). For |
| 135 |
// the shell's JS surface we strip this entry so: |
| 136 |
// |
| 137 |
// - `submenu.length === 0` reliably means "no real children" |
| 138 |
// (the right-click submenu popover stays suppressed; the |
| 139 |
// in-window tab strip stays hidden). |
| 140 |
// - `submenu.length > 0` reliably means "has real child links" |
| 141 |
// — every entry points at a distinct URL. |
| 142 |
// |
| 143 |
// Detection by URL (post-`openstation_menu_item_url()` normalize) |
| 144 |
// rather than slug equality covers plugins that register a child |
| 145 |
// at a different slug pointing at the parent's URL. |
| 146 |
// |
| 147 |
// Two passes, because the second decision depends on the first: |
| 148 |
// a `hide-if-js` row is normally noise, but when it is the |
| 149 |
// wp-admin original of an off-site row we just dropped, it is |
| 150 |
// the route back to the page Core intended. The original takes |
| 151 |
// the replacement's place in the list, so the menu reads the way |
| 152 |
// it would have if nothing had swapped the row out. |
| 153 |
$rows = array(); |
| 154 |
$restore_slots = array(); |
| 155 |
$dropped_off_site = 0; |
| 156 |
if ( ! empty( $submenu[ $item[2] ] ) ) { |
| 157 |
foreach ( $submenu[ $item[2] ] as $sub_item ) { |
| 158 |
if ( ! empty( $sub_item[1] ) && ! current_user_can( $sub_item[1] ) ) { |
| 159 |
continue; |
| 160 |
} |
| 161 |
// No `hide-if-no-customize` filter here. WordPress tags |
| 162 |
// Appearance → Customize / Header / Background with that |
| 163 |
// class; the semantics are "shown by default; hide only |
| 164 |
// when `<body class=\"no-customize-support\">`". The |
| 165 |
// Customizer is supported inside chromeless iframes, so |
| 166 |
// these entries belong in the dock. |
| 167 |
$sub_url = openstation_menu_item_url( $sub_item[2] ); |
| 168 |
$sub_external = openstation_menu_item_is_external( $sub_url ); |
| 169 |
|
| 170 |
if ( $sub_external && ! $allow_external_subs ) { |
| 171 |
++$dropped_off_site; |
| 172 |
// Leave a slot behind, in case the wp-admin row this |
| 173 |
// entry displaced is still in the list. |
| 174 |
$dropped_title = openstation_menu_item_title( $sub_item[0] ); |
| 175 |
if ( '' !== $dropped_title && ! isset( $restore_slots[ $dropped_title ] ) ) { |
| 176 |
$rows[] = array( 'restore' => $dropped_title ); |
| 177 |
$restore_slots[ $dropped_title ] = count( $rows ) - 1; |
| 178 |
} |
| 179 |
continue; |
| 180 |
} |
| 181 |
|
| 182 |
$rows[] = array( |
| 183 |
'raw_title' => $sub_item[0], |
| 184 |
'slug' => (string) $sub_item[2], |
| 185 |
'url' => $sub_url, |
| 186 |
'external' => $sub_external, |
| 187 |
'hidden' => openstation_menu_item_is_hidden( $sub_item ), |
| 188 |
); |
| 189 |
} |
| 190 |
} |
| 191 |
|
| 192 |
// Second pass. A hidden row moves into the slot its replacement |
| 193 |
// left; one whose replacement was the top-level slug itself |
| 194 |
// stays where it is (there is no slot — the menu row is not part |
| 195 |
// of this list). Every other hidden row, and every slot nothing |
| 196 |
// claimed, drops out. |
| 197 |
$restored = array(); |
| 198 |
$keep = array_fill( 0, count( $rows ), true ); |
| 199 |
foreach ( $rows as $i => $row ) { |
| 200 |
if ( isset( $row['restore'] ) || ! $row['hidden'] ) { |
| 201 |
continue; |
| 202 |
} |
| 203 |
$keep[ $i ] = false; |
| 204 |
$row_title = openstation_menu_item_title( $row['raw_title'] ); |
| 205 |
if ( '' === $row_title || isset( $restored[ $row_title ] ) ) { |
| 206 |
continue; |
| 207 |
} |
| 208 |
if ( isset( $restore_slots[ $row_title ] ) ) { |
| 209 |
$rows[ $restore_slots[ $row_title ] ] = $row; |
| 210 |
$restored[ $row_title ] = true; |
| 211 |
} elseif ( $parent_external && $row_title === $title ) { |
| 212 |
// The menu's own row, hidden in place. WordPress builds |
| 213 |
// a parent's self-link by copying the menu row's first |
| 214 |
// four fields, so its label is the menu's label, which |
| 215 |
// is what makes the comparison hold. |
| 216 |
$keep[ $i ] = true; |
| 217 |
$restored[ $row_title ] = true; |
| 218 |
} |
| 219 |
} |
| 220 |
|
| 221 |
// Last resort for a menu whose own slug points off-site: if |
| 222 |
// nothing on-site survived, take the first hidden on-site row |
| 223 |
// rather than lose the menu. The label comparison above is the |
| 224 |
// precise answer and covers the ordinary case, but it breaks the |
| 225 |
// moment a host relabels the menu row without relabelling the |
| 226 |
// self-link it already generated. Showing a row someone hid |
| 227 |
// beats dropping a working menu off the dock. |
| 228 |
if ( $parent_external ) { |
| 229 |
$has_on_site = false; |
| 230 |
foreach ( $rows as $i => $row ) { |
| 231 |
if ( ! isset( $row['restore'] ) && $keep[ $i ] && ! $row['external'] ) { |
| 232 |
$has_on_site = true; |
| 233 |
break; |
| 234 |
} |
| 235 |
} |
| 236 |
if ( ! $has_on_site ) { |
| 237 |
foreach ( $rows as $i => $row ) { |
| 238 |
if ( isset( $row['restore'] ) || ! $row['hidden'] || $row['external'] ) { |
| 239 |
continue; |
| 240 |
} |
| 241 |
$keep[ $i ] = true; |
| 242 |
break; |
| 243 |
} |
| 244 |
} |
| 245 |
} |
| 246 |
|
| 247 |
$kept_rows = array(); |
| 248 |
foreach ( $rows as $i => $row ) { |
| 249 |
if ( isset( $row['restore'] ) || ! $keep[ $i ] ) { |
| 250 |
continue; |
| 251 |
} |
| 252 |
$kept_rows[] = $row; |
| 253 |
} |
| 254 |
$rows = $kept_rows; |
| 255 |
|
| 256 |
// When the top-level slug itself points off-site, the menu's |
| 257 |
// identity is now whichever child survived — adopt it before the |
| 258 |
// self-link strip runs, so a restored original collapses into |
| 259 |
// `selfLabel` instead of becoming a child that duplicates its |
| 260 |
// own parent. |
| 261 |
// |
| 262 |
// Identity travels with it. Everything below keys off the menu's |
| 263 |
// slug — whether it's a Core menu, whether a plugin owns it, |
| 264 |
// whether it opens more than one window, and which slug the |
| 265 |
// `openstation_dock_item` filter is told about. Left on the |
| 266 |
// off-site slug, a rescued Plugins tile reads as a plugin menu |
| 267 |
// owned by whoever registered the replacement, sorts to the far |
| 268 |
// end of the dock, and offers to deactivate them. |
| 269 |
$identity_slug = (string) $item[2]; |
| 270 |
if ( $parent_external ) { |
| 271 |
foreach ( $rows as $row ) { |
| 272 |
if ( ! $row['external'] ) { |
| 273 |
$parent_url = $row['url']; |
| 274 |
$identity_slug = $row['slug']; |
| 275 |
break; |
| 276 |
} |
| 277 |
} |
| 278 |
} |
| 279 |
|
| 280 |
// A menu that only ever pointed at its children, and whose |
| 281 |
// children we just took away. Checked only for menus the |
| 282 |
// off-site rule actually touched, so a menu registering its page |
| 283 |
// hook in some way we don't recognise is left exactly as it was. |
| 284 |
$parent_is_container = $dropped_off_site > 0 |
| 285 |
&& ! $parent_external |
| 286 |
&& ! openstation_menu_slug_has_page( $item[2] ); |
| 287 |
|
| 288 |
$url = $parent_url; |
| 289 |
$sub_items = array(); |
| 290 |
$first_visible_sub_url = null; |
| 291 |
$has_self_link = false; |
| 292 |
$self_label = ''; |
| 293 |
foreach ( $rows as $row ) { |
| 294 |
$sub_url = $row['url']; |
| 295 |
if ( $parent_is_container && $sub_url === $parent_url ) { |
| 296 |
// A row pointing back at a menu with no page is a dead |
| 297 |
// end, not a way back — it can't name the menu and it |
| 298 |
// can't stand in for it. |
| 299 |
continue; |
| 300 |
} |
| 301 |
// Capture the first capability-passing submenu URL so |
| 302 |
// we can use it as the parent's effective URL below |
| 303 |
// (mirrors `wp-admin/menu-header.php`). Captured BEFORE |
| 304 |
// the self-link strip so plugins whose first submenu IS |
| 305 |
// the auto-prepended self-link land on the parent URL |
| 306 |
// (a no-op rewrite — preserves existing behavior). Never |
| 307 |
// an off-site child, which would take the whole tile with |
| 308 |
// it when the final external check runs. |
| 309 |
if ( null === $first_visible_sub_url && ! $row['external'] ) { |
| 310 |
$first_visible_sub_url = $sub_url; |
| 311 |
} |
| 312 |
// Self-link strip — `$sub_url === $parent_url` covers |
| 313 |
// WP's auto-prepended entry AND any plugin-registered |
| 314 |
// alias that happens to land on the parent URL. |
| 315 |
if ( $sub_url === $parent_url ) { |
| 316 |
$has_self_link = true; |
| 317 |
// Keep its LABEL, though. The stripped entry is a |
| 318 |
// real row in wp-admin's own menu ("All Posts", |
| 319 |
// "All Pages"), and the constellation flyout lists |
| 320 |
// it as the first thing the menu opens — a list of |
| 321 |
// a menu's pages that omits its main page reads as |
| 322 |
// a bug. |
| 323 |
// |
| 324 |
// Carried separately rather than left in `submenu` |
| 325 |
// because `submenu` has two other consumers that |
| 326 |
// need it to mean "distinct child links only": the |
| 327 |
// in-window tab strip, which would grow a duplicate |
| 328 |
// first tab, and the right-click popover, which is |
| 329 |
// suppressed on `length === 0`. |
| 330 |
// |
| 331 |
// First one only — a plugin can register several |
| 332 |
// aliases onto the parent URL, and the canonical |
| 333 |
// self-link is the one WordPress prepends. |
| 334 |
if ( '' === $self_label ) { |
| 335 |
$self_label = openstation_menu_item_title( $row['raw_title'] ); |
| 336 |
} |
| 337 |
continue; |
| 338 |
} |
| 339 |
// Skip entries with no resolvable title. Plugins (e.g. |
| 340 |
// WooCommerce's `wc-addons` Extensions row) register |
| 341 |
// `menu_title => null` to hide a row from classic admin's |
| 342 |
// left menu while keeping the page reachable. Without |
| 343 |
// this guard the dock renders an empty, label-less tab |
| 344 |
// that visually duplicates a sibling entry. |
| 345 |
$sub_title = openstation_menu_item_title( $row['raw_title'] ); |
| 346 |
if ( '' === $sub_title ) { |
| 347 |
continue; |
| 348 |
} |
| 349 |
$sub_entry = array( |
| 350 |
'title' => $sub_title, |
| 351 |
'url' => $sub_url, |
| 352 |
); |
| 353 |
if ( $row['external'] ) { |
| 354 |
// Consumers that route a URL into a window skip these; |
| 355 |
// the ones that can hand a link to the browser mark |
| 356 |
// them as leaving the site. |
| 357 |
// |
| 358 |
// `offSite` rather than `external`: the window's tab |
| 359 |
// strip already calls plugin-opened sub-iframe tabs |
| 360 |
// "external" (`data-kind="external"`), and that is a |
| 361 |
// different thing entirely. |
| 362 |
$sub_entry['offSite'] = true; |
| 363 |
} |
| 364 |
$sub_items[] = $sub_entry; |
| 365 |
} |
| 366 |
|
| 367 |
// Mirror `wp-admin/menu-header.php`: when a parent menu has any |
| 368 |
// visible submenu, classic admin rewrites the parent's |
| 369 |
// clickable URL to the first submenu's URL. Plugins like |
| 370 |
// WooCommerce rely on this — their top-level slug |
| 371 |
// (`woocommerce`) has no working callback and 500s when hit |
| 372 |
// directly. The real landing page is the first submenu |
| 373 |
// (`?page=wc-admin` for WC). Without this rewrite the dock |
| 374 |
// icon points users at a broken URL that classic admin would |
| 375 |
// never have linked to. |
| 376 |
// |
| 377 |
// A menu that registered a self-link has a working page of its |
| 378 |
// own and keeps it, wherever in the list that link sits. Only |
| 379 |
// the WooCommerce shape — no self-link at all — needs a child to |
| 380 |
// stand in. Position matters here because a restored wp-admin |
| 381 |
// row inherits the slot its off-site replacement held, which on |
| 382 |
// WordPress.com puts `plugin-install.php` first under Plugins. |
| 383 |
if ( null !== $first_visible_sub_url && ! $has_self_link ) { |
| 384 |
$url = $first_visible_sub_url; |
| 385 |
} |
| 386 |
|
| 387 |
// Nothing on this menu resolves to a page we can open. Hosts |
| 388 |
// that link their own control panel from the admin menu |
| 389 |
// (WordPress.com's My Home, Theme Showcase, Hosting) land here, |
| 390 |
// and so does a Core menu whose slug was repointed off-site with |
| 391 |
// no wp-admin child left to fall back to. |
| 392 |
if ( openstation_menu_item_is_external( $url ) ) { |
| 393 |
continue; |
| 394 |
} |
| 395 |
|
| 396 |
// A container menu with nothing left to stand in for it. Its |
| 397 |
// URL resolves to core's "Cannot load <slug>." page, which is a |
| 398 |
// worse tile than no tile. |
| 399 |
if ( $parent_is_container && $url === $parent_url ) { |
| 400 |
continue; |
| 401 |
} |
| 402 |
|
| 403 |
$dock_item = array( |
| 404 |
'id' => sanitize_key( $item[5] ?? $item[2] ), |
| 405 |
'title' => $title, |
| 406 |
'icon' => $icon, |
| 407 |
'url' => $url, |
| 408 |
'badge' => $badge, |
| 409 |
'submenu' => $sub_items, |
| 410 |
// Label of the stripped self-link ("All Posts"), for |
| 411 |
// surfaces that list a menu's pages and want its main page |
| 412 |
// named the way wp-admin names it. Empty when the menu had |
| 413 |
// no self-link to strip. |
| 414 |
'selfLabel' => $self_label, |
| 415 |
'multi' => openstation_dock_item_is_multi( $identity_slug ), |
| 416 |
'placement' => openstation_dock_placement( $identity_slug ), |
| 417 |
'isCore' => openstation_is_core_menu_slug( $identity_slug ), |
| 418 |
'pluginFile' => $identity_slug === (string) $item[2] |
| 419 |
? $plugin_file |
| 420 |
: openstation_resolve_menu_plugin_file( $identity_slug ), |
| 421 |
'pluginName' => null, |
| 422 |
); |
| 423 |
if ( $dock_item['pluginFile'] ) { |
| 424 |
$dock_item['pluginName'] = openstation_plugin_display_name( $dock_item['pluginFile'] ); |
| 425 |
} |
| 426 |
|
| 427 |
/** |
| 428 |
* Filters a single dock item's data. |
| 429 |
* |
| 430 |
* @param array $dock_item The dock item data. |
| 431 |
* @param string $menu_slug The menu slug. |
| 432 |
*/ |
| 433 |
$dock_item = apply_filters( 'openstation_dock_item', $dock_item, $identity_slug ); |
| 434 |
|
| 435 |
$items[] = $dock_item; |
| 436 |
} |
| 437 |
|
| 438 |
/** |
| 439 |
* Filters the dock items before they are passed to JavaScript. |
| 440 |
* |
| 441 |
* @param array[] $items Array of dock item arrays. |
| 442 |
*/ |
| 443 |
return apply_filters( 'openstation_dock_items', $items ); |
| 444 |
} |
| 445 |
|
| 446 |
/** |
| 447 |
* Whether a resolved menu URL points at a host other than this site's. |
| 448 |
* |
| 449 |
* OpenStation opens admin pages inside iframes, and an off-site URL |
| 450 |
* cannot load in one — the remote origin's `X-Frame-Options` / |
| 451 |
* `frame-ancestors` header refuses it. Hosts that extend the admin |
| 452 |
* menu with links to their own control panel (WordPress.com registers |
| 453 |
* My Home, Theme Showcase, Hosting and friends as `wordpress.com` |
| 454 |
* URLs) would therefore fill the dock with tiles that can only ever |
| 455 |
* escape to a browser tab, which breaks the shell's navigation model. |
| 456 |
* Those entries are dropped from the payload instead. |
| 457 |
* |
| 458 |
* `self_admin_url()`, `admin_url()` and `home_url()` hosts all count as |
| 459 |
* ours: a site can run its admin on a different domain than its front |
| 460 |
* end, and the network admin lives on the network's own. |
| 461 |
* |
| 462 |
* @param string $url Absolute URL, as returned by `openstation_menu_item_url()`. |
| 463 |
* @return bool True when the URL is off-site. |
| 464 |
*/ |
| 465 |
function openstation_menu_item_is_external( $url ) { |
| 466 |
$host = wp_parse_url( (string) $url, PHP_URL_HOST ); |
| 467 |
$external = false; |
| 468 |
|
| 469 |
if ( $host ) { |
| 470 |
$ours = array(); |
| 471 |
foreach ( array( self_admin_url(), admin_url(), home_url() ) as $known ) { |
| 472 |
$known_host = wp_parse_url( $known, PHP_URL_HOST ); |
| 473 |
if ( $known_host ) { |
| 474 |
$ours[] = strtolower( $known_host ); |
| 475 |
} |
| 476 |
} |
| 477 |
$external = ! in_array( strtolower( $host ), $ours, true ); |
| 478 |
} |
| 479 |
|
| 480 |
/** |
| 481 |
* Filters whether an admin-menu URL counts as off-site. |
| 482 |
* |
| 483 |
* @param bool $external Whether the URL points off-site. |
| 484 |
* @param string $url The resolved menu URL. |
| 485 |
*/ |
| 486 |
return (bool) apply_filters( 'openstation_menu_item_is_external', $external, $url ); |
| 487 |
} |
| 488 |
|
| 489 |
/** |
| 490 |
* Whether a `$menu` / `$submenu` row carries the `hide-if-js` class. |
| 491 |
* |
| 492 |
* Core never sets it on a menu row, so it reads as "some other code |
| 493 |
* took this entry out of the sidebar". Jetpack's admin-menu |
| 494 |
* customisation on WordPress.com uses it heavily: rather than replace |
| 495 |
* a Core entry with its wordpress.com counterpart, it marks the |
| 496 |
* original `hide-if-js` and appends a duplicate pointing at Calypso. |
| 497 |
* Honouring the class is what keeps those pairs from rendering twice |
| 498 |
* in the dock. |
| 499 |
* |
| 500 |
* @param array $item A `$menu` or `$submenu` row. |
| 501 |
* @return bool True when the row is hidden from the classic sidebar. |
| 502 |
*/ |
| 503 |
function openstation_menu_item_is_hidden( $item ) { |
| 504 |
return ! empty( $item[4] ) && false !== strpos( (string) $item[4], 'hide-if-js' ); |
| 505 |
} |
| 506 |
|
| 507 |
/** |
| 508 |
* Whether a top-level menu slug has a page of its own behind it. |
| 509 |
* |
| 510 |
* `add_menu_page()` accepts a `null` callback, which registers a menu |
| 511 |
* that is nothing but a container for its children — WordPress links |
| 512 |
* such a parent to its first submenu and `admin.php` refuses the slug |
| 513 |
* directly with "Cannot load <slug>." WordPress.com's Upgrades menu is |
| 514 |
* one: `paid-upgrades.php` has no callback and no self-link, and every |
| 515 |
* child is a wordpress.com URL. Drop the children and the tile is left |
| 516 |
* pointing at core's error page. |
| 517 |
* |
| 518 |
* Two ways a slug earns a page: it names a real file under `wp-admin/`, |
| 519 |
* or something is listening on its page hook — the same `has_action()` |
| 520 |
* test `get_plugin_page_hook()` makes before `admin.php` gives up. |
| 521 |
* Anything we can't answer counts as a page, so an unusual registration |
| 522 |
* costs a menu nothing. |
| 523 |
* |
| 524 |
* @param string $slug The menu slug from `$menu[$i][2]`. |
| 525 |
* @return bool False only when the slug is provably a container. |
| 526 |
*/ |
| 527 |
function openstation_menu_slug_has_page( $slug ) { |
| 528 |
if ( openstation_is_admin_file_slug( $slug ) ) { |
| 529 |
return true; |
| 530 |
} |
| 531 |
|
| 532 |
if ( ! function_exists( 'get_plugin_page_hookname' ) ) { |
| 533 |
return true; |
| 534 |
} |
| 535 |
|
| 536 |
$hookname = get_plugin_page_hookname( $slug, '' ); |
| 537 |
if ( empty( $hookname ) ) { |
| 538 |
return true; |
| 539 |
} |
| 540 |
|
| 541 |
return has_action( $hookname ); |
| 542 |
} |
| 543 |
|
| 544 |
/** |
| 545 |
* Lazy accessor for the pre-rewrite menu icon snapshot: `slug → icon`. |
| 546 |
* |
| 547 |
* Populated by {@see openstation_snapshot_menu_icons()}. |
| 548 |
* |
| 549 |
* @return array<string,string> |
| 550 |
*/ |
| 551 |
function &openstation_menu_icon_snapshot() { |
| 552 |
static $map = null; |
| 553 |
if ( null === $map ) { |
| 554 |
$map = array(); |
| 555 |
} |
| 556 |
return $map; |
| 557 |
} |
| 558 |
|
| 559 |
/** |
| 560 |
* Record the first real icon each menu row is seen wearing. |
| 561 |
* |
| 562 |
* A menu row's icon is not final when it is registered. Anything on |
| 563 |
* `admin_menu` can rewrite `$menu[ $i ][6]`, and the rewrite that hurts |
| 564 |
* is to `'none'` — the row keeps its picture in the sidebar, painted |
| 565 |
* from a stylesheet instead, and the menu array stops carrying it. The |
| 566 |
* dock reads the array, so those menus arrived wearing a generic gear. |
| 567 |
* Jetpack's `override_svg_icons()` does this to every SVG-data-URI icon |
| 568 |
* on WordPress.com, which is where it was found, but nothing about the |
| 569 |
* move is specific to that host. |
| 570 |
* |
| 571 |
* Rather than sit at one priority chosen to undercut one known rewriter, |
| 572 |
* sample repeatedly and **never overwrite**: the map keeps the earliest |
| 573 |
* real icon each slug had, whenever it appeared and whoever blanked it |
| 574 |
* afterwards. Write-once is safe because the map is only ever consulted |
| 575 |
* as a fallback — a menu that genuinely changes its icon still ships the |
| 576 |
* live value. |
| 577 |
* |
| 578 |
* A slug that had no real icon at any sample point is simply absent, and |
| 579 |
* the caller lands on the generic fallback it would have had anyway. |
| 580 |
*/ |
| 581 |
function openstation_snapshot_menu_icons() { |
| 582 |
global $menu; |
| 583 |
|
| 584 |
if ( ! is_array( $menu ) ) { |
| 585 |
return; |
| 586 |
} |
| 587 |
|
| 588 |
$map = &openstation_menu_icon_snapshot(); |
| 589 |
|
| 590 |
foreach ( $menu as $item ) { |
| 591 |
if ( empty( $item[2] ) || empty( $item[6] ) ) { |
| 592 |
continue; |
| 593 |
} |
| 594 |
$slug = (string) $item[2]; |
| 595 |
if ( isset( $map[ $slug ] ) ) { |
| 596 |
continue; |
| 597 |
} |
| 598 |
$icon = (string) $item[6]; |
| 599 |
if ( 'none' === $icon || 'div' === $icon ) { |
| 600 |
continue; |
| 601 |
} |
| 602 |
$map[ $slug ] = $icon; |
| 603 |
} |
| 604 |
} |
| 605 |
// Spread across the hook rather than parked just below any one |
| 606 |
// rewriter: registrations and rewrites both happen at arbitrary |
| 607 |
// priorities, and only a sample taken before a given rewrite can see |
| 608 |
// what it overwrote. |
| 609 |
foreach ( array( 11, 100, 1000, 99998, PHP_INT_MAX ) as $openstation_icon_snapshot_priority ) { |
| 610 |
add_action( 'admin_menu', 'openstation_snapshot_menu_icons', $openstation_icon_snapshot_priority ); |
| 611 |
} |
| 612 |
unset( $openstation_icon_snapshot_priority ); |
| 613 |
|
| 614 |
/** |
| 615 |
* Sanitizes a dock icon value for safe injection into the shell JS. |
| 616 |
* |
| 617 |
* Menu items can set their icon to one of: |
| 618 |
* |
| 619 |
* - A Dashicons class (e.g. `dashicons-admin-post`) |
| 620 |
* - An http/https URL pointing at an image asset |
| 621 |
* - A `data:image/svg+xml;base64,…` URI (common for plugins that |
| 622 |
* ship inline vector art — Jetpack, WooCommerce, etc.). Rendered |
| 623 |
* as a CSS background-image, where per-spec SVG script content |
| 624 |
* does not execute, so the surface is safe. |
| 625 |
* - `'none'` or `'div'` (CSS hooks, no icon asset). The dock's JS |
| 626 |
* layer extracts the real icon from the hidden `#adminmenu` DOM |
| 627 |
* for these cases. |
| 628 |
* |
| 629 |
* Inline SVG data URIs (`data:image/svg+xml;base64,…` and |
| 630 |
* `data:image/svg+xml,…`) are also accepted because that's how the |
| 631 |
* vast majority of WP plugins ship their menu icon — Yoast, |
| 632 |
* WooCommerce, Jetpack, Elementor, et al. all register `$menu[$i][6]` |
| 633 |
* as an SVG data URI. Other `data:` schemes (`data:text/html`, |
| 634 |
* `data:application/javascript`, …) and raw `javascript:` / `vbscript:` |
| 635 |
* / `file:` schemes remain rejected. The shell renders the SVG via a |
| 636 |
* CSS `background-image`, which (per the modern browser security model |
| 637 |
* shared with `<img>`) sandboxes scripts inside the SVG so they do not |
| 638 |
* execute. |
| 639 |
* |
| 640 |
* The return value is always a string safe to drop into an `img.src`, |
| 641 |
* a CSS class, or a CSS `url()` background without further escaping. |
| 642 |
* |
| 643 |
* @param mixed $icon Raw icon value from the menu registration. |
| 644 |
* @return string Sanitized icon string. |
| 645 |
*/ |
| 646 |
function openstation_sanitize_dock_icon( $icon ) { |
| 647 |
$fallback = 'dashicons-admin-generic'; |
| 648 |
if ( ! is_string( $icon ) || '' === $icon ) { |
| 649 |
return $fallback; |
| 650 |
} |
| 651 |
|
| 652 |
$icon = trim( $icon ); |
| 653 |
|
| 654 |
if ( 'none' === $icon || 'div' === $icon ) { |
| 655 |
return $fallback; |
| 656 |
} |
| 657 |
|
| 658 |
if ( 0 === strpos( $icon, 'dashicons-' ) ) { |
| 659 |
// Allow only the safe subset of characters a Dashicons class can |
| 660 |
// contain — prevents class-attribute break-out via spaces or |
| 661 |
// quotes if a plugin registers a malicious "dashicons-…" value. |
| 662 |
return preg_replace( '/[^a-z0-9_-]/', '', $icon ); |
| 663 |
} |
| 664 |
|
| 665 |
// http/https URL — the icon is a hosted image. |
| 666 |
if ( 0 === stripos( $icon, 'http://' ) || 0 === stripos( $icon, 'https://' ) ) { |
| 667 |
$clean = esc_url_raw( $icon, array( 'http', 'https' ) ); |
| 668 |
return $clean ? $clean : $fallback; |
| 669 |
} |
| 670 |
|
| 671 |
// `data:image/svg+xml` — the canonical inline-icon shape WordPress |
| 672 |
// plugins use for their admin-menu icon (`$menu[$i][6]`). Two valid |
| 673 |
// payload encodings: base64 (`;base64,<base64>`) and URL-encoded |
| 674 |
// (`,<percent-encoded>`). Reject everything outside the SVG MIME so |
| 675 |
// `data:text/html` and `data:application/javascript` still bounce. |
| 676 |
// |
| 677 |
// Strict whole-string regex — no embedded whitespace, no smuggled |
| 678 |
// quotes, no second `data:` prefix. Case-insensitive on the scheme |
| 679 |
// alone since `Data:` and `DATA:` are syntactically valid but the |
| 680 |
// payload portion stays case-sensitive (base64 alphabet is). |
| 681 |
if ( 0 === stripos( $icon, 'data:image/svg+xml' ) ) { |
| 682 |
if ( |
| 683 |
preg_match( '#^data:image/svg\+xml;base64,[A-Za-z0-9+/=]+$#i', $icon ) |
| 684 |
|| preg_match( '#^data:image/svg\+xml,[A-Za-z0-9._~!$&\'()*+,;=:@/?%-]+$#i', $icon ) |
| 685 |
) { |
| 686 |
return $icon; |
| 687 |
} |
| 688 |
// Malformed SVG data URI — fall through to fallback rather than |
| 689 |
// pass a half-validated string through to the renderer. |
| 690 |
} |
| 691 |
|
| 692 |
return $fallback; |
| 693 |
} |
| 694 |
|
| 695 |
/** |
| 696 |
* Decides whether a given admin page should support multiple open windows. |
| 697 |
* |
| 698 |
* List-style screens (Posts, Pages, custom post types, Media, Users, |
| 699 |
* Comments, taxonomy terms) often benefit from being open more than once: |
| 700 |
* a writer may want to read one post while drafting another, compare two |
| 701 |
* users side-by-side, pick media from one window and drop it into a draft |
| 702 |
* in another. Singleton-ish screens (Dashboard, Settings, Tools, Profile) |
| 703 |
* have a single logical state — opening two makes no sense. |
| 704 |
* |
| 705 |
* The default rule matches the base filename of the menu slug against a |
| 706 |
* known list. Plugin authors can override via the |
| 707 |
* `openstation_dock_item_multi` filter to mark any custom page as multi |
| 708 |
* (or force a stock list page into singleton mode). |
| 709 |
* |
| 710 |
* @param string $menu_slug The raw menu slug (e.g. `edit.php`, `upload.php`, |
| 711 |
* or `my-plugin-page`). Query strings are preserved |
| 712 |
* so `edit.php?post_type=page` resolves correctly. |
| 713 |
* @return bool True if this page supports multiple simultaneous windows. |
| 714 |
*/ |
| 715 |
function openstation_dock_item_is_multi( $menu_slug ) { |
| 716 |
// Multi-capable admin files. Match by the base file regardless of |
| 717 |
// any query string (post_type, taxonomy, page, paged, etc.) so every |
| 718 |
// CPT and every taxonomy inherits the same rule as their parent. |
| 719 |
$multi_files = array( |
| 720 |
'edit.php', |
| 721 |
'edit-tags.php', |
| 722 |
'upload.php', |
| 723 |
'users.php', |
| 724 |
'edit-comments.php', |
| 725 |
); |
| 726 |
|
| 727 |
$base = strtok( (string) $menu_slug, '?' ); |
| 728 |
$multi = in_array( $base, $multi_files, true ); |
| 729 |
|
| 730 |
/** |
| 731 |
* Filters whether a dock item supports multiple open windows. |
| 732 |
* |
| 733 |
* Return true to let the user open more than one window of this page. |
| 734 |
* A "+" affordance appears on the dock icon and a "Open another" action |
| 735 |
* becomes available in the window's title-bar menu. Singletons (false) |
| 736 |
* always focus the existing window when re-opened. |
| 737 |
* |
| 738 |
* @param bool $multi Whether this page is multi-capable. |
| 739 |
* @param string $menu_slug The menu slug (e.g. `edit.php?post_type=page`). |
| 740 |
*/ |
| 741 |
return (bool) apply_filters( 'openstation_dock_item_multi', $multi, $menu_slug ); |
| 742 |
} |
| 743 |
|
| 744 |
/** |
| 745 |
* Returns true when `$menu_slug` maps to a first-party WordPress |
| 746 |
* Core admin menu item (Dashboard, Posts, Pages, Media, Settings, |
| 747 |
* etc.), false otherwise. The caller uses the answer as an ordering |
| 748 |
* hint — core items are placed ahead of plugin items in the |
| 749 |
* unified dock rail. |
| 750 |
* |
| 751 |
* The rule: |
| 752 |
* |
| 753 |
* 1. Any known core admin filename (index.php, edit.php, upload.php, |
| 754 |
* themes.php, plugins.php, users.php, tools.php, options-*.php, |
| 755 |
* edit-comments.php, etc.) is Core. |
| 756 |
* 2. Any Custom Post Type route (`edit.php?post_type=…`) is Core — |
| 757 |
* CPTs are content-oriented even when a plugin registers them, |
| 758 |
* so they belong next to Posts / Pages in the dock. |
| 759 |
* 3. Every `admin.php?page=*` route is Plugin — that's WP's |
| 760 |
* universal "a plugin registered its own top-level admin route" |
| 761 |
* signal. |
| 762 |
* 4. Anything else is treated as Plugin (safer default — plugins |
| 763 |
* with custom top-level files can still opt in via the filter |
| 764 |
* below). |
| 765 |
* |
| 766 |
* Plugins + site admins can override any answer via |
| 767 |
* `openstation_dock_placement`: |
| 768 |
* |
| 769 |
* ```php |
| 770 |
* // Keep Jetpack on the left dock: |
| 771 |
* add_filter( 'openstation_dock_placement', function ( $placement, $slug ) { |
| 772 |
* return 'jetpack' === $slug ? 'dock' : $placement; |
| 773 |
* }, 10, 2 ); |
| 774 |
* ``` |
| 775 |
* |
| 776 |
* @param string $menu_slug Menu item slug (e.g. `edit.php`, `edit.php?post_type=foo`, `woocommerce`). |
| 777 |
* @return bool True when the slug is a core admin page. |
| 778 |
*/ |
| 779 |
function openstation_is_core_menu_slug( $menu_slug ) { |
| 780 |
$slug = (string) $menu_slug; |
| 781 |
$base = strtok( $slug, '?' ); |
| 782 |
|
| 783 |
// Known top-level core admin files. Stable across WP versions — |
| 784 |
// additions happen maybe once a release, removals almost never. |
| 785 |
$core_files = array( |
| 786 |
'index.php', // Dashboard |
| 787 |
'edit.php', // Posts (+ CPTs via ?post_type=) |
| 788 |
'edit-comments.php', // Comments |
| 789 |
'upload.php', // Media |
| 790 |
'edit-tags.php', // Taxonomies |
| 791 |
'term.php', // Single-term edit |
| 792 |
'post-new.php', // New post form |
| 793 |
'post.php', // Edit-post form |
| 794 |
'themes.php', // Appearance |
| 795 |
'nav-menus.php', // Menus (Appearance > Menus) |
| 796 |
'widgets.php', // Widgets (Appearance > Widgets) |
| 797 |
'customize.php', // Customizer |
| 798 |
'plugins.php', // Plugins |
| 799 |
'plugin-install.php', // Plugins > Add New |
| 800 |
'plugin-editor.php', // Plugins > Editor |
| 801 |
'users.php', // Users |
| 802 |
'user-new.php', // Users > Add New |
| 803 |
'profile.php', // Profile |
| 804 |
'user-edit.php', // Edit another user |
| 805 |
'tools.php', // Tools |
| 806 |
'import.php', // Tools > Import |
| 807 |
'export.php', // Tools > Export |
| 808 |
'site-health.php', // Tools > Site Health |
| 809 |
'export-personal-data.php', |
| 810 |
'erase-personal-data.php', |
| 811 |
'options-general.php', // Settings |
| 812 |
'options-writing.php', // Settings > Writing |
| 813 |
'options-reading.php', // Settings > Reading |
| 814 |
'options-discussion.php', // Settings > Discussion |
| 815 |
'options-media.php', // Settings > Media |
| 816 |
'options-permalink.php', // Settings > Permalinks |
| 817 |
'options-privacy.php', // Settings > Privacy |
| 818 |
'link-manager.php', // Link manager (legacy) |
| 819 |
'update-core.php', // Dashboard > Updates |
| 820 |
); |
| 821 |
|
| 822 |
// The two top-level network menus the site admin has no filename |
| 823 |
// for: without them, Sites and Settings sat in the apps zone while |
| 824 |
// Dashboard, Users, Themes and Plugins — whose filenames the site |
| 825 |
// admin shares — grouped correctly. Gated on the context, since |
| 826 |
// `settings.php` is plausible enough as a plugin's own top-level |
| 827 |
// slug that claiming it everywhere would misfile it. |
| 828 |
if ( is_network_admin() ) { |
| 829 |
$core_files[] = 'sites.php'; |
| 830 |
$core_files[] = 'settings.php'; |
| 831 |
} |
| 832 |
|
| 833 |
return in_array( $base, $core_files, true ); |
| 834 |
} |
| 835 |
|
| 836 |
/** |
| 837 |
* Resolve the plugin file (e.g. `woocommerce/woocommerce.php`) that owns |
| 838 |
* a given top-level admin menu slug, by reflecting on the callbacks |
| 839 |
* registered for the menu's page hook. |
| 840 |
* |
| 841 |
* Returns the plugin's main file path (relative to `WP_PLUGIN_DIR`) when |
| 842 |
* the menu was registered by a regular plugin, `null` otherwise. Core |
| 843 |
* menus, mu-plugins, drop-ins, theme-registered menus, and OpenStation |
| 844 |
* itself all return `null` — none of these are deactivatable through the |
| 845 |
* `wp/v2/plugins` REST route, so the dock right-click menu should not |
| 846 |
* offer a deactivate action for them. |
| 847 |
* |
| 848 |
* Resolution algorithm: |
| 849 |
* |
| 850 |
* 1. Skip core menu slugs outright — `plugins.php`, `edit.php?post_type=…`, |
| 851 |
* etc. are never owned by a deactivatable plugin. |
| 852 |
* 2. Compute the page hookname via `get_plugin_page_hookname()` and read |
| 853 |
* `$wp_filter[ $hookname ]->callbacks`. This is the action list WP |
| 854 |
* walks to render the menu's body — the plugin's own render callback |
| 855 |
* lives here. |
| 856 |
* 3. Reflect each callback to find its declaring file. Match the file |
| 857 |
* path against `WP_PLUGIN_DIR/<folder>/…` and use `<folder>` to look |
| 858 |
* up an entry in `get_plugins()`. Return the matching `<folder>/<file>.php`. |
| 859 |
* 4. Exclude OpenStation itself — deactivating from inside the shell |
| 860 |
* is handled by the plugins-window's self-deactivate path. |
| 861 |
* |
| 862 |
* @param string $menu_slug The menu slug from `$menu[$i][2]` (e.g. `woocommerce`, |
| 863 |
* `admin.php?page=jetpack`, `edit.php?post_type=foo`). |
| 864 |
* @return string|null Plugin file path relative to `WP_PLUGIN_DIR`, or null |
| 865 |
* when the slug isn't owned by a deactivatable plugin. |
| 866 |
*/ |
| 867 |
function openstation_resolve_menu_plugin_file( $menu_slug ) { |
| 868 |
$slug = (string) $menu_slug; |
| 869 |
|
| 870 |
// `get_plugin_page_hookname` + `get_plugins` come from |
| 871 |
// `wp-admin/includes/plugin.php`, which Core loads itself on |
| 872 |
// every admin request. The resolver only runs in admin context |
| 873 |
// (called during `admin_enqueue_scripts` and the `_admin_menu` |
| 874 |
// tracker), so the symbols are always available. Bail rather |
| 875 |
// than `require_once` something that's Core's job to load. |
| 876 |
if ( ! function_exists( 'get_plugin_page_hookname' ) || ! function_exists( 'get_plugins' ) ) { |
| 877 |
return null; |
| 878 |
} |
| 879 |
|
| 880 |
$self_basename = defined( 'OPENSTATION_FILE' ) ? plugin_basename( OPENSTATION_FILE ) : ''; |
| 881 |
|
| 882 |
// Strategy 1 — registration-time attribution. The admin_menu hook |
| 883 |
// wrapper (see `openstation_install_menu_attribution_tracker`) snapshots |
| 884 |
// `$menu`/`$submenu` around every admin_menu callback and records |
| 885 |
// "this plugin file added this slug". This is the authoritative |
| 886 |
// source — it captures menus whose page hook isn't predictable from |
| 887 |
// the slug (e.g. WC's `wc-admin&path=/marketing`) and handles |
| 888 |
// callbacks that simply forward to a shared renderer (which |
| 889 |
// reflection would mis-attribute). |
| 890 |
$map = openstation_menu_attribution_map(); |
| 891 |
if ( isset( $map[ $slug ] ) ) { |
| 892 |
$plugin_file = $map[ $slug ]; |
| 893 |
if ( $self_basename && $plugin_file === $self_basename ) { |
| 894 |
return null; |
| 895 |
} |
| 896 |
return $plugin_file; |
| 897 |
} |
| 898 |
|
| 899 |
// Strategy 2 — CPT / taxonomy registration tracker. Core's `edit.php` |
| 900 |
// / `edit-tags.php` handle the render, so the page hook would never |
| 901 |
// point at the registering plugin. We caught the plugin at |
| 902 |
// `register_post_type()` / `register_taxonomy()` time via |
| 903 |
// `debug_backtrace()`. |
| 904 |
$tracked = openstation_lookup_taxonomy_or_post_type_plugin_file( $slug ); |
| 905 |
if ( null !== $tracked ) { |
| 906 |
if ( $self_basename && $tracked === $self_basename ) { |
| 907 |
return null; |
| 908 |
} |
| 909 |
return $tracked; |
| 910 |
} |
| 911 |
|
| 912 |
$base = strtok( $slug, '?' ); |
| 913 |
|
| 914 |
// Cheap reject: literal core PHP files with no `?page=` parameter |
| 915 |
// (the universal "a plugin registered an admin route" signal). We |
| 916 |
// can't reuse `openstation_is_core_menu_slug()` here — that |
| 917 |
// classifier strtok's the query string and treats `admin.php?page=foo` |
| 918 |
// as core, which would hide every plugin-registered top-level tile. |
| 919 |
if ( openstation_is_pure_core_file( $base ) && false === strpos( $slug, '?page=' ) ) { |
| 920 |
return null; |
| 921 |
} |
| 922 |
|
| 923 |
// Strategy 3 — page-hook reflection fallback. The earlier strategies |
| 924 |
// can miss when a plugin is loaded after admin_menu has fired (rare), |
| 925 |
// or when the menu was injected by a non-admin_menu pathway. Reflect |
| 926 |
// on `$wp_filter[$hookname]` to find the callback's declaring file |
| 927 |
// and map it back to an active plugin. |
| 928 |
global $wp_filter; |
| 929 |
$hookname = get_plugin_page_hookname( $slug, '' ); |
| 930 |
if ( empty( $hookname ) || empty( $wp_filter[ $hookname ] ) ) { |
| 931 |
return null; |
| 932 |
} |
| 933 |
|
| 934 |
$hook = $wp_filter[ $hookname ]; |
| 935 |
foreach ( $hook->callbacks as $cbs ) { |
| 936 |
foreach ( $cbs as $cb ) { |
| 937 |
$plugin_file = openstation_plugin_file_for_callback( $cb['function'] ?? null ); |
| 938 |
if ( ! $plugin_file ) { |
| 939 |
continue; |
| 940 |
} |
| 941 |
if ( $self_basename && $plugin_file === $self_basename ) { |
| 942 |
return null; |
| 943 |
} |
| 944 |
return $plugin_file; |
| 945 |
} |
| 946 |
} |
| 947 |
|
| 948 |
return null; |
| 949 |
} |
| 950 |
|
| 951 |
/** |
| 952 |
* Look up the human-readable display name for a plugin file. Returns |
| 953 |
* the plugin folder name as a last-resort fallback if `get_plugins()` |
| 954 |
* has no entry (extremely rare — would mean the plugin file isn't |
| 955 |
* installed but somehow registered a menu). |
| 956 |
* |
| 957 |
* @param string $plugin_file Plugin file relative to `WP_PLUGIN_DIR`. |
| 958 |
* @return string Display name. |
| 959 |
*/ |
| 960 |
function openstation_plugin_display_name( $plugin_file ) { |
| 961 |
if ( ! function_exists( 'get_plugins' ) ) { |
| 962 |
$dir = strtok( $plugin_file, '/' ); |
| 963 |
return $dir ? $dir : $plugin_file; |
| 964 |
} |
| 965 |
$installed = get_plugins(); |
| 966 |
if ( isset( $installed[ $plugin_file ]['Name'] ) && '' !== $installed[ $plugin_file ]['Name'] ) { |
| 967 |
return (string) $installed[ $plugin_file ]['Name']; |
| 968 |
} |
| 969 |
$folder = strtok( $plugin_file, '/' ); |
| 970 |
return $folder ? $folder : $plugin_file; |
| 971 |
} |
| 972 |
|
| 973 |
/** |
| 974 |
* Map an arbitrary filesystem path inside `WP_PLUGIN_DIR` to the |
| 975 |
* corresponding plugin file in `get_plugins()`. Returns null when the |
| 976 |
* path isn't under the plugins directory, or doesn't match any active |
| 977 |
* plugin folder. |
| 978 |
* |
| 979 |
* @param string $file Absolute filesystem path. |
| 980 |
* @return string|null Plugin file (`<folder>/<file>.php`) or null. |
| 981 |
*/ |
| 982 |
function openstation_plugin_file_for_path( $file ) { |
| 983 |
if ( ! is_string( $file ) || '' === $file ) { |
| 984 |
return null; |
| 985 |
} |
| 986 |
$plugins_dir = wp_normalize_path( WP_PLUGIN_DIR ); |
| 987 |
$norm = wp_normalize_path( $file ); |
| 988 |
if ( 0 !== strpos( $norm, $plugins_dir . '/' ) ) { |
| 989 |
return null; |
| 990 |
} |
| 991 |
if ( ! function_exists( 'get_plugins' ) ) { |
| 992 |
return null; |
| 993 |
} |
| 994 |
$installed = get_plugins(); |
| 995 |
|
| 996 |
$rel = ltrim( substr( $norm, strlen( $plugins_dir ) ), '/' ); |
| 997 |
$folder = ( false !== strpos( $rel, '/' ) ) ? strtok( $rel, '/' ) : ''; |
| 998 |
|
| 999 |
foreach ( $installed as $plugin_file => $_data ) { |
| 1000 |
if ( '' !== $folder && 0 === strpos( $plugin_file, $folder . '/' ) ) { |
| 1001 |
return $plugin_file; |
| 1002 |
} |
| 1003 |
if ( '' === $folder && $plugin_file === $rel ) { |
| 1004 |
return $plugin_file; |
| 1005 |
} |
| 1006 |
} |
| 1007 |
return null; |
| 1008 |
} |
| 1009 |
|
| 1010 |
/** |
| 1011 |
* Convenience wrapper: reflect on a callback to find its declaring |
| 1012 |
* file, then map that file to an active plugin via |
| 1013 |
* {@see openstation_plugin_file_for_path()}. |
| 1014 |
* |
| 1015 |
* @param mixed $callback A WP-style callback. |
| 1016 |
* @return string|null Plugin file or null. |
| 1017 |
*/ |
| 1018 |
function openstation_plugin_file_for_callback( $callback ) { |
| 1019 |
$file = openstation_callback_source_file( $callback ); |
| 1020 |
return $file ? openstation_plugin_file_for_path( $file ) : null; |
| 1021 |
} |
| 1022 |
|
| 1023 |
/** |
| 1024 |
* Lazy accessor + lazy initializer for the registration-time menu |
| 1025 |
* attribution map: `slug → plugin_file`. The map is populated by the |
| 1026 |
* wrapped admin_menu callbacks installed by |
| 1027 |
* {@see openstation_install_menu_attribution_tracker()}. |
| 1028 |
* |
| 1029 |
* @return array<string,string> |
| 1030 |
*/ |
| 1031 |
function &openstation_menu_attribution_map() { |
| 1032 |
static $map = null; |
| 1033 |
if ( null === $map ) { |
| 1034 |
$map = array(); |
| 1035 |
} |
| 1036 |
return $map; |
| 1037 |
} |
| 1038 |
|
| 1039 |
/** |
| 1040 |
* Install admin_menu callback wrappers that record which plugin file |
| 1041 |
* registered each `$menu` / `$submenu` slug. |
| 1042 |
* |
| 1043 |
* Approach: |
| 1044 |
* |
| 1045 |
* 1. Hooked on `_admin_menu` priority `-PHP_INT_MAX`, just before |
| 1046 |
* `admin_menu` fires. |
| 1047 |
* 2. Walk `$wp_filter['admin_menu']->callbacks`. For each callback, |
| 1048 |
* reflect on the function to find its declaring file → plugin |
| 1049 |
* file. If the callback doesn't live in `WP_PLUGIN_DIR`, leave it |
| 1050 |
* alone (Core's own callbacks). |
| 1051 |
* 3. Replace the callback in-place with a closure that snapshots |
| 1052 |
* `$menu` and `$submenu` keys, invokes the original, then diffs |
| 1053 |
* the globals. Every new top-level slug and every new submenu |
| 1054 |
* entry gets attributed to that plugin file. |
| 1055 |
* |
| 1056 |
* This is the source of truth for plugin → menu ownership because it |
| 1057 |
* captures menus regardless of slug shape, hook name predictability, |
| 1058 |
* or whether the plugin shares a render callback. Reflection on the |
| 1059 |
* page hook (in `openstation_resolve_menu_plugin_file`) is now a |
| 1060 |
* fallback for the rare cases where the tracker wasn't able to install |
| 1061 |
* in time. |
| 1062 |
* |
| 1063 |
* Idempotent — runs at most once per request via a static `$installed` |
| 1064 |
* flag. |
| 1065 |
* |
| 1066 |
* @return void |
| 1067 |
*/ |
| 1068 |
function openstation_install_menu_attribution_tracker() { |
| 1069 |
static $installed = false; |
| 1070 |
if ( $installed ) { |
| 1071 |
return; |
| 1072 |
} |
| 1073 |
$installed = true; |
| 1074 |
|
| 1075 |
global $wp_filter; |
| 1076 |
if ( empty( $wp_filter['admin_menu'] ) ) { |
| 1077 |
return; |
| 1078 |
} |
| 1079 |
$hook = $wp_filter['admin_menu']; |
| 1080 |
|
| 1081 |
foreach ( $hook->callbacks as $priority => $cbs ) { |
| 1082 |
foreach ( $cbs as $id => $cb ) { |
| 1083 |
$orig = $cb['function'] ?? null; |
| 1084 |
$plugin_file = openstation_plugin_file_for_callback( $orig ); |
| 1085 |
if ( ! $plugin_file || ! is_callable( $orig ) ) { |
| 1086 |
continue; |
| 1087 |
} |
| 1088 |
$accepted_args = (int) ( $cb['accepted_args'] ?? 1 ); |
| 1089 |
|
| 1090 |
$wrapper = static function () use ( $orig, $plugin_file ) { |
| 1091 |
global $menu, $submenu; |
| 1092 |
|
| 1093 |
$before_top_slugs = array(); |
| 1094 |
if ( is_array( $menu ) ) { |
| 1095 |
foreach ( $menu as $entry ) { |
| 1096 |
if ( isset( $entry[2] ) ) { |
| 1097 |
$before_top_slugs[ (string) $entry[2] ] = true; |
| 1098 |
} |
| 1099 |
} |
| 1100 |
} |
| 1101 |
$before_submenu_keys = is_array( $submenu ) ? array_keys( $submenu ) : array(); |
| 1102 |
$before_submenu_sigs = array(); |
| 1103 |
if ( is_array( $submenu ) ) { |
| 1104 |
foreach ( $submenu as $parent => $children ) { |
| 1105 |
$sigs = array(); |
| 1106 |
foreach ( (array) $children as $child ) { |
| 1107 |
if ( isset( $child[2] ) ) { |
| 1108 |
$sigs[ (string) $child[2] ] = true; |
| 1109 |
} |
| 1110 |
} |
| 1111 |
$before_submenu_sigs[ $parent ] = $sigs; |
| 1112 |
} |
| 1113 |
} |
| 1114 |
|
| 1115 |
$args = func_get_args(); |
| 1116 |
$return = call_user_func_array( $orig, $args ); |
| 1117 |
|
| 1118 |
$map = &openstation_menu_attribution_map(); |
| 1119 |
|
| 1120 |
if ( is_array( $menu ) ) { |
| 1121 |
foreach ( $menu as $entry ) { |
| 1122 |
if ( ! isset( $entry[2] ) ) { |
| 1123 |
continue; |
| 1124 |
} |
| 1125 |
$slug = (string) $entry[2]; |
| 1126 |
if ( ! isset( $before_top_slugs[ $slug ] ) && ! isset( $map[ $slug ] ) ) { |
| 1127 |
$map[ $slug ] = $plugin_file; |
| 1128 |
} |
| 1129 |
} |
| 1130 |
} |
| 1131 |
|
| 1132 |
if ( is_array( $submenu ) ) { |
| 1133 |
foreach ( $submenu as $parent => $children ) { |
| 1134 |
$prev_sigs = $before_submenu_sigs[ $parent ] ?? array(); |
| 1135 |
foreach ( (array) $children as $child ) { |
| 1136 |
if ( ! isset( $child[2] ) ) { |
| 1137 |
continue; |
| 1138 |
} |
| 1139 |
$slug = (string) $child[2]; |
| 1140 |
if ( isset( $prev_sigs[ $slug ] ) ) { |
| 1141 |
continue; |
| 1142 |
} |
| 1143 |
if ( ! isset( $map[ $slug ] ) ) { |
| 1144 |
$map[ $slug ] = $plugin_file; |
| 1145 |
} |
| 1146 |
// Also attribute the parent if it isn't |
| 1147 |
// already attributed and Core doesn't own it. |
| 1148 |
// Lets a submenu-only plugin (registered |
| 1149 |
// under a Core parent like `tools.php`) be |
| 1150 |
// resolvable too. |
| 1151 |
} |
| 1152 |
if ( |
| 1153 |
! in_array( $parent, $before_submenu_keys, true ) |
| 1154 |
&& ! isset( $map[ $parent ] ) |
| 1155 |
) { |
| 1156 |
$map[ $parent ] = $plugin_file; |
| 1157 |
} |
| 1158 |
} |
| 1159 |
} |
| 1160 |
|
| 1161 |
return $return; |
| 1162 |
}; |
| 1163 |
|
| 1164 |
// Preserve the `accepted_args` metadata so callbacks |
| 1165 |
// expecting parameters from `do_action_ref_array()` still |
| 1166 |
// receive them. The wrapper uses `func_get_args()` so it |
| 1167 |
// forwards everything. |
| 1168 |
$wp_filter['admin_menu']->callbacks[ $priority ][ $id ] = array( |
| 1169 |
'function' => $wrapper, |
| 1170 |
'accepted_args' => $accepted_args, |
| 1171 |
); |
| 1172 |
} |
| 1173 |
} |
| 1174 |
} |
| 1175 |
|
| 1176 |
add_action( '_admin_menu', 'openstation_install_menu_attribution_tracker', -PHP_INT_MAX ); |
| 1177 |
add_action( '_network_admin_menu', 'openstation_install_menu_attribution_tracker', -PHP_INT_MAX ); |
| 1178 |
add_action( '_user_admin_menu', 'openstation_install_menu_attribution_tracker', -PHP_INT_MAX ); |
| 1179 |
|
| 1180 |
/** |
| 1181 |
* The subset of `openstation_is_core_menu_slug`'s "core files" that's |
| 1182 |
* actually owned by Core regardless of any query string — this is what |
| 1183 |
* we use inside the plugin-file resolver to reject Posts / Pages / etc. |
| 1184 |
* without rejecting `admin.php?page=…` (a universal plugin signal that |
| 1185 |
* the public is_core classifier also incorrectly treats as core for |
| 1186 |
* legacy reasons we don't want to disturb). |
| 1187 |
* |
| 1188 |
* The list intentionally drops `admin.php` so plugin-registered |
| 1189 |
* top-level pages can still be resolved. |
| 1190 |
* |
| 1191 |
* @param string $base Slug with query string already stripped. |
| 1192 |
* @return bool True when the base filename is a Core admin handler. |
| 1193 |
*/ |
| 1194 |
function openstation_is_pure_core_file( $base ) { |
| 1195 |
$core_files = array( |
| 1196 |
'index.php', |
| 1197 |
'edit-comments.php', |
| 1198 |
'upload.php', |
| 1199 |
'term.php', |
| 1200 |
'post-new.php', |
| 1201 |
'post.php', |
| 1202 |
'themes.php', |
| 1203 |
'nav-menus.php', |
| 1204 |
'widgets.php', |
| 1205 |
'customize.php', |
| 1206 |
'plugins.php', |
| 1207 |
'plugin-install.php', |
| 1208 |
'plugin-editor.php', |
| 1209 |
'users.php', |
| 1210 |
'user-new.php', |
| 1211 |
'profile.php', |
| 1212 |
'user-edit.php', |
| 1213 |
'tools.php', |
| 1214 |
'import.php', |
| 1215 |
'export.php', |
| 1216 |
'site-health.php', |
| 1217 |
'export-personal-data.php', |
| 1218 |
'erase-personal-data.php', |
| 1219 |
'options-general.php', |
| 1220 |
'options-writing.php', |
| 1221 |
'options-reading.php', |
| 1222 |
'options-discussion.php', |
| 1223 |
'options-media.php', |
| 1224 |
'options-permalink.php', |
| 1225 |
'options-privacy.php', |
| 1226 |
'link-manager.php', |
| 1227 |
'update-core.php', |
| 1228 |
); |
| 1229 |
return in_array( $base, $core_files, true ); |
| 1230 |
} |
| 1231 |
|
| 1232 |
/** |
| 1233 |
* Resolve a CPT / taxonomy URL slug (`edit.php?post_type=X` or |
| 1234 |
* `edit-tags.php?taxonomy=Y`) to the plugin file that registered the |
| 1235 |
* type. The mapping is built lazily on `init` by capturing the |
| 1236 |
* filename of whichever code called `register_post_type()` / |
| 1237 |
* `register_taxonomy()` for non-builtin types. |
| 1238 |
* |
| 1239 |
* Returns null when the slug isn't a CPT / taxonomy URL, when the |
| 1240 |
* registered type is builtin, or when the registrant lives outside |
| 1241 |
* `WP_PLUGIN_DIR` (theme-registered or mu-plugin). |
| 1242 |
* |
| 1243 |
* @param string $slug Menu slug. |
| 1244 |
* @return string|null Plugin file or null. |
| 1245 |
*/ |
| 1246 |
function openstation_lookup_taxonomy_or_post_type_plugin_file( $slug ) { |
| 1247 |
if ( false !== strpos( $slug, 'edit.php?' ) && false !== strpos( $slug, 'post_type=' ) ) { |
| 1248 |
$qs = wp_parse_url( 'http://x/' . ltrim( $slug, '/' ), PHP_URL_QUERY ); |
| 1249 |
parse_str( (string) $qs, $args ); |
| 1250 |
$pt = isset( $args['post_type'] ) ? (string) $args['post_type'] : ''; |
| 1251 |
if ( '' === $pt ) { |
| 1252 |
return null; |
| 1253 |
} |
| 1254 |
$file = openstation_type_registrant_file( $pt, 'post_type' ); |
| 1255 |
return null === $file ? null : openstation_plugin_file_for_path( $file ); |
| 1256 |
} |
| 1257 |
if ( false !== strpos( $slug, 'edit-tags.php?' ) && false !== strpos( $slug, 'taxonomy=' ) ) { |
| 1258 |
$qs = wp_parse_url( 'http://x/' . ltrim( $slug, '/' ), PHP_URL_QUERY ); |
| 1259 |
parse_str( (string) $qs, $args ); |
| 1260 |
$tx = isset( $args['taxonomy'] ) ? (string) $args['taxonomy'] : ''; |
| 1261 |
if ( '' === $tx ) { |
| 1262 |
return null; |
| 1263 |
} |
| 1264 |
$file = openstation_type_registrant_file( $tx, 'taxonomy' ); |
| 1265 |
return null === $file ? null : openstation_plugin_file_for_path( $file ); |
| 1266 |
} |
| 1267 |
return null; |
| 1268 |
} |
| 1269 |
|
| 1270 |
/** |
| 1271 |
* Lazy accessor for the CPT/taxonomy → registering-file map. The map is |
| 1272 |
* populated by `openstation_record_type_registrant()` (hooked on |
| 1273 |
* `registered_post_type` / `registered_taxonomy`, which fire during |
| 1274 |
* `init`), so by the time the dock payload is built — on |
| 1275 |
* `admin_enqueue_scripts`, well after `init` — every non-builtin type |
| 1276 |
* registered from an extension has an entry. Stored in a static so |
| 1277 |
* repeated lookups during a single request don't trigger the populator |
| 1278 |
* twice. |
| 1279 |
* |
| 1280 |
* Values are **absolute filesystem paths**, not plugin files. Core does |
| 1281 |
* not load `wp-admin/includes/plugin.php` (where `get_plugins()` lives) |
| 1282 |
* until `wp-admin/admin.php` runs it *after* `wp-load.php` has already |
| 1283 |
* fired `init` — so a plugin file cannot be resolved at record time. |
| 1284 |
* Callers resolve the path lazily instead: |
| 1285 |
* `openstation_lookup_taxonomy_or_post_type_plugin_file()` for the |
| 1286 |
* dock's plugin attribution, and the My WordPress group resolver for |
| 1287 |
* the plugin / mu-plugin / theme split. |
| 1288 |
* |
| 1289 |
* @return array{post_type: array<string,string>, taxonomy: array<string,string>} |
| 1290 |
*/ |
| 1291 |
function &openstation_get_typed_registrant_map() { |
| 1292 |
static $map = null; |
| 1293 |
if ( null === $map ) { |
| 1294 |
$map = array( |
| 1295 |
'post_type' => array(), |
| 1296 |
'taxonomy' => array(), |
| 1297 |
); |
| 1298 |
} |
| 1299 |
return $map; |
| 1300 |
} |
| 1301 |
|
| 1302 |
/** |
| 1303 |
* Read the recorded registering file for a CPT or taxonomy. |
| 1304 |
* |
| 1305 |
* @param string $type Type name (CPT or taxonomy). |
| 1306 |
* @param string $kind Either `'post_type'` or `'taxonomy'`. |
| 1307 |
* @return string|null Absolute normalized path, or null when unrecorded. |
| 1308 |
*/ |
| 1309 |
function openstation_type_registrant_file( $type, $kind ) { |
| 1310 |
$map = openstation_get_typed_registrant_map(); |
| 1311 |
return $map[ $kind ][ $type ] ?? null; |
| 1312 |
} |
| 1313 |
|
| 1314 |
/** |
| 1315 |
* Whether this request will ever read the CPT / taxonomy attribution |
| 1316 |
* map, and is therefore worth paying a `debug_backtrace()` per |
| 1317 |
* non-builtin type registration to build it. |
| 1318 |
* |
| 1319 |
* Only admin-side surfaces consume it: the dock payload (built on |
| 1320 |
* `admin_enqueue_scripts`) and the site window's section list (built |
| 1321 |
* on `init`, admin only). A front-end page view registers exactly the |
| 1322 |
* same types — WooCommerce alone brings several — and would pay the |
| 1323 |
* whole cost for a map nothing reads. |
| 1324 |
* |
| 1325 |
* The predecessor of this function got the same effect by accident: |
| 1326 |
* it bailed when `get_plugins()` was undefined, which is every |
| 1327 |
* front-end request. That guard went away when the resolution moved to |
| 1328 |
* lazy path recording, so the gate is now explicit. |
| 1329 |
* |
| 1330 |
* @return bool |
| 1331 |
*/ |
| 1332 |
function openstation_should_track_type_registrants() { |
| 1333 |
$track = is_admin(); |
| 1334 |
|
| 1335 |
/** |
| 1336 |
* Filter whether to record which extension registered each CPT and |
| 1337 |
* taxonomy this request. |
| 1338 |
* |
| 1339 |
* The map drives the dock's "Deactivate <plugin>" action and the |
| 1340 |
* site window's plugin folders. Return true on a front-end request |
| 1341 |
* only if something there reads it — building it costs one bounded |
| 1342 |
* backtrace per non-builtin type registration. |
| 1343 |
* |
| 1344 |
* **Status: Experimental** |
| 1345 |
* |
| 1346 |
* @param bool $track Default: admin requests only. |
| 1347 |
*/ |
| 1348 |
return (bool) apply_filters( 'openstation_track_type_registrants', $track ); |
| 1349 |
} |
| 1350 |
|
| 1351 |
/** |
| 1352 |
* Record the registering file for a CPT or taxonomy. Hooked at |
| 1353 |
* `registered_post_type` / `registered_taxonomy` priority 9999 so we |
| 1354 |
* fire after every other listener has run (lets a plugin re-register |
| 1355 |
* its own type on top of someone else's — last writer wins, which |
| 1356 |
* matches WP's runtime semantics). |
| 1357 |
* |
| 1358 |
* Resolution is via `debug_backtrace()`: walk frames until we hit one |
| 1359 |
* whose `file` lives inside an extension directory (plugins, mu-plugins, |
| 1360 |
* or a theme root). Cheap — the backtrace is bounded and runs once per |
| 1361 |
* type registration, all during `init`. |
| 1362 |
* |
| 1363 |
* @param string $type_or_post_type Type name (CPT or taxonomy). |
| 1364 |
* @param string $kind Either `'post_type'` or `'taxonomy'`. |
| 1365 |
* @return void |
| 1366 |
*/ |
| 1367 |
function openstation_record_type_registrant( $type_or_post_type, $kind ) { |
| 1368 |
if ( '' === (string) $type_or_post_type ) { |
| 1369 |
return; |
| 1370 |
} |
| 1371 |
if ( ! openstation_should_track_type_registrants() ) { |
| 1372 |
return; |
| 1373 |
} |
| 1374 |
// Skip Core builtin types — they're registered from Core itself |
| 1375 |
// (Posts, Pages, Categories, …) and the backtrace would never land |
| 1376 |
// inside WP_PLUGIN_DIR anyway. Cheap pre-filter. |
| 1377 |
if ( 'post_type' === $kind ) { |
| 1378 |
$obj = get_post_type_object( $type_or_post_type ); |
| 1379 |
if ( $obj && ! empty( $obj->_builtin ) ) { |
| 1380 |
return; |
| 1381 |
} |
| 1382 |
} elseif ( 'taxonomy' === $kind ) { |
| 1383 |
$obj = get_taxonomy( $type_or_post_type ); |
| 1384 |
if ( $obj && ! empty( $obj->_builtin ) ) { |
| 1385 |
return; |
| 1386 |
} |
| 1387 |
} |
| 1388 |
|
| 1389 |
$file = openstation_registrant_file_from_backtrace(); |
| 1390 |
if ( null === $file ) { |
| 1391 |
return; |
| 1392 |
} |
| 1393 |
$map = &openstation_get_typed_registrant_map(); |
| 1394 |
$map[ $kind ][ $type_or_post_type ] = $file; |
| 1395 |
} |
| 1396 |
|
| 1397 |
/** |
| 1398 |
* The extension directories a registration can legitimately come from, |
| 1399 |
* normalized and trailing-slashed. Anything else (Core itself, a |
| 1400 |
* drop-in, `wp-config.php`) is not attributable to an extension. |
| 1401 |
* |
| 1402 |
* @return string[] Normalized directory prefixes. |
| 1403 |
*/ |
| 1404 |
function openstation_extension_dirs() { |
| 1405 |
static $dirs = null; |
| 1406 |
if ( null !== $dirs ) { |
| 1407 |
return $dirs; |
| 1408 |
} |
| 1409 |
$dirs = array(); |
| 1410 |
if ( defined( 'WP_PLUGIN_DIR' ) ) { |
| 1411 |
$dirs[] = wp_normalize_path( WP_PLUGIN_DIR ) . '/'; |
| 1412 |
} |
| 1413 |
if ( defined( 'WPMU_PLUGIN_DIR' ) ) { |
| 1414 |
$dirs[] = wp_normalize_path( WPMU_PLUGIN_DIR ) . '/'; |
| 1415 |
} |
| 1416 |
foreach ( (array) get_theme_roots() as $theme_root ) { |
| 1417 |
// `get_theme_roots()` returns roots relative to `wp-content` |
| 1418 |
// when there's only one; `get_theme_root()` normalizes that. |
| 1419 |
$dirs[] = wp_normalize_path( get_theme_root( (string) $theme_root ) ) . '/'; |
| 1420 |
} |
| 1421 |
$dirs = array_values( array_unique( array_filter( $dirs ) ) ); |
| 1422 |
return $dirs; |
| 1423 |
} |
| 1424 |
|
| 1425 |
/** |
| 1426 |
* Walk the current PHP backtrace and return the closest frame that |
| 1427 |
* lives inside an extension directory (plugin, mu-plugin, or theme). |
| 1428 |
* |
| 1429 |
* Frames belonging to OpenStation itself are skipped: this function is |
| 1430 |
* called from `payload.php`, which is under `WP_PLUGIN_DIR`, so the two |
| 1431 |
* innermost frames would otherwise match and attribute every registered |
| 1432 |
* type to us. |
| 1433 |
* |
| 1434 |
* Used by the CPT / taxonomy registration tracker to attribute |
| 1435 |
* `register_post_type()` / `register_taxonomy()` calls without forcing |
| 1436 |
* Core to load `wp-admin/includes/plugin.php` earlier than it would — |
| 1437 |
* `get_plugins()` does not exist yet at `init`. |
| 1438 |
* |
| 1439 |
* @return string|null Normalized absolute path, or null. |
| 1440 |
*/ |
| 1441 |
function openstation_registrant_file_from_backtrace() { |
| 1442 |
$self_dir = defined( 'OPENSTATION_DIR' ) ? wp_normalize_path( OPENSTATION_DIR ) : ''; |
| 1443 |
$self_dir = $self_dir ? trailingslashit( $self_dir ) : ''; |
| 1444 |
$dirs = openstation_extension_dirs(); |
| 1445 |
if ( empty( $dirs ) ) { |
| 1446 |
return null; |
| 1447 |
} |
| 1448 |
|
| 1449 |
$bt = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS, 20 ); |
| 1450 |
foreach ( $bt as $frame ) { |
| 1451 |
if ( empty( $frame['file'] ) ) { |
| 1452 |
continue; |
| 1453 |
} |
| 1454 |
$norm = wp_normalize_path( (string) $frame['file'] ); |
| 1455 |
if ( '' !== $self_dir && 0 === strpos( $norm, $self_dir ) ) { |
| 1456 |
continue; |
| 1457 |
} |
| 1458 |
foreach ( $dirs as $dir ) { |
| 1459 |
if ( 0 === strpos( $norm, $dir ) ) { |
| 1460 |
return $norm; |
| 1461 |
} |
| 1462 |
} |
| 1463 |
} |
| 1464 |
return null; |
| 1465 |
} |
| 1466 |
|
| 1467 |
add_action( |
| 1468 |
'registered_post_type', |
| 1469 |
static function ( $post_type ) { |
| 1470 |
openstation_record_type_registrant( $post_type, 'post_type' ); |
| 1471 |
}, |
| 1472 |
9999, |
| 1473 |
1 |
| 1474 |
); |
| 1475 |
|
| 1476 |
add_action( |
| 1477 |
'registered_taxonomy', |
| 1478 |
static function ( $taxonomy ) { |
| 1479 |
openstation_record_type_registrant( $taxonomy, 'taxonomy' ); |
| 1480 |
}, |
| 1481 |
9999, |
| 1482 |
1 |
| 1483 |
); |
| 1484 |
|
| 1485 |
/** |
| 1486 |
* Resolve the declaring file of a hook callback. Handles closures, |
| 1487 |
* `[ $object, 'method' ]`, `[ 'Class', 'method' ]`, plain function names, |
| 1488 |
* and `'Class::method'` strings. Returns null when reflection fails or |
| 1489 |
* the callback shape isn't reflectable (rare — e.g. an invocable object |
| 1490 |
* whose `__invoke` lives in PHP core). |
| 1491 |
* |
| 1492 |
* @param mixed $callback A callback as stored in `WP_Hook::$callbacks[$prio][$id]['function']`. |
| 1493 |
* @return string|null Absolute filesystem path of the declaring file, or null. |
| 1494 |
*/ |
| 1495 |
function openstation_callback_source_file( $callback ) { |
| 1496 |
if ( empty( $callback ) ) { |
| 1497 |
return null; |
| 1498 |
} |
| 1499 |
try { |
| 1500 |
if ( is_string( $callback ) && false !== strpos( $callback, '::' ) ) { |
| 1501 |
list( $class, $method ) = explode( '::', $callback, 2 ); |
| 1502 |
$ref = new ReflectionMethod( $class, $method ); |
| 1503 |
} elseif ( is_array( $callback ) && isset( $callback[0], $callback[1] ) ) { |
| 1504 |
$ref = new ReflectionMethod( $callback[0], (string) $callback[1] ); |
| 1505 |
} elseif ( is_object( $callback ) && ! ( $callback instanceof Closure ) && method_exists( $callback, '__invoke' ) ) { |
| 1506 |
$ref = new ReflectionMethod( $callback, '__invoke' ); |
| 1507 |
} elseif ( is_callable( $callback ) ) { |
| 1508 |
$ref = new ReflectionFunction( $callback ); |
| 1509 |
} else { |
| 1510 |
return null; |
| 1511 |
} |
| 1512 |
$file = $ref->getFileName(); |
| 1513 |
return $file ? $file : null; |
| 1514 |
} catch ( ReflectionException $e ) { |
| 1515 |
return null; |
| 1516 |
} |
| 1517 |
} |
| 1518 |
|
| 1519 |
/** |
| 1520 |
* Resolve whether a given menu slug is rendered in the dock. |
| 1521 |
* Returns one of two values: |
| 1522 |
* |
| 1523 |
* - `'dock'` — render this item on the unified dock rail. |
| 1524 |
* - `'hidden'` — don't render this item anywhere in the desktop |
| 1525 |
* shell. The underlying admin menu entry still |
| 1526 |
* exists server-side; this only suppresses the |
| 1527 |
* desktop-shell tile. |
| 1528 |
* |
| 1529 |
* Default is `'dock'` for every menu item. Plugins + site admins can |
| 1530 |
* hide individual items via the `openstation_dock_placement` filter. |
| 1531 |
* |
| 1532 |
* @param string $menu_slug The menu slug (e.g. `edit.php`, `woocommerce`). |
| 1533 |
* @return string `'dock'` or `'hidden'`. |
| 1534 |
*/ |
| 1535 |
function openstation_dock_placement( $menu_slug ) { |
| 1536 |
/** |
| 1537 |
* Filter whether a specific menu item is shown in the dock. |
| 1538 |
* |
| 1539 |
* Return `'dock'` to render the item on the dock (default) or |
| 1540 |
* `'hidden'` to suppress it entirely. Any other value coerces to |
| 1541 |
* `'dock'` — a defensive guard so a misbehaving filter can't |
| 1542 |
* corrupt the dock with `null` / `false` / arbitrary strings. |
| 1543 |
* |
| 1544 |
* @param string $placement Default — always `'dock'`. |
| 1545 |
* @param string $menu_slug The menu slug triggering the lookup. |
| 1546 |
*/ |
| 1547 |
$filtered = apply_filters( 'openstation_dock_placement', 'dock', $menu_slug ); |
| 1548 |
return 'hidden' === $filtered ? 'hidden' : 'dock'; |
| 1549 |
} |
| 1550 |
|
| 1551 |
/** |
| 1552 |
* Assemble the menu payload consumed by the shell. |
| 1553 |
* |
| 1554 |
* Runs the full dock-builder and returns a single `dockItems` array — |
| 1555 |
* core WordPress menus first (Dashboard, Posts, Media, …), then |
| 1556 |
* plugin-contributed top-level menus. Items whose `placement` is |
| 1557 |
* `'hidden'` are dropped entirely. |
| 1558 |
* |
| 1559 |
* Extracted out of `includes/render.php` so both the initial PHP |
| 1560 |
* localize AND the chromeless bridge's live-refresh emit (including |
| 1561 |
* the hidden-iframe probe spawned by `wp.os.refreshMenu()`) |
| 1562 |
* read from a single source of truth — any drift would desync the |
| 1563 |
* live refresh. |
| 1564 |
* |
| 1565 |
* @return array{dockItems: array[]} Menu payload. |
| 1566 |
*/ |
| 1567 |
function openstation_build_menu_payload() { |
| 1568 |
$all = openstation_build_dock_items(); |
| 1569 |
|
| 1570 |
// Drop hidden items; preserve the default "core first, plugins |
| 1571 |
// after" ordering by partitioning on the core classifier. |
| 1572 |
$visible = array_values( |
| 1573 |
array_filter( |
| 1574 |
$all, |
| 1575 |
static function ( $item ) { |
| 1576 |
return 'hidden' !== ( $item['placement'] ?? 'dock' ); |
| 1577 |
} |
| 1578 |
) |
| 1579 |
); |
| 1580 |
|
| 1581 |
// Partition on the per-item `isCore` flag set in |
| 1582 |
// openstation_build_dock_items — that classifier ran against the |
| 1583 |
// raw menu slug ($item[2]), which is what |
| 1584 |
// openstation_is_core_menu_slug actually compares. The outer 'id' |
| 1585 |
// field is a sanitized CSS id (e.g. `toplevel_page_jetpack`) and |
| 1586 |
// would never match. |
| 1587 |
$core = array(); |
| 1588 |
$plugin = array(); |
| 1589 |
foreach ( $visible as $item ) { |
| 1590 |
if ( ! empty( $item['isCore'] ) ) { |
| 1591 |
$core[] = $item; |
| 1592 |
} else { |
| 1593 |
$plugin[] = $item; |
| 1594 |
} |
| 1595 |
} |
| 1596 |
|
| 1597 |
$dock = array_merge( $core, $plugin ); |
| 1598 |
|
| 1599 |
// One collector call feeds both halves: the slim entry list and |
| 1600 |
// the handle-keyed script data the shell joins them with. |
| 1601 |
$native_windows = openstation_collect_native_windows_payload(); |
| 1602 |
|
| 1603 |
$payload = array( |
| 1604 |
'dockItems' => $dock, |
| 1605 |
'nativeWindows' => $native_windows['windows'], |
| 1606 |
'nativeWindowScriptData' => $native_windows['scriptData'], |
| 1607 |
); |
| 1608 |
|
| 1609 |
// Optional per-surface payload builders — each module ships a |
| 1610 |
// zero-arg `openstation_build_*_payload()`; modules that aren't |
| 1611 |
// loaded this request contribute an empty array. |
| 1612 |
$builders = array( |
| 1613 |
'serverWidgets' => 'openstation_build_desktop_widgets_payload', |
| 1614 |
'serverWallpapers' => 'openstation_build_desktop_wallpapers_payload', |
| 1615 |
'serverCommandScripts' => 'openstation_build_desktop_command_scripts_payload', |
| 1616 |
'serverCommands' => 'openstation_build_desktop_commands_payload', |
| 1617 |
'serverSettingsTabScripts' => 'openstation_build_desktop_settings_tab_scripts_payload', |
| 1618 |
'serverSettingsTabs' => 'openstation_build_desktop_settings_tabs_payload', |
| 1619 |
'serverDockRailRendererScripts' => 'openstation_build_dock_rail_renderer_scripts_payload', |
| 1620 |
'serverTitleBarButtonScripts' => 'openstation_build_desktop_titlebar_button_scripts_payload', |
| 1621 |
'serverWindowActionScripts' => 'openstation_build_desktop_window_action_scripts_payload', |
| 1622 |
'serverUnfocusEffectScripts' => 'openstation_build_desktop_unfocus_effect_scripts_payload', |
| 1623 |
'serverWindowLinkRendererScripts' => 'openstation_build_window_link_renderer_scripts_payload', |
| 1624 |
'serverWindowThemeScripts' => 'openstation_build_window_theme_scripts_payload', |
| 1625 |
'serverWindowThemes' => 'openstation_build_window_themes_payload', |
| 1626 |
'serverWindowControlScripts' => 'openstation_build_window_control_scripts_payload', |
| 1627 |
'serverWindowControls' => 'openstation_build_window_controls_payload', |
| 1628 |
'serverWindowSlotScripts' => 'openstation_build_window_slot_scripts_payload', |
| 1629 |
'serverWindowSlots' => 'openstation_build_window_slots_payload', |
| 1630 |
'serverWindowChromeScripts' => 'openstation_build_window_chrome_scripts_payload', |
| 1631 |
'serverWindowChromes' => 'openstation_build_window_chromes_payload', |
| 1632 |
'serverWindowNotices' => 'openstation_build_window_notices_payload', |
| 1633 |
'serverGames' => 'openstation_build_desktop_games_payload', |
| 1634 |
'serverDesktopThemes' => 'openstation_build_desktop_themes_payload', |
| 1635 |
'desktopIcons' => 'openstation_build_desktop_icons_payload', |
| 1636 |
); |
| 1637 |
|
| 1638 |
foreach ( $builders as $key => $builder ) { |
| 1639 |
$payload[ $key ] = function_exists( $builder ) ? $builder() : array(); |
| 1640 |
} |
| 1641 |
|
| 1642 |
// Aggregate update counts for the admin bar's "updates" notifier |
| 1643 |
// (the circle-arrows badge Core renders top-left). The node is |
| 1644 |
// static server HTML on the shell page, so after an in-window |
| 1645 |
// update run the shell needs fresh numbers to repaint it — GH#296. |
| 1646 |
// `wp_get_update_data()` is capability-aware (plugins / themes / |
| 1647 |
// core each gated), so the count matches what this user can act |
| 1648 |
// on. Strings are prebuilt here so the client repaint stays |
| 1649 |
// locale-correct without shipping translations to JS. |
| 1650 |
if ( function_exists( 'wp_get_update_data' ) ) { |
| 1651 |
$update_data = wp_get_update_data(); |
| 1652 |
$update_total = isset( $update_data['counts']['total'] ) ? (int) $update_data['counts']['total'] : 0; |
| 1653 |
|
| 1654 |
$payload['updateCounts'] = array( |
| 1655 |
'total' => $update_total, |
| 1656 |
'formatted' => number_format_i18n( $update_total ), |
| 1657 |
'text' => sprintf( |
| 1658 |
/* translators: %s: number of pending updates. */ |
| 1659 |
_n( '%s update available', '%s updates available', $update_total, 'desktop-mode' ), |
| 1660 |
number_format_i18n( $update_total ) |
| 1661 |
), |
| 1662 |
'url' => network_admin_url( 'update-core.php' ), |
| 1663 |
); |
| 1664 |
} |
| 1665 |
|
| 1666 |
// The site switcher's rows: on a network, the instances this shell |
| 1667 |
// may switch to (`openstation_multisite_payload()`), null elsewhere. |
| 1668 |
// The Network app spends a menu refresh after every action that |
| 1669 |
// changes them (add, remove, join, leave, sync), so the row above |
| 1670 |
// overview's desktop tiles follows the registry without a reload. |
| 1671 |
$payload['multisite'] = openstation_multisite_payload(); |
| 1672 |
|
| 1673 |
// A cheap structural fingerprint of the admin menu the shell uses to |
| 1674 |
// decide whether a live refresh is warranted. Shipped in every full |
| 1675 |
// payload so the shell can seed / update its last-known signature |
| 1676 |
// without recomputing it client-side (which would risk drift from |
| 1677 |
// the server's capability-gated view). See |
| 1678 |
// openstation_menu_signature(). |
| 1679 |
$payload['menuSig'] = openstation_menu_signature(); |
| 1680 |
|
| 1681 |
return $payload; |
| 1682 |
} |
| 1683 |
|
| 1684 |
/** |
| 1685 |
* Cheap structural fingerprint of the current admin menu. |
| 1686 |
* |
| 1687 |
* The chromeless bridge emits the *full* menu payload only from the |
| 1688 |
* handful of pages whose completion commonly mutates the admin menu |
| 1689 |
* (activation / install / theme switch). That leaves a gap: a custom |
| 1690 |
* post type registered through a settings-based tool (CPT UI, Pods, |
| 1691 |
* ACF, …) saves on its own `admin.php?page=…` / `options.php` screen, |
| 1692 |
* none of which is in that list, so the new top-level menu never |
| 1693 |
* reaches the live dock until a full browser reload rebuilds the shell |
| 1694 |
* (GH#325). |
| 1695 |
* |
| 1696 |
* Building the full payload on *every* chromeless page just to catch |
| 1697 |
* that case would be wasteful — most navigations don't touch the menu. |
| 1698 |
* Instead every chromeless page ships this lightweight signature; the |
| 1699 |
* shell compares it against its last-known value and only spends a |
| 1700 |
* `wp.os.refreshMenu()` probe when it actually changed. |
| 1701 |
* |
| 1702 |
* The hash covers the capability-passing top-level + submenu slugs and |
| 1703 |
* their (badge-stripped) titles — i.e. exactly the add / remove / |
| 1704 |
* rename events the dock cares about. Transient badge counts (update |
| 1705 |
* notifications, moderation queues) are stripped so they don't churn |
| 1706 |
* the signature; those have their own refresh path. |
| 1707 |
* |
| 1708 |
* @return string 32-char md5 fingerprint, or '' when the menu is |
| 1709 |
* unavailable (non-admin context). |
| 1710 |
*/ |
| 1711 |
function openstation_menu_signature() { |
| 1712 |
global $menu, $submenu; |
| 1713 |
|
| 1714 |
if ( empty( $menu ) || ! is_array( $menu ) ) { |
| 1715 |
return ''; |
| 1716 |
} |
| 1717 |
|
| 1718 |
$clean_title = static function ( $raw ) { |
| 1719 |
// Mirror openstation_build_dock_items(): drop badge spans first, |
| 1720 |
// then any remaining markup, so update counts don't move the hash. |
| 1721 |
$stripped = preg_replace( '/<span[^>]*>.*?<\/span>/s', '', (string) $raw ); |
| 1722 |
return trim( wp_strip_all_tags( (string) $stripped ) ); |
| 1723 |
}; |
| 1724 |
|
| 1725 |
$parts = array(); |
| 1726 |
|
| 1727 |
foreach ( $menu as $item ) { |
| 1728 |
if ( empty( $item[2] ) ) { |
| 1729 |
continue; |
| 1730 |
} |
| 1731 |
if ( ! empty( $item[4] ) && false !== strpos( $item[4], 'wp-menu-separator' ) ) { |
| 1732 |
continue; |
| 1733 |
} |
| 1734 |
if ( ! empty( $item[1] ) && ! current_user_can( $item[1] ) ) { |
| 1735 |
continue; |
| 1736 |
} |
| 1737 |
|
| 1738 |
$slug = (string) $item[2]; |
| 1739 |
$parts[] = $slug . '|' . $clean_title( $item[0] ?? '' ); |
| 1740 |
|
| 1741 |
if ( empty( $submenu[ $slug ] ) || ! is_array( $submenu[ $slug ] ) ) { |
| 1742 |
continue; |
| 1743 |
} |
| 1744 |
foreach ( $submenu[ $slug ] as $sub_item ) { |
| 1745 |
if ( ! empty( $sub_item[1] ) && ! current_user_can( $sub_item[1] ) ) { |
| 1746 |
continue; |
| 1747 |
} |
| 1748 |
$parts[] = "\t" . ( isset( $sub_item[2] ) ? (string) $sub_item[2] : '' ) |
| 1749 |
. '|' . $clean_title( $sub_item[0] ?? '' ); |
| 1750 |
} |
| 1751 |
} |
| 1752 |
|
| 1753 |
return md5( implode( "\n", $parts ) ); |
| 1754 |
} |
| 1755 |
|
| 1756 |
/** |
| 1757 |
* A handle's dependency closure, in load order. |
| 1758 |
* |
| 1759 |
* Post-order depth-first: a handle is emitted only after everything it |
| 1760 |
* declares, which is the order `WP_Scripts::do_item()` would have |
| 1761 |
* printed them in. A handle is marked visited *before* its own |
| 1762 |
* dependencies are walked, so a dependency cycle unwinds instead of |
| 1763 |
* recursing forever, and an unregistered handle is skipped rather than |
| 1764 |
* being fatal — it contributes nothing and stops nothing. |
| 1765 |
* |
| 1766 |
* **Deliberately not `WP_Dependencies::all_deps()`.** Three reasons, |
| 1767 |
* each of which has bitten this codebase: |
| 1768 |
* |
| 1769 |
* 1. `WP_Scripts::all_deps()` applies `print_scripts_array` to its |
| 1770 |
* result whenever `$recursion` is falsy. That filter is where the |
| 1771 |
* chromeless palette trim and the asset guard live, so resolving a |
| 1772 |
* payload through it would run a print-time trim across a dependency |
| 1773 |
* list and let the guard splice this plugin's own bundles into it. |
| 1774 |
* Called from inside one of those filters it is an infinite loop. |
| 1775 |
* |
| 1776 |
* 2. Passing `$recursion = true` silences that filter but changes the |
| 1777 |
* contract: the first handle that fails aborts the entire call |
| 1778 |
* (`return false`), abandoning every handle after it in the list. The |
| 1779 |
* caller is left with a `$to_do` that is a truncated prefix of the real |
| 1780 |
* closure and indistinguishable from a complete one — a silent, partial |
| 1781 |
* answer conditional on unrelated registrations elsewhere on the page. |
| 1782 |
* A lazily-delivered bundle resolved that way loses packages it |
| 1783 |
* declared and throws on an undefined global at mount, which is the |
| 1784 |
* exact bug this whole mechanism exists to prevent. |
| 1785 |
* |
| 1786 |
* 3. `all_deps()` reports missing dependencies through |
| 1787 |
* `_doing_it_wrong()`. This is read-only analysis; the real print pass |
| 1788 |
* raises those anyway, and raising them twice turns someone else's |
| 1789 |
* pre-existing warning into our noise. |
| 1790 |
* |
| 1791 |
* O(V+E) over the graph, allocates one set, and clones nothing. |
| 1792 |
* |
| 1793 |
* @param WP_Dependencies $dependencies The scripts or styles registry. |
| 1794 |
* @param string[] $handles Roots to walk. |
| 1795 |
* @return string[] Registered handles, dependencies before dependents. |
| 1796 |
*/ |
| 1797 |
function openstation_script_dependency_closure( $dependencies, $handles ) { |
| 1798 |
$seen = array(); |
| 1799 |
$out = array(); |
| 1800 |
openstation_collect_script_dependency_closure( $dependencies, (array) $handles, $seen, $out ); |
| 1801 |
|
| 1802 |
return $out; |
| 1803 |
} |
| 1804 |
|
| 1805 |
/** |
| 1806 |
* Recursive half of {@see openstation_script_dependency_closure()}. |
| 1807 |
* |
| 1808 |
* @param WP_Dependencies $dependencies The scripts or styles registry. |
| 1809 |
* @param string[] $handles Handles to walk. |
| 1810 |
* @param array $seen Handle => true, by reference. |
| 1811 |
* @param string[] $out Ordered result, by reference. |
| 1812 |
*/ |
| 1813 |
function openstation_collect_script_dependency_closure( $dependencies, $handles, &$seen, &$out ) { |
| 1814 |
foreach ( (array) $handles as $handle ) { |
| 1815 |
if ( isset( $seen[ $handle ] ) ) { |
| 1816 |
continue; |
| 1817 |
} |
| 1818 |
// Marked BEFORE recursing, so a cycle meets itself as visited |
| 1819 |
// and unwinds rather than recursing forever. |
| 1820 |
$seen[ $handle ] = true; |
| 1821 |
if ( ! isset( $dependencies->registered[ $handle ] ) ) { |
| 1822 |
continue; |
| 1823 |
} |
| 1824 |
openstation_collect_script_dependency_closure( |
| 1825 |
$dependencies, |
| 1826 |
$dependencies->registered[ $handle ]->deps, |
| 1827 |
$seen, |
| 1828 |
$out |
| 1829 |
); |
| 1830 |
$out[] = $handle; |
| 1831 |
} |
| 1832 |
} |
| 1833 |
|
| 1834 |
/** |
| 1835 |
* Resolve a handle's dependency closure, in load order. |
| 1836 |
* |
| 1837 |
* **Why a lazily-delivered handle needs this at all.** WordPress |
| 1838 |
* normally resolves a script's dependencies when it enqueues it — the |
| 1839 |
* packages a bundle declares are on the page before its own body runs. |
| 1840 |
* A handle that is only ever delivered lazily never goes through that: |
| 1841 |
* `loadVendorScript()` injects one URL, and a bundle declaring |
| 1842 |
* `wp-api-fetch` found `wp.apiFetch` undefined at mount. |
| 1843 |
* |
| 1844 |
* That used to work by accident. Core's ⌘K palette was enqueued on |
| 1845 |
* every admin page and its closure is the whole Gutenberg runtime, so |
| 1846 |
* `wp.apiFetch`, `wp.element` and friends happened to be globals. |
| 1847 |
* Deferring the palette took the accident away and left the contract |
| 1848 |
* exposed — see `docs/migration-wp-package-globals.md`. |
| 1849 |
* |
| 1850 |
* The closure comes from {@see openstation_script_dependency_closure()} |
| 1851 |
* rather than `WP_Dependencies::all_deps()`; that function's docblock |
| 1852 |
* records why, and the short version is that `all_deps()` answers a |
| 1853 |
* question like this one with a silently truncated list. The handle |
| 1854 |
* itself is excluded — the caller loads it separately, after these. |
| 1855 |
* |
| 1856 |
* @param string $handle Script handle. |
| 1857 |
* @return array<int,array<string,mixed>> Ordered dependency payloads. |
| 1858 |
*/ |
| 1859 |
function openstation_resolve_script_dependencies( $handle ) { |
| 1860 |
$handle = (string) $handle; |
| 1861 |
$wp_scripts = wp_scripts(); |
| 1862 |
if ( '' === $handle || ! $wp_scripts || ! isset( $wp_scripts->registered[ $handle ] ) ) { |
| 1863 |
return array(); |
| 1864 |
} |
| 1865 |
$deps = $wp_scripts->registered[ $handle ]->deps; |
| 1866 |
if ( empty( $deps ) ) { |
| 1867 |
return array(); |
| 1868 |
} |
| 1869 |
|
| 1870 |
$out = array(); |
| 1871 |
foreach ( openstation_script_dependency_closure( $wp_scripts, $deps ) as $dep_handle ) { |
| 1872 |
if ( $dep_handle === $handle ) { |
| 1873 |
continue; |
| 1874 |
} |
| 1875 |
$payload = openstation_resolve_script_payload( $dep_handle ); |
| 1876 |
if ( '' === $payload['url'] |
| 1877 |
&& empty( $payload['before'] ) |
| 1878 |
&& empty( $payload['after'] ) |
| 1879 |
&& empty( $payload['l10n'] ) ) { |
| 1880 |
continue; |
| 1881 |
} |
| 1882 |
// The handle rides along because the shell needs it to decide |
| 1883 |
// whether the page already has this package. A URL is not |
| 1884 |
// enough: with Core's script concatenation on — the wp-admin |
| 1885 |
// default — every package below `wp-includes/js/` is served |
| 1886 |
// from one `load-scripts.php` blob and has no `<script src>` |
| 1887 |
// of its own to match against. Re-running `wp-hooks` because |
| 1888 |
// we could not see it replaces `window.wp.hooks`, and every |
| 1889 |
// subscriber registered at boot goes deaf. See |
| 1890 |
// `src/script-presence.ts`. |
| 1891 |
$payload['handle'] = (string) $dep_handle; |
| 1892 |
$out[] = $payload; |
| 1893 |
} |
| 1894 |
return $out; |
| 1895 |
} |
| 1896 |
|
| 1897 |
/** |
| 1898 |
* Resolve a registered WP script handle into the full payload the |
| 1899 |
* shell needs to lazy-load it without going through `wp_print_scripts()`. |
| 1900 |
* |
| 1901 |
* Returns: |
| 1902 |
* |
| 1903 |
* ``` |
| 1904 |
* array( |
| 1905 |
* 'url' => 'https://…/script.js?ver=…', |
| 1906 |
* 'before' => array( /* `wp_add_inline_script( $h, $code, 'before' )` strings *\/ ), |
| 1907 |
* 'after' => array( /* `wp_add_inline_script( $h, $code, 'after' )` strings *\/ ), |
| 1908 |
* 'l10n' => array( /* `wp_localize_script( $h, $name, $data )` precomputed `<script>var $name = …;</script>` strings *\/ ), |
| 1909 |
* 'translations' => string, /* `wp_set_script_translations()` JED chunk *\/ |
| 1910 |
* ) |
| 1911 |
* ``` |
| 1912 |
* |
| 1913 |
* **The `l10n` / `before` / `after` / `translations` fields exist |
| 1914 |
* because the lazy-load path in the shell appends a raw |
| 1915 |
* `<script src="…">` and never invokes `wp_print_scripts()` — so any |
| 1916 |
* `wp_localize_script` / `wp_add_inline_script` / `wp_set_script_translations` |
| 1917 |
* data attached to the handle would be silently dropped without this |
| 1918 |
* harvest.** The shell injects each entry as inline `<script>` tags |
| 1919 |
* around the lazy `<script src>` in the same order |
| 1920 |
* `WP_Scripts::do_item()` would have used. |
| 1921 |
* |
| 1922 |
* Returns an empty payload (`array( 'url' => '' )`) when the handle |
| 1923 |
* is unregistered or has no source — callers treat that as "no |
| 1924 |
* script to load." |
| 1925 |
* |
| 1926 |
* Shared between `openstation_register_window()` and |
| 1927 |
* `openstation_register_widget()` (and every other registration that |
| 1928 |
* relies on lazy script loading in the shell) because all of them |
| 1929 |
* need identical handle→payload plumbing to power mid-session dynamic |
| 1930 |
* script loading without the `wp_print_scripts` lifecycle. |
| 1931 |
* |
| 1932 |
* @param string $handle WP script handle. |
| 1933 |
* @return array{ url:string, before:string[], after:string[], l10n:string[], translations:string } Payload (empty `url` on miss). |
| 1934 |
*/ |
| 1935 |
function openstation_resolve_script_payload( $handle ) { |
| 1936 |
$empty = array( |
| 1937 |
'url' => '', |
| 1938 |
'before' => array(), |
| 1939 |
'after' => array(), |
| 1940 |
'l10n' => array(), |
| 1941 |
'translations' => '', |
| 1942 |
); |
| 1943 |
|
| 1944 |
$handle = (string) $handle; |
| 1945 |
if ( '' === $handle ) { |
| 1946 |
return $empty; |
| 1947 |
} |
| 1948 |
$wp_scripts = wp_scripts(); |
| 1949 |
if ( ! $wp_scripts || ! isset( $wp_scripts->registered[ $handle ] ) ) { |
| 1950 |
return $empty; |
| 1951 |
} |
| 1952 |
$registered = $wp_scripts->registered[ $handle ]; |
| 1953 |
$src = is_string( $registered->src ) ? $registered->src : ''; |
| 1954 |
if ( '' === $src ) { |
| 1955 |
return $empty; |
| 1956 |
} |
| 1957 |
|
| 1958 |
// Normalize relative paths + attach cache-bust ver. |
| 1959 |
$resolved = $src; |
| 1960 |
if ( 0 === strpos( $resolved, '/' ) && 0 !== strpos( $resolved, '//' ) ) { |
| 1961 |
$resolved = site_url( $resolved ); |
| 1962 |
} |
| 1963 |
if ( ! empty( $registered->ver ) ) { |
| 1964 |
$resolved = add_query_arg( 'ver', $registered->ver, $resolved ); |
| 1965 |
} |
| 1966 |
|
| 1967 |
// Harvest `extra` data the lazy-load path would otherwise drop. |
| 1968 |
$before = array(); |
| 1969 |
$after = array(); |
| 1970 |
$l10n = array(); |
| 1971 |
|
| 1972 |
if ( isset( $registered->extra['before'] ) && is_array( $registered->extra['before'] ) ) { |
| 1973 |
foreach ( $registered->extra['before'] as $code ) { |
| 1974 |
$code = (string) $code; |
| 1975 |
if ( '' !== $code ) { |
| 1976 |
$before[] = $code; |
| 1977 |
} |
| 1978 |
} |
| 1979 |
} |
| 1980 |
if ( isset( $registered->extra['after'] ) && is_array( $registered->extra['after'] ) ) { |
| 1981 |
foreach ( $registered->extra['after'] as $code ) { |
| 1982 |
$code = (string) $code; |
| 1983 |
if ( '' !== $code ) { |
| 1984 |
$after[] = $code; |
| 1985 |
} |
| 1986 |
} |
| 1987 |
} |
| 1988 |
// `wp_localize_script` stores its JS at `extra['data']` as a single |
| 1989 |
// concatenated string of `var x = …;` assignments. We capture it |
| 1990 |
// verbatim — the shell will eval it as the body of an inline |
| 1991 |
// `<script>` tag, mirroring what `WP_Scripts::print_extra_script()` |
| 1992 |
// does at print time. |
| 1993 |
if ( ! empty( $registered->extra['data'] ) && is_string( $registered->extra['data'] ) ) { |
| 1994 |
$l10n[] = $registered->extra['data']; |
| 1995 |
} |
| 1996 |
|
| 1997 |
// Translations chunk — `wp_set_script_translations()` builds a |
| 1998 |
// `wp.i18n.setLocaleData( JSON, 'domain' )` snippet that the print |
| 1999 |
// pipeline emits before the script body. `print_translations( |
| 2000 |
// $handle, false )` returns the snippet without echoing. |
| 2001 |
$translations = ''; |
| 2002 |
if ( method_exists( $wp_scripts, 'print_translations' ) ) { |
| 2003 |
$captured = $wp_scripts->print_translations( $handle, false ); |
| 2004 |
if ( is_string( $captured ) ) { |
| 2005 |
$translations = $captured; |
| 2006 |
} |
| 2007 |
} |
| 2008 |
|
| 2009 |
return array( |
| 2010 |
'url' => $resolved, |
| 2011 |
'before' => $before, |
| 2012 |
'after' => $after, |
| 2013 |
'l10n' => $l10n, |
| 2014 |
'translations' => $translations, |
| 2015 |
); |
| 2016 |
} |
| 2017 |
|
| 2018 |
/** |
| 2019 |
* Resolves a registered style handle to its print-time URL + harvested |
| 2020 |
* inline CSS, the styles-side mirror of |
| 2021 |
* {@see openstation_resolve_script_payload()}. |
| 2022 |
* |
| 2023 |
* Why this exists: when a plugin's native window (or window-chrome |
| 2024 |
* theme/control/slot/chrome) is activated mid-session — i.e. the user |
| 2025 |
* activates the plugin from inside an open desktop shell — the parent |
| 2026 |
* shell page already finished `wp_print_styles`. The plugin's |
| 2027 |
* `admin_enqueue_scripts` callback never ran for it, so its |
| 2028 |
* stylesheet is missing. The shell's lazy-loader fixes that by |
| 2029 |
* injecting a `<link rel="stylesheet">` for every entry whose payload |
| 2030 |
* carries a `styleUrl`. |
| 2031 |
* |
| 2032 |
* Captures both the resolved `src` and any `wp_add_inline_style()` |
| 2033 |
* blobs attached to the handle so the shell can replay the same data |
| 2034 |
* the print pipeline would have written. |
| 2035 |
* |
| 2036 |
* @param string $handle WP style handle. |
| 2037 |
* @return array{ url:string, inline:string[] } Payload (empty `url` on miss). |
| 2038 |
*/ |
| 2039 |
function openstation_resolve_style_payload( $handle ) { |
| 2040 |
$empty = array( |
| 2041 |
'url' => '', |
| 2042 |
'inline' => array(), |
| 2043 |
); |
| 2044 |
|
| 2045 |
$handle = (string) $handle; |
| 2046 |
if ( '' === $handle ) { |
| 2047 |
return $empty; |
| 2048 |
} |
| 2049 |
$wp_styles = wp_styles(); |
| 2050 |
if ( ! $wp_styles || ! isset( $wp_styles->registered[ $handle ] ) ) { |
| 2051 |
return $empty; |
| 2052 |
} |
| 2053 |
$registered = $wp_styles->registered[ $handle ]; |
| 2054 |
$src = is_string( $registered->src ) ? $registered->src : ''; |
| 2055 |
if ( '' === $src ) { |
| 2056 |
return $empty; |
| 2057 |
} |
| 2058 |
|
| 2059 |
// Normalize relative paths + attach cache-bust ver — same shape as |
| 2060 |
// the script resolver. Keeps the two helpers symmetric so callers |
| 2061 |
// don't have to special-case style vs script payloads. |
| 2062 |
$resolved = $src; |
| 2063 |
if ( 0 === strpos( $resolved, '/' ) && 0 !== strpos( $resolved, '//' ) ) { |
| 2064 |
$resolved = site_url( $resolved ); |
| 2065 |
} |
| 2066 |
if ( ! empty( $registered->ver ) ) { |
| 2067 |
$resolved = add_query_arg( 'ver', $registered->ver, $resolved ); |
| 2068 |
} |
| 2069 |
|
| 2070 |
// `wp_add_inline_style()` blobs land in `extra['after']` — capture |
| 2071 |
// them so the shell can emit a `<style>` tag after the `<link>` to |
| 2072 |
// preserve cascade order with what `WP_Styles::print_inline_style()` |
| 2073 |
// would have written. |
| 2074 |
$inline = array(); |
| 2075 |
if ( isset( $registered->extra['after'] ) && is_array( $registered->extra['after'] ) ) { |
| 2076 |
foreach ( $registered->extra['after'] as $code ) { |
| 2077 |
$code = (string) $code; |
| 2078 |
if ( '' !== $code ) { |
| 2079 |
$inline[] = $code; |
| 2080 |
} |
| 2081 |
} |
| 2082 |
} |
| 2083 |
|
| 2084 |
return array( |
| 2085 |
'url' => $resolved, |
| 2086 |
'inline' => $inline, |
| 2087 |
); |
| 2088 |
} |
| 2089 |
|
| 2090 |
/** |
| 2091 |
* Build the deferred command-palette asset manifest. |
| 2092 |
* |
| 2093 |
* `wp_enqueue_command_palette_assets()` (WP 6.9+) enqueues |
| 2094 |
* `wp-commands` + `wp-core-commands` and attaches the inline |
| 2095 |
* `wp.coreCommands.initializeCommandPalette( … )` call that seeds the |
| 2096 |
* `core/commands` store. Its transitive dependency chain is the whole |
| 2097 |
* Gutenberg runtime — `wp-block-editor`, `wp-components`, React, |
| 2098 |
* `wp-core-data`, some forty bundles, ~800 KB gzipped — which the |
| 2099 |
* shell used to pay on EVERY boot so that the ⌘K palette's baseline |
| 2100 |
* commands existed if the user ever opened it. |
| 2101 |
* |
| 2102 |
* This builder lets Core do exactly what it would have done — the |
| 2103 |
* menu-command serialization and the inline init included — then |
| 2104 |
* UNWINDS the enqueue: it snapshots the script/style queues, calls |
| 2105 |
* the Core function, diffs out the roots it added, restores the |
| 2106 |
* queues so nothing prints at boot, and resolves the full ordered |
| 2107 |
* dependency chain on CLONES (the live `$to_do` is never touched). |
| 2108 |
* Each handle in the chain is harvested into the same |
| 2109 |
* url/before/after/l10n/translations shape the native-window lazy |
| 2110 |
* loader uses, and the shell replays the list — in order — the first |
| 2111 |
* time the palette is invoked (`src/commands/palette-assets.ts`). |
| 2112 |
* |
| 2113 |
* Handles with no `src` (pure aggregators) are kept whenever they |
| 2114 |
* carry inline data; dropping them would lose middleware and locale |
| 2115 |
* setup the chain depends on. Handles the boot page already printed |
| 2116 |
* are skipped client-side, by handle as well as by path so that a |
| 2117 |
* package Core concatenated into `load-scripts.php` is recognized |
| 2118 |
* (`src/script-presence.ts`) — the manifest deliberately lists them |
| 2119 |
* anyway, because which ones those are differs per site and per |
| 2120 |
* screen. Each entry therefore carries its `handle`, and that is |
| 2121 |
* load-bearing rather than informational. |
| 2122 |
* |
| 2123 |
* Returns `null` on pre-6.9 sites (no Core palette to defer). |
| 2124 |
* |
| 2125 |
* @return array{scripts:array<int,array<string,mixed>>,styles:array<int,array<string,mixed>>}|null |
| 2126 |
*/ |
| 2127 |
function openstation_build_command_palette_assets_payload() { |
| 2128 |
if ( ! function_exists( 'wp_enqueue_command_palette_assets' ) ) { |
| 2129 |
return null; |
| 2130 |
} |
| 2131 |
$scripts = wp_scripts(); |
| 2132 |
$styles = wp_styles(); |
| 2133 |
if ( ! $scripts || ! $styles ) { |
| 2134 |
return null; |
| 2135 |
} |
| 2136 |
|
| 2137 |
// `wp_enqueue_command_palette_assets()` reads `$submenu` without |
| 2138 |
// guarding the global — initialize defensively (test contexts, |
| 2139 |
// edge-case admin requests where the menu wasn't built yet). |
| 2140 |
global $menu, $submenu; |
| 2141 |
// phpcs:disable WordPress.WP.GlobalVariablesOverride.Prohibited -- initializing an unset global to its documented empty shape, not replacing a built menu. |
| 2142 |
if ( ! isset( $submenu ) || ! is_array( $submenu ) ) { |
| 2143 |
$submenu = array(); |
| 2144 |
} |
| 2145 |
if ( ! isset( $menu ) || ! is_array( $menu ) ) { |
| 2146 |
$menu = array(); |
| 2147 |
} |
| 2148 |
// phpcs:enable WordPress.WP.GlobalVariablesOverride.Prohibited |
| 2149 |
|
| 2150 |
$script_queue_before = $scripts->queue; |
| 2151 |
$style_queue_before = $styles->queue; |
| 2152 |
|
| 2153 |
wp_enqueue_command_palette_assets(); |
| 2154 |
|
| 2155 |
$script_roots = array_values( array_diff( $scripts->queue, $script_queue_before ) ); |
| 2156 |
$style_roots = array_values( array_diff( $styles->queue, $style_queue_before ) ); |
| 2157 |
|
| 2158 |
// Unwind: the boot page must not print any of it. The inline init |
| 2159 |
// stays attached to the `wp-core-commands` HANDLE — that is the |
| 2160 |
// point: the harvest below captures it, and if some other screen |
| 2161 |
// legitimately enqueues the handle, it prints as Core intended. |
| 2162 |
$scripts->queue = $script_queue_before; |
| 2163 |
$styles->queue = $style_queue_before; |
| 2164 |
|
| 2165 |
$out = array( |
| 2166 |
'scripts' => array(), |
| 2167 |
'styles' => array(), |
| 2168 |
); |
| 2169 |
|
| 2170 |
// Ordered dependency chains, resolved on clones so the request's |
| 2171 |
// real `$to_do` / `$done` state is untouched. |
| 2172 |
$script_probe = clone $scripts; |
| 2173 |
$script_probe->to_do = array(); |
| 2174 |
$script_probe->done = array(); |
| 2175 |
$script_probe->all_deps( $script_roots ); |
| 2176 |
foreach ( $script_probe->to_do as $handle ) { |
| 2177 |
$payload = openstation_resolve_script_payload( $handle ); |
| 2178 |
if ( '' === $payload['url'] ) { |
| 2179 |
// Src-less aggregator — keep it only for its inline data. |
| 2180 |
$registered = isset( $scripts->registered[ $handle ] ) ? $scripts->registered[ $handle ] : null; |
| 2181 |
if ( $registered ) { |
| 2182 |
foreach ( array( 'before', 'after' ) as $position ) { |
| 2183 |
if ( isset( $registered->extra[ $position ] ) && is_array( $registered->extra[ $position ] ) ) { |
| 2184 |
$payload[ $position ] = array_values( array_filter( array_map( 'strval', $registered->extra[ $position ] ) ) ); |
| 2185 |
} |
| 2186 |
} |
| 2187 |
if ( ! empty( $registered->extra['data'] ) && is_string( $registered->extra['data'] ) ) { |
| 2188 |
$payload['l10n'][] = $registered->extra['data']; |
| 2189 |
} |
| 2190 |
} |
| 2191 |
if ( empty( $payload['before'] ) && empty( $payload['after'] ) && empty( $payload['l10n'] ) ) { |
| 2192 |
continue; |
| 2193 |
} |
| 2194 |
} |
| 2195 |
// Core's `initializeCommandPalette( {…} )` inline embeds the |
| 2196 |
// serialized admin-menu command list — ~20 KB that the boot |
| 2197 |
// page ALREADY carries as `window.__openStationMenuCommands` |
| 2198 |
// (the shell harvester's lookup, attached as a `before` |
| 2199 |
// inline on the main bundle, and the richer of the two: its |
| 2200 |
// URL derivation routes legacy file-path slugs through |
| 2201 |
// `menu_page_url()` where Core's regex takes them literally). |
| 2202 |
// Ship the list once: strip Core's embedded copy and |
| 2203 |
// synthesize the same call against the global, which is |
| 2204 |
// guaranteed present long before the manifest replays — it |
| 2205 |
// prints at boot, the replay waits for the first ⌘K. |
| 2206 |
if ( 'wp-core-commands' === $handle ) { |
| 2207 |
foreach ( array( 'before', 'after' ) as $position ) { |
| 2208 |
$payload[ $position ] = array_values( |
| 2209 |
array_filter( |
| 2210 |
$payload[ $position ], |
| 2211 |
static function ( $snippet ) { |
| 2212 |
return false === strpos( (string) $snippet, 'initializeCommandPalette(' ); |
| 2213 |
} |
| 2214 |
) |
| 2215 |
); |
| 2216 |
} |
| 2217 |
$payload['after'][] = sprintf( |
| 2218 |
'wp.coreCommands.initializeCommandPalette({"is_network_admin":%s,"menu_commands":window.__openStationMenuCommands||[]});', |
| 2219 |
is_network_admin() ? 'true' : 'false' |
| 2220 |
); |
| 2221 |
} |
| 2222 |
|
| 2223 |
$out['scripts'][] = array( |
| 2224 |
'handle' => (string) $handle, |
| 2225 |
'url' => $payload['url'], |
| 2226 |
'before' => $payload['before'], |
| 2227 |
'after' => $payload['after'], |
| 2228 |
'l10n' => $payload['l10n'], |
| 2229 |
'translations' => $payload['translations'], |
| 2230 |
); |
| 2231 |
} |
| 2232 |
|
| 2233 |
$style_probe = clone $styles; |
| 2234 |
$style_probe->to_do = array(); |
| 2235 |
$style_probe->done = array(); |
| 2236 |
$style_probe->all_deps( $style_roots ); |
| 2237 |
foreach ( $style_probe->to_do as $handle ) { |
| 2238 |
$style_payload = openstation_resolve_style_payload( $handle ); |
| 2239 |
if ( '' === $style_payload['url'] ) { |
| 2240 |
continue; |
| 2241 |
} |
| 2242 |
$out['styles'][] = array( |
| 2243 |
'handle' => (string) $handle, |
| 2244 |
'url' => $style_payload['url'], |
| 2245 |
'inline' => $style_payload['inline'], |
| 2246 |
); |
| 2247 |
} |
| 2248 |
|
| 2249 |
return $out; |
| 2250 |
} |
| 2251 |
|
| 2252 |
/** |
| 2253 |
* Resolve a list of style handles into the `deferredStyles` config |
| 2254 |
* map: handle → `array( 'url' => …, 'inline' => string[] )`. |
| 2255 |
* |
| 2256 |
* For shell surfaces that render on demand but are NOT native |
| 2257 |
* windows — the Preferences panel, the AI assistant, the bug-report |
| 2258 |
* window — so the `styles` companion mechanism can't carry their |
| 2259 |
* CSS. The shell reads this map off `openStationConfig.deferredStyles` |
| 2260 |
* and injects each sheet the first time its surface opens |
| 2261 |
* (`ensureDeferredStyle()` in `src/deferred-styles.ts`). |
| 2262 |
* |
| 2263 |
* Handles that resolve to nothing (never registered) are dropped, so |
| 2264 |
* the client map only ever holds injectable entries. |
| 2265 |
* |
| 2266 |
* @param string[] $handles Registered style handles. |
| 2267 |
* @return array<string, array{url:string, inline:string[]}> |
| 2268 |
*/ |
| 2269 |
function openstation_build_deferred_styles( $handles ) { |
| 2270 |
$out = array(); |
| 2271 |
foreach ( (array) $handles as $handle ) { |
| 2272 |
$handle = (string) $handle; |
| 2273 |
$payload = openstation_resolve_style_payload( $handle ); |
| 2274 |
if ( '' === $payload['url'] ) { |
| 2275 |
continue; |
| 2276 |
} |
| 2277 |
$out[ $handle ] = $payload; |
| 2278 |
} |
| 2279 |
return $out; |
| 2280 |
} |
| 2281 |
|
| 2282 |
/** |
| 2283 |
* Fire a `_doing_it_wrong()` notice exactly once per handle per |
| 2284 |
* request. Shared by every `openstation_build_desktop_*_scripts_payload()` |
| 2285 |
* caller — payload builders run on every shell-config rebuild |
| 2286 |
* (multiple times per page load via REST + admin-bar refresh + |
| 2287 |
* tests), so undeduped notices spam the error log AND trip |
| 2288 |
* `expectedIncorrectUsage` assertions in unrelated tests. |
| 2289 |
* |
| 2290 |
* @param string $function_name `openstation_register_*_script` — passed verbatim to `_doing_it_wrong`. |
| 2291 |
* @param string $kind Human label: `Command`, `Settings-tab`, `Title-bar button`. |
| 2292 |
* @param string $handle Offending script handle. |
| 2293 |
*/ |
| 2294 |
function openstation_warn_unresolvable_script_handle( $function_name, $kind, $handle ) { |
| 2295 |
static $warned = array(); |
| 2296 |
$cache_key = $function_name . '|' . $handle; |
| 2297 |
if ( isset( $warned[ $cache_key ] ) ) { |
| 2298 |
return; |
| 2299 |
} |
| 2300 |
$warned[ $cache_key ] = true; |
| 2301 |
|
| 2302 |
if ( '__flush__' === $handle ) { |
| 2303 |
// Test escape hatch: clear the dedupe cache so a flush |
| 2304 |
// helper can reset between tests. |
| 2305 |
$warned = array(); |
| 2306 |
return; |
| 2307 |
} |
| 2308 |
|
| 2309 |
_doing_it_wrong( |
| 2310 |
esc_html( $function_name ), |
| 2311 |
sprintf( |
| 2312 |
/* translators: 1: kind ("Command"/"Settings-tab"/"Title-bar button"), 2: handle. */ |
| 2313 |
esc_html__( '%1$s script handle "%2$s" could not be resolved: no `wp_register_script( \'%2$s\', … )` call had run by the time the shell harvested its payload. Register the handle on `admin_enqueue_scripts` at priority 5 or earlier — the harvest itself runs at priority 10, and a handle registered alongside it may or may not exist yet depending on plugin load order. Until then the script will not load.', 'desktop-mode' ), |
| 2314 |
esc_html( $kind ), |
| 2315 |
esc_html( $handle ) |
| 2316 |
), |
| 2317 |
'0.8.1' |
| 2318 |
); |
| 2319 |
} |
| 2320 |
|
| 2321 |
/** |
| 2322 |
* Test-only: clear every script-handle registry + the dedupe |
| 2323 |
* cache for the unresolvable-handle notice. Tests call this in |
| 2324 |
* `set_up` so prior tests' synthetic handles can't leak into |
| 2325 |
* later assertions about payload shape. |
| 2326 |
*/ |
| 2327 |
function openstation_flush_script_handle_registries() { |
| 2328 |
$flushers = array( |
| 2329 |
'openstation_flush_desktop_command_script_registry', |
| 2330 |
'openstation_flush_desktop_settings_tab_script_registry', |
| 2331 |
'openstation_flush_dock_rail_renderer_script_registry', |
| 2332 |
'openstation_flush_desktop_titlebar_button_script_registry', |
| 2333 |
'openstation_flush_desktop_window_action_script_registry', |
| 2334 |
'openstation_flush_desktop_unfocus_effect_script_registry', |
| 2335 |
'openstation_flush_window_link_renderer_script_registry', |
| 2336 |
'openstation_flush_window_theme_script_registry', |
| 2337 |
'openstation_flush_window_theme_registry', |
| 2338 |
'openstation_flush_window_control_script_registry', |
| 2339 |
'openstation_flush_window_control_registry', |
| 2340 |
'openstation_flush_window_slot_script_registry', |
| 2341 |
'openstation_flush_window_slot_registry', |
| 2342 |
'openstation_flush_window_chrome_script_registry', |
| 2343 |
'openstation_flush_window_chrome_registry', |
| 2344 |
'openstation_flush_window_notice_registry', |
| 2345 |
); |
| 2346 |
|
| 2347 |
foreach ( $flushers as $flusher ) { |
| 2348 |
if ( function_exists( $flusher ) ) { |
| 2349 |
$flusher(); |
| 2350 |
} |
| 2351 |
} |
| 2352 |
|
| 2353 |
openstation_warn_unresolvable_script_handle( '', '', '__flush__' ); |
| 2354 |
} |
| 2355 |
|
| 2356 |
/** |
| 2357 |
* Collect the native-window payload: slim per-window entries plus a |
| 2358 |
* handle-keyed script-data map. |
| 2359 |
* |
| 2360 |
* For each entry registered via `openstation_register_window()` the |
| 2361 |
* `windows` list captures the window's metadata |
| 2362 |
* (id/title/icon/placement/dimensions/autofocus), the rendered |
| 2363 |
* template HTML, and the HANDLE NAMES of its script, companions and |
| 2364 |
* tab scripts. The resolved data those handles stand for — URL plus |
| 2365 |
* harvested `wp_localize_script` / `wp_add_inline_script` / |
| 2366 |
* translations, see `openstation_resolve_script_payload()` — lives |
| 2367 |
* ONCE per handle in `scriptData`, and the shell joins the two on |
| 2368 |
* receipt (`hydrateServerEntries()` in `src/native-windows.ts`). |
| 2369 |
* |
| 2370 |
* The split exists because script data is a property of the HANDLE, |
| 2371 |
* not of the window: every App Framework window rides |
| 2372 |
* `openstation-app-runtime`, and inlining each entry's resolved copy |
| 2373 |
* serialized the same localize blobs and the same shared config set |
| 2374 |
* four times over — `scriptL10n` alone was ~100 KB of the boot |
| 2375 |
* payload, most of it repetition. The synthesized |
| 2376 |
* `openStationWindowConfig[ id ]` assignments group by handle for |
| 2377 |
* the same reason they used to ride every sharing entry: the shell |
| 2378 |
* fetches a URL once, and a bundle can serve one window from inside |
| 2379 |
* another (the Users window mounts the Profile form, which reads the |
| 2380 |
* user-edit config), so whichever entry loads the bundle must |
| 2381 |
* deliver the whole handle's config set. |
| 2382 |
* |
| 2383 |
* Style data stays inline on the entries — it never had a |
| 2384 |
* duplication problem worth a second map ( companion styles across |
| 2385 |
* the whole registry total ~2 KB ). |
| 2386 |
* |
| 2387 |
* @return array{windows:array[],scriptData:array<string,array{url:string,before:string[],after:string[],l10n:string[],translations:string}>} |
| 2388 |
*/ |
| 2389 |
function openstation_collect_native_windows_payload() { |
| 2390 |
$empty = array( |
| 2391 |
'windows' => array(), |
| 2392 |
'scriptData' => array(), |
| 2393 |
); |
| 2394 |
if ( ! function_exists( 'openstation_native_window_registry' ) ) { |
| 2395 |
return $empty; |
| 2396 |
} |
| 2397 |
|
| 2398 |
$registry = openstation_native_window_registry(); |
| 2399 |
if ( ! is_array( $registry ) ) { |
| 2400 |
return $empty; |
| 2401 |
} |
| 2402 |
|
| 2403 |
// A window says which admin offers it (`admin` in its registration: |
| 2404 |
// `site`, `network` or `any`). Every native window OpenStation |
| 2405 |
// ships is site-scoped, reading the current site's REST API, so in |
| 2406 |
// the network admin a `users.php` tile meaning "everyone on the |
| 2407 |
// network" would open one site's user list; those stay off the |
| 2408 |
// network shell. A window that declares `network` (the Network app) |
| 2409 |
// is offered there and nowhere else. |
| 2410 |
// |
| 2411 |
// Dropping the site windows there is also what disarms the |
| 2412 |
// client-side URL remaps: they match on the tail of a pathname |
| 2413 |
// (`endsWith( '/users.php' )`) and the network admin serves |
| 2414 |
// same-named files one directory down, but with nothing registered |
| 2415 |
// `openById()` finds no window and the remap falls through to the |
| 2416 |
// iframe. |
| 2417 |
$registry = array_filter( $registry, 'openstation_native_window_offered_here' ); |
| 2418 |
|
| 2419 |
$script_data = array(); |
| 2420 |
|
| 2421 |
// Resolve a handle into the map, once. Returns the handle when it |
| 2422 |
// resolved to something loadable, '' when it did not (never |
| 2423 |
// registered, no src) — the same silent drop the inline shape |
| 2424 |
// applied to companions and tab scripts. |
| 2425 |
$collect_handle = static function ( $handle ) use ( &$script_data ) { |
| 2426 |
$handle = (string) $handle; |
| 2427 |
if ( '' === $handle ) { |
| 2428 |
return ''; |
| 2429 |
} |
| 2430 |
if ( isset( $script_data[ $handle ] ) ) { |
| 2431 |
return $handle; |
| 2432 |
} |
| 2433 |
$payload = openstation_resolve_script_payload( $handle ); |
| 2434 |
if ( '' === $payload['url'] ) { |
| 2435 |
return ''; |
| 2436 |
} |
| 2437 |
$script_data[ $handle ] = $payload; |
| 2438 |
return $handle; |
| 2439 |
}; |
| 2440 |
|
| 2441 |
// Synthesized `openStationWindowConfig[ id ]` assignments, grouped |
| 2442 |
// by script handle (see the function docblock). Collected first so |
| 2443 |
// they can be appended to each handle's map entry exactly once, |
| 2444 |
// after its own harvested data — the same order the print pipeline |
| 2445 |
// would have used. |
| 2446 |
$config_snippets_by_handle = array(); |
| 2447 |
foreach ( $registry as $entry ) { |
| 2448 |
$handle = isset( $entry['script'] ) ? (string) $entry['script'] : ''; |
| 2449 |
if ( '' === $handle || ! is_callable( $entry['template'] ) ) { |
| 2450 |
continue; |
| 2451 |
} |
| 2452 |
$window_config = openstation_filter_native_window_config( $entry ); |
| 2453 |
if ( empty( $window_config ) ) { |
| 2454 |
continue; |
| 2455 |
} |
| 2456 |
$config_snippets_by_handle[ $handle ][ $entry['id'] ] = sprintf( |
| 2457 |
'window.openStationWindowConfig=window.openStationWindowConfig||{};window.openStationWindowConfig[%s]=%s;', |
| 2458 |
wp_json_encode( $entry['id'] ), |
| 2459 |
wp_json_encode( $window_config ) |
| 2460 |
); |
| 2461 |
} |
| 2462 |
|
| 2463 |
$out = array(); |
| 2464 |
foreach ( $registry as $entry ) { |
| 2465 |
if ( ! is_callable( $entry['template'] ) ) { |
| 2466 |
continue; |
| 2467 |
} |
| 2468 |
|
| 2469 |
// Capture the template HTML (tab-wrapped when any |
| 2470 |
// additional tabs are registered via |
| 2471 |
// `openstation_register_window_tab()`; flat otherwise). |
| 2472 |
// Captured as a string so the shell can inject it as a |
| 2473 |
// `<template>` at mid-session plugin activation without a |
| 2474 |
// reload. |
| 2475 |
$template_html = openstation_build_native_window_template_html( $entry ); |
| 2476 |
|
| 2477 |
// `$collect_handle()` answers "is there a bundle to fetch?", and |
| 2478 |
// returns '' when the handle resolves to no URL — a src-less |
| 2479 |
// alias handle registered only to carry `preload_script` or |
| 2480 |
// inline data, for instance. That is the right answer for |
| 2481 |
// `scriptHandle`, which names something to load. It is the |
| 2482 |
// wrong answer for `ownerHandle`, which names WHO the window |
| 2483 |
// belongs to: attribution does not depend on whether the owner |
| 2484 |
// happens to ship a file. Shipping '' there broke the |
| 2485 |
// documented "always populated" contract and blanked |
| 2486 |
// `wp.os.debug.window()`. |
| 2487 |
$declared_script = isset( $entry['script'] ) ? (string) $entry['script'] : ''; |
| 2488 |
$script_handle = $collect_handle( $declared_script ); |
| 2489 |
$owner_handle = '' !== $script_handle ? $script_handle : $declared_script; |
| 2490 |
|
| 2491 |
// Companion handles (`scripts` arg) — bundles that extend the |
| 2492 |
// window from outside it and must be in the tab before its |
| 2493 |
// render callback paints. Kept as an ordered handle list; the |
| 2494 |
// shell loads them in declared order ahead of the window's |
| 2495 |
// own script, resolving each through `scriptData`. |
| 2496 |
$companion_scripts = array(); |
| 2497 |
if ( ! empty( $entry['scripts'] ) && is_array( $entry['scripts'] ) ) { |
| 2498 |
foreach ( $entry['scripts'] as $companion_handle ) { |
| 2499 |
$companion_handle = $collect_handle( $companion_handle ); |
| 2500 |
if ( '' !== $companion_handle ) { |
| 2501 |
$companion_scripts[] = $companion_handle; |
| 2502 |
} |
| 2503 |
} |
| 2504 |
} |
| 2505 |
|
| 2506 |
// Resolve the optional style handle alongside the script so the |
| 2507 |
// shell's lazy-loader can inject a `<link rel="stylesheet">` |
| 2508 |
// (and any `wp_add_inline_style()` blobs) on mid-session |
| 2509 |
// activation. Empty payload when no handle was declared OR the |
| 2510 |
// handle isn't registered — both treated as "no styles to load." |
| 2511 |
$style_handle = isset( $entry['style'] ) ? (string) $entry['style'] : ''; |
| 2512 |
$style_payload = openstation_resolve_style_payload( $style_handle ); |
| 2513 |
|
| 2514 |
// Companion style handles (`styles` arg) — stylesheets the |
| 2515 |
// shell injects on the window's FIRST OPEN, after the window's |
| 2516 |
// own style, in declared order. The styles-side mirror of |
| 2517 |
// `companionScripts`, with different timing on purpose: the |
| 2518 |
// window's own `style` lands when the window registers so a |
| 2519 |
// mid-session activation paints, but a companion exists to be |
| 2520 |
// deferred — it costs nothing until the window is actually |
| 2521 |
// shown. Unregistered handles drop, same as script companions. |
| 2522 |
$companion_styles = array(); |
| 2523 |
if ( ! empty( $entry['styles'] ) && is_array( $entry['styles'] ) ) { |
| 2524 |
foreach ( $entry['styles'] as $companion_style_handle ) { |
| 2525 |
$companion_style_handle = (string) $companion_style_handle; |
| 2526 |
$companion_style_payload = openstation_resolve_style_payload( $companion_style_handle ); |
| 2527 |
if ( '' === $companion_style_payload['url'] ) { |
| 2528 |
continue; |
| 2529 |
} |
| 2530 |
$companion_styles[] = array( |
| 2531 |
'styleUrl' => $companion_style_payload['url'], |
| 2532 |
'styleHandle' => $companion_style_handle, |
| 2533 |
'styleInline' => $companion_style_payload['inline'], |
| 2534 |
); |
| 2535 |
} |
| 2536 |
} |
| 2537 |
|
| 2538 |
// Tab metadata ships alongside the template so the shell can |
| 2539 |
// render a picker UI, and each tab's script handle joins the |
| 2540 |
// map so a late tab activation can still load its bundle. |
| 2541 |
$tab_descriptors = array(); |
| 2542 |
if ( function_exists( 'openstation_get_native_window_tabs' ) ) { |
| 2543 |
foreach ( openstation_get_native_window_tabs( $entry['id'] ) as $tab ) { |
| 2544 |
$tab_descriptors[] = array( |
| 2545 |
'value' => $tab['value'], |
| 2546 |
'label' => $tab['label'], |
| 2547 |
'isMain' => $tab['is_main'], |
| 2548 |
'scriptHandle' => $collect_handle( $tab['script'] ), |
| 2549 |
); |
| 2550 |
} |
| 2551 |
} |
| 2552 |
|
| 2553 |
$out[] = array( |
| 2554 |
'id' => $entry['id'], |
| 2555 |
'title' => $entry['title'], |
| 2556 |
'icon' => $entry['icon'], |
| 2557 |
'placement' => $entry['placement'], |
| 2558 |
// `'app'` or `'control'` — the navigation kind, which |
| 2559 |
// decides the launcher's default placement and its dock |
| 2560 |
// zone. See `src/nav/defaults.ts`. |
| 2561 |
'navKind' => isset( $entry['nav_kind'] ) ? $entry['nav_kind'] : 'app', |
| 2562 |
// Sort key among system tiles. Absent / 0 puts a plugin's |
| 2563 |
// launcher ahead of the shell's own trailing cluster. |
| 2564 |
'dockOrder' => isset( $entry['dock_order'] ) ? (int) $entry['dock_order'] : 0, |
| 2565 |
'placeable' => ! empty( $entry['placeable'] ), |
| 2566 |
'width' => $entry['width'], |
| 2567 |
'height' => $entry['height'], |
| 2568 |
'minWidth' => $entry['min_width'], |
| 2569 |
'minHeight' => $entry['min_height'], |
| 2570 |
'autofocus' => $entry['autofocus'], |
| 2571 |
'templateId' => 'os-native-window-' . $entry['id'], |
| 2572 |
'templateHtml' => $template_html, |
| 2573 |
'scriptHandle' => $script_handle, |
| 2574 |
'ownerHandle' => $owner_handle, |
| 2575 |
'companionScripts' => $companion_scripts, |
| 2576 |
// Whether the shell loads the bundle at boot rather than on |
| 2577 |
// first open. Off by default: a window's script is dead |
| 2578 |
// weight on every admin page until the window is actually |
| 2579 |
// opened. |
| 2580 |
'preloadScript' => ! empty( $entry['preload_script'] ), |
| 2581 |
'styleUrl' => $style_payload['url'], |
| 2582 |
'styleHandle' => $style_handle, |
| 2583 |
'styleInline' => $style_payload['inline'], |
| 2584 |
'companionStyles' => $companion_styles, |
| 2585 |
'tabs' => $tab_descriptors, |
| 2586 |
); |
| 2587 |
} |
| 2588 |
|
| 2589 |
// Append each handle's synthesized config set to its map entry — |
| 2590 |
// once, after the handle's own harvested data. The snippets land |
| 2591 |
// in REGISTRY-ITERATION order for every consumer of the handle; |
| 2592 |
// the old per-entry shape put each window's own config first, an |
| 2593 |
// ordering nothing could observe (each snippet assigns a distinct |
| 2594 |
// `openStationWindowConfig[ id ]` key and none reads another), so |
| 2595 |
// it is deliberately not preserved. Configs for handles that |
| 2596 |
// resolved to nothing are undeliverable and drop, exactly as they |
| 2597 |
// always did. |
| 2598 |
foreach ( $config_snippets_by_handle as $handle => $snippets ) { |
| 2599 |
if ( ! isset( $script_data[ $handle ] ) ) { |
| 2600 |
continue; |
| 2601 |
} |
| 2602 |
foreach ( $snippets as $snippet ) { |
| 2603 |
$script_data[ $handle ]['l10n'][] = $snippet; |
| 2604 |
} |
| 2605 |
} |
| 2606 |
|
| 2607 |
return array( |
| 2608 |
'windows' => $out, |
| 2609 |
'scriptData' => $script_data, |
| 2610 |
); |
| 2611 |
} |
| 2612 |
|
| 2613 |
/** |
| 2614 |
* The `windows` half of {@see openstation_collect_native_windows_payload()}. |
| 2615 |
* |
| 2616 |
* Kept as the historical entry point — tests and older call sites |
| 2617 |
* ask for the entry list alone. Anything that also needs the |
| 2618 |
* script-data map (everything that actually LOADS a bundle) should |
| 2619 |
* call the collector and take both halves from one build. |
| 2620 |
* |
| 2621 |
* @return array[] |
| 2622 |
*/ |
| 2623 |
function openstation_build_native_windows_payload() { |
| 2624 |
$bundle = openstation_collect_native_windows_payload(); |
| 2625 |
return $bundle['windows']; |
| 2626 |
} |
| 2627 |
|
| 2628 |
/** |
| 2629 |
* Cleans a `$menu` / `$submenu` title for display. |
| 2630 |
* |
| 2631 |
* Strips badge spans first (`<span class="update-plugins count-3">`), |
| 2632 |
* then any remaining markup. An empty result means the entry has no |
| 2633 |
* usable label: plugins register `menu_title => null` to keep a page |
| 2634 |
* reachable while hiding its row from classic admin's left menu, and |
| 2635 |
* those must not become tabs. |
| 2636 |
* |
| 2637 |
* Shared so everything deciding "is this a visible tab?" agrees. |
| 2638 |
* {@see openstation_chromeless_submenu_tab_urls()} hides an in-page |
| 2639 |
* button on the strength of a tab existing, so a divergence here would |
| 2640 |
* hide a button with nothing on screen to replace it. |
| 2641 |
* |
| 2642 |
* @param string $raw_title Raw `$menu[$i][0]` / `$submenu[$p][$i][0]` value. |
| 2643 |
* @return string Cleaned title, empty when there is none. |
| 2644 |
*/ |
| 2645 |
function openstation_menu_item_title( $raw_title ) { |
| 2646 |
$stripped = preg_replace( '/<span[^>]*>.*?<\/span>/s', '', (string) $raw_title ); |
| 2647 |
|
| 2648 |
return trim( wp_strip_all_tags( $stripped ) ); |
| 2649 |
} |
| 2650 |
|
| 2651 |
/** |
| 2652 |
* Determines whether a menu slug references a real file under `wp-admin/`. |
| 2653 |
* |
| 2654 |
* Mirrors the decision core's `wp-admin/menu-header.php` makes when |
| 2655 |
* linking menu items: strip the query portion, then check whether the |
| 2656 |
* remaining path exists inside `wp-admin/`. Two registered-slug shapes |
| 2657 |
* hinge on this distinction: |
| 2658 |
* |
| 2659 |
* - URL-style slugs — ACF registers its top-level menu as |
| 2660 |
* `edit.php?post_type=acf-field-group` via `add_menu_page()`. The |
| 2661 |
* slug lands in `$_parent_pages`, but `edit.php` is a real admin |
| 2662 |
* file: classic admin links it directly, and routing it through |
| 2663 |
* `admin.php?page=…` makes core's dispatcher `wp_die()` with |
| 2664 |
* "Cannot load edit.php?post_type=acf-field-group." |
| 2665 |
* - Legacy file-path slugs — WP-Sweep registers |
| 2666 |
* `wp-sweep/admin.php` via `add_management_page()`. No such file |
| 2667 |
* exists under `wp-admin/`, so it must resolve as a plugin page |
| 2668 |
* (`tools.php?page=wp-sweep/admin.php`). |
| 2669 |
* |
| 2670 |
* @param string $slug The raw menu item slug. |
| 2671 |
* @return bool True when the query-stripped slug is a file under `wp-admin/`. |
| 2672 |
*/ |
| 2673 |
function openstation_is_admin_file_slug( $slug ) { |
| 2674 |
$file = $slug; |
| 2675 |
$pos = strpos( $file, '?' ); |
| 2676 |
if ( false !== $pos ) { |
| 2677 |
$file = substr( $file, 0, $pos ); |
| 2678 |
} |
| 2679 |
|
| 2680 |
if ( '' === $file || 0 !== validate_file( $file ) ) { |
| 2681 |
return false; |
| 2682 |
} |
| 2683 |
|
| 2684 |
return file_exists( ABSPATH . 'wp-admin/' . $file ); |
| 2685 |
} |
| 2686 |
|
| 2687 |
/** |
| 2688 |
* Converts a menu item slug to a full admin URL. |
| 2689 |
* |
| 2690 |
* Resolution goes through `self_admin_url()`, not `admin_url()`: in the |
| 2691 |
* network admin the same globals carry network slugs (`sites.php`, |
| 2692 |
* `settings.php`) that exist only under `wp-admin/network/`. |
| 2693 |
* |
| 2694 |
* Handles three slug shapes: |
| 2695 |
* 1. Direct file references (`edit.php`, `upload.php`) — passed |
| 2696 |
* through `self_admin_url()` as-is. |
| 2697 |
* 2. Plain plugin page slugs (`my-plugin`) — routed through |
| 2698 |
* `admin.php?page=<slug>` with the slug `rawurlencode()`d. |
| 2699 |
* 3. Plugin page slugs that embed extra query parameters |
| 2700 |
* (`wc-admin&path=/customers`) — split on the first `&`, the |
| 2701 |
* page portion is `rawurlencode()`d, the trailing query is |
| 2702 |
* reparsed and reassembled with `add_query_arg()` so each |
| 2703 |
* value is encoded once and the `&` separators are preserved. |
| 2704 |
* |
| 2705 |
* The third shape is unusual but legal — WordPress's |
| 2706 |
* `add_submenu_page()` accepts a slug containing query |
| 2707 |
* parameters and routes them through `admin.php`. WooCommerce |
| 2708 |
* uses this pattern for every wc-admin React route |
| 2709 |
* (`Customers`, `Analytics`, `Marketing`). Without the split |
| 2710 |
* branch the entire string gets `rawurlencode()`d into the |
| 2711 |
* `page` parameter, mangling `&` to `%26` and `=` to `%3D` — |
| 2712 |
* WC's router never sees `path` and the page renders blank. |
| 2713 |
* |
| 2714 |
* Returns an `esc_url_raw()`-sanitized URL — these URLs flow |
| 2715 |
* into the dock JS payload (JSON-encoded, then assigned to |
| 2716 |
* `iframe.src` / `window.location.href`), not into HTML |
| 2717 |
* attributes. Using `esc_url()` would emit `&` for the `&` |
| 2718 |
* separators, which the browser does NOT decode in JS string |
| 2719 |
* contexts — the resulting iframe load would treat `&path` |
| 2720 |
* as a literal query key and miss the `path` parameter, sending |
| 2721 |
* WC's router back to home instead of the requested route. |
| 2722 |
* |
| 2723 |
* @param string $slug The menu item slug or URL. |
| 2724 |
* @return string The full admin URL, sanitized via `esc_url_raw()`. |
| 2725 |
*/ |
| 2726 |
function openstation_menu_item_url( $slug ) { |
| 2727 |
// Already a full URL. |
| 2728 |
if ( str_starts_with( $slug, 'http://' ) || str_starts_with( $slug, 'https://' ) ) { |
| 2729 |
return esc_url_raw( $slug ); |
| 2730 |
} |
| 2731 |
|
| 2732 |
// Strip path traversal sequences. |
| 2733 |
$slug = str_replace( '..', '', $slug ); |
| 2734 |
|
| 2735 |
global $_parent_pages; |
| 2736 |
|
| 2737 |
// Direct file reference (e.g., 'edit.php', 'upload.php') — but |
| 2738 |
// NOT a registered plugin page that merely looks like one. |
| 2739 |
// Legacy file-path slugs (WP-Sweep's 'wp-sweep/admin.php', |
| 2740 |
// registered via add_management_page()) contain '.php' yet are |
| 2741 |
// page slugs, not admin-root files; `$_parent_pages` is keyed by |
| 2742 |
// the raw registered slug, so a hit there routes the slug to the |
| 2743 |
// canonical resolver below (→ `tools.php?page=wp-sweep/admin.php`, |
| 2744 |
// byte-identical to what core's menu_page_url() builds) instead |
| 2745 |
// of a 404 at `admin_url( 'wp-sweep/admin.php' )`. |
| 2746 |
// |
| 2747 |
// The reverse also happens: URL-style slugs registered through |
| 2748 |
// `add_menu_page()` / `add_submenu_page()` (ACF's |
| 2749 |
// 'edit.php?post_type=acf-field-group') sit in `$_parent_pages` |
| 2750 |
// too, yet reference a real `wp-admin/` file — those must stay |
| 2751 |
// direct links, or core's `admin.php` dispatcher dies with |
| 2752 |
// "Cannot load edit.php?post_type=acf-field-group." The admin- |
| 2753 |
// file check wins over the registration check, same as classic |
| 2754 |
// admin's `menu-header.php`. |
| 2755 |
if ( |
| 2756 |
false !== strpos( $slug, '.php' ) && |
| 2757 |
( ! isset( $_parent_pages[ $slug ] ) || openstation_is_admin_file_slug( $slug ) ) |
| 2758 |
) { |
| 2759 |
return esc_url_raw( self_admin_url( $slug ) ); |
| 2760 |
} |
| 2761 |
|
| 2762 |
// Plugin page slug with embedded query parameters |
| 2763 |
// (e.g., 'wc-admin&path=/customers'). Split the page slug from |
| 2764 |
// the trailing args; we'll resolve the page slug below and |
| 2765 |
// layer the args back on at the end. This avoids the naive |
| 2766 |
// `rawurlencode()` packing the `&` separator into `%26`. |
| 2767 |
$extra_args = array(); |
| 2768 |
if ( false !== strpos( $slug, '&' ) ) { |
| 2769 |
list( $slug, $tail ) = array_pad( explode( '&', $slug, 2 ), 2, '' ); |
| 2770 |
if ( '' !== $tail ) { |
| 2771 |
parse_str( $tail, $extra_args ); |
| 2772 |
} |
| 2773 |
} |
| 2774 |
|
| 2775 |
// Plain page slug — defer to WordPress's canonical resolver. |
| 2776 |
// |
| 2777 |
// `$_parent_pages` is the same global `menu_page_url()` reads; |
| 2778 |
// we mirror its 4-line decision tree directly so we can return |
| 2779 |
// a `esc_url_raw`-style raw URL (the `menu_page_url()` helper |
| 2780 |
// runs its result through `esc_url()`, which entity-encodes the |
| 2781 |
// `&` separators we need to keep raw for the downstream |
| 2782 |
// `add_query_arg()` and the JS slug compare). |
| 2783 |
// |
| 2784 |
// Resolution rules, identical to core: |
| 2785 |
// 1. Slug registered under a `.php` parent that itself isn't |
| 2786 |
// a parent (Tools → `tools.php?page=…`, Settings → |
| 2787 |
// `options-general.php?page=…`). |
| 2788 |
// 2. Slug registered as a top-level menu, OR under a slug- |
| 2789 |
// based parent (WC: `woocommerce` → `admin.php?page=…`). |
| 2790 |
// 3. Slug not registered at all → fall back to `admin.php` |
| 2791 |
// so the URL still targets a real dispatcher (matches the |
| 2792 |
// pre-resolver behavior callers depended on). |
| 2793 |
$host = 'admin.php?page=' . rawurlencode( $slug ); |
| 2794 |
if ( isset( $_parent_pages[ $slug ] ) ) { |
| 2795 |
$parent_slug = $_parent_pages[ $slug ]; |
| 2796 |
if ( $parent_slug && ! isset( $_parent_pages[ $parent_slug ] ) ) { |
| 2797 |
$host = add_query_arg( 'page', $slug, $parent_slug ); |
| 2798 |
} |
| 2799 |
} |
| 2800 |
|
| 2801 |
$url = self_admin_url( $host ); |
| 2802 |
if ( ! empty( $extra_args ) ) { |
| 2803 |
$url = add_query_arg( $extra_args, $url ); |
| 2804 |
} |
| 2805 |
return esc_url_raw( $url ); |
| 2806 |
} |
| 2807 |
|