PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.13
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.13
1.1.13 1.1.12 1.1.11 1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 All 37 releases
← All changes | includes/core/payload.php +323 -62 1.1.6 → 1.1.13 View file →
@@ -348,8 +348,11 @@
348 348 }
349 349 $sub_entry = array(
350 350 'title' => $sub_title,
351 351 'url' => $sub_url,
352 + // The registered slug, kept for the window-tab merge
353 + // below and stripped again before the payload ships.
354 + 'slug' => (string) $row['slug'],
352 355 );
353 356 if ( $row['external'] ) {
354 357 // Consumers that route a URL into a window skip these;
355 358 // the ones that can hand a link to the browser mark
@@ -399,8 +402,47 @@
399 402 if ( $parent_is_container && $url === $parent_url ) {
400 403 continue;
401 404 }
402 405
406 + // A native window in charge of this menu owns its rows too:
407 + // whatever it offers as a tab, the dock offers as a row, same
408 + // labels and same order, each one tagged with the tab it
409 + // opens. The first tab IS the menu's own page, so it becomes
410 + // the self-label rather than a second row for the tile's own
411 + // destination. See `App::menu()`.
412 + $window_tabs = openstation_app_menu_tabs( $identity_slug );
413 + if ( $window_tabs ) {
414 + $self_label = $window_tabs[0]['label'];
415 + $claimed = array();
416 + foreach ( $window_tabs as $tab ) {
417 + if ( '' !== $tab['page'] ) {
418 + $claimed[] = $tab['page'];
419 + }
420 + }
421 + // A page this window has no tab for is still a page: a
422 + // plugin's screen registered under this menu, a taxonomy
423 + // someone added. Dropping those would make them
424 + // unreachable from the dock, so they follow the window's
425 + // own rows rather than being replaced by them.
426 + $kept = array();
427 + foreach ( $sub_items as $sub_entry ) {
428 + if ( ! in_array( $sub_entry['slug'], $claimed, true ) ) {
429 + $kept[] = $sub_entry;
430 + }
431 + }
432 + $sub_items = array();
433 + foreach ( array_slice( $window_tabs, 1 ) as $tab ) {
434 + $sub_items[] = array(
435 + 'title' => $tab['label'],
436 + 'url' => add_query_arg( 'os_tab', $tab['id'], $url ),
437 + );
438 + }
439 + $sub_items = array_merge( $sub_items, $kept );
440 + }
441 + foreach ( $sub_items as $i => $sub_entry ) {
442 + unset( $sub_items[ $i ]['slug'] );
443 + }
444 +
403 445 $dock_item = array(
404 446 'id' => sanitize_key( $item[5] ?? $item[2] ),
405 447 'title' => $title,
406 448 'icon' => $icon,
@@ -454,11 +496,12 @@
454 496 * URLs) would therefore fill the dock with tiles that can only ever
455 497 * escape to a browser tab, which breaks the shell's navigation model.
456 498 * Those entries are dropped from the payload instead.
457 499 *
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.
500 + * The menu's own admin (`openstation_menu_admin_url()`), `admin_url()`
501 + * and `home_url()` hosts all count as ours: a site can run its admin on
502 + * a different domain than its front end, and the network admin lives on
503 + * the network's own.
461 504 *
462 505 * @param string $url Absolute URL, as returned by `openstation_menu_item_url()`.
463 506 * @return bool True when the URL is off-site.
464 507 */
@@ -467,9 +510,9 @@
467 510 $external = false;
468 511
469 512 if ( $host ) {
470 513 $ours = array();
471 - foreach ( array( self_admin_url(), admin_url(), home_url() ) as $known ) {
514 + foreach ( array( openstation_menu_admin_url(), admin_url(), home_url() ) as $known ) {
472 515 $known_host = wp_parse_url( $known, PHP_URL_HOST );
473 516 if ( $known_host ) {
474 517 $ours[] = strtolower( $known_host );
475 518 }
@@ -729,12 +772,13 @@
729 772
730 773 /**
731 774 * Filters whether a dock item supports multiple open windows.
732 775 *
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.
776 + * Return true to advertise this page as multi-capable: an instance
777 + * rail appears under the dock icon and an "Open another" action
778 + * becomes available in the window's title-bar menu. It does not gate
779 + * the submenu, which opens a window of its own on every pick either
780 + * way; a tile click focuses the menu's open window.
737 781 *
738 782 * @param bool $multi Whether this page is multi-capable.
739 783 * @param string $menu_slug The menu slug (e.g. `edit.php?post_type=page`).
740 784 */
@@ -983,9 +1027,9 @@
983 1027 if ( ! is_string( $file ) || '' === $file ) {
984 1028 return null;
985 1029 }
986 1030 $plugins_dir = wp_normalize_path( WP_PLUGIN_DIR );
987 - $norm = wp_normalize_path( $file );
1031 + $norm = openstation_plugin_link_path( wp_normalize_path( $file ) );
988 1032 if ( 0 !== strpos( $norm, $plugins_dir . '/' ) ) {
989 1033 return null;
990 1034 }
991 1035 if ( ! function_exists( 'get_plugins' ) ) {
@@ -1454,8 +1498,9 @@
1454 1498 $norm = wp_normalize_path( (string) $frame['file'] );
1455 1499 if ( '' !== $self_dir && 0 === strpos( $norm, $self_dir ) ) {
1456 1500 continue;
1457 1501 }
1502 + $norm = openstation_plugin_link_path( $norm );
1458 1503 foreach ( $dirs as $dir ) {
1459 1504 if ( 0 === strpos( $norm, $dir ) ) {
1460 1505 return $norm;
1461 1506 }
@@ -1463,8 +1508,36 @@
1463 1508 }
1464 1509 return null;
1465 1510 }
1466 1511
1512 +/**
1513 + * Map a path PHP reported back to the plugin folder WordPress loaded
1514 + * it through.
1515 + *
1516 + * PHP resolves symlinks in file names, so a plugin folder linked into
1517 + * `plugins/` from elsewhere shows up in backtraces and reflection outside
1518 + * `WP_PLUGIN_DIR`. WordPress.com's managed plugins are linked that way:
1519 + * `plugins/woocommerce` resolves to `/wordpress/plugins/woocommerce/<version>/`.
1520 + * Core records each active plugin's link and target in
1521 + * `$wp_plugin_paths` before loading it, and `plugin_basename()` maps
1522 + * through the same list.
1523 + *
1524 + * @param string $path Normalized absolute path.
1525 + * @return string The same file under the plugin's link, or `$path`
1526 + * when no active plugin is linked from there.
1527 + */
1528 +function openstation_plugin_link_path( $path ) {
1529 + global $wp_plugin_paths;
1530 +
1531 + foreach ( (array) $wp_plugin_paths as $link => $target ) {
1532 + $target = trailingslashit( (string) $target );
1533 + if ( 0 === strpos( $path, $target ) ) {
1534 + return trailingslashit( (string) $link ) . substr( $path, strlen( $target ) );
1535 + }
1536 + }
1537 + return $path;
1538 +}
1539 +
1467 1540 add_action(
1468 1541 'registered_post_type',
1469 1542 static function ( $post_type ) {
1470 1543 openstation_record_type_registrant( $post_type, 'post_type' );
@@ -1662,8 +1735,15 @@
1662 1735 'url' => network_admin_url( 'update-core.php' ),
1663 1736 );
1664 1737 }
1665 1738
1739 + // The site switcher's rows: on a network, the instances this shell
1740 + // may switch to (`openstation_multisite_payload()`), null elsewhere.
1741 + // The Network app spends a menu refresh after every action that
1742 + // changes them (add, remove, join, leave, sync), so the row above
1743 + // overview's desktop tiles follows the registry without a reload.
1744 + $payload['multisite'] = openstation_multisite_payload();
1745 +
1666 1746 // A cheap structural fingerprint of the admin menu the shell uses to
1667 1747 // decide whether a live refresh is warranted. Shipped in every full
1668 1748 // payload so the shell can seed / update its last-known signature
1669 1749 // without recomputing it client-side (which would risk drift from
@@ -1670,8 +1750,13 @@
1670 1750 // the server's capability-gated view). See
1671 1751 // openstation_menu_signature().
1672 1752 $payload['menuSig'] = openstation_menu_signature();
1673 1753
1754 + // Each script dependency's payload once, entries carry handles.
1755 + $script_dep_payloads = array();
1756 + $payload = openstation_compact_script_deps( $payload, $script_dep_payloads );
1757 + $payload['scriptDepPayloads'] = (object) $script_dep_payloads;
1758 +
1674 1759 return $payload;
1675 1760 }
1676 1761
1677 1762 /**
@@ -1865,8 +1950,12 @@
1865 1950 if ( $dep_handle === $handle ) {
1866 1951 continue;
1867 1952 }
1868 1953 $payload = openstation_resolve_script_payload( $dep_handle );
1954 + // An alias (no `src`) stays in the list when it carries inline
1955 + // data — that data is the whole reason it was declared, and a
1956 + // plugin's config blob commonly rides one. Nothing to fetch
1957 + // AND nothing to run is the only thing dropped.
1869 1958 if ( '' === $payload['url']
1870 1959 && empty( $payload['before'] )
1871 1960 && empty( $payload['after'] )
1872 1961 && empty( $payload['l10n'] ) ) {
@@ -1887,8 +1976,102 @@
1887 1976 return $out;
1888 1977 }
1889 1978
1890 1979 /**
1980 + * Move every entry's `scriptDeps` payloads into one map.
1981 + *
1982 + * {@see openstation_resolve_script_dependencies()} returns the full
1983 + * payload of each dependency (URL, l10n, before/after), and ~20 entry
1984 + * builders call it. A dependency shared by N entries was therefore
1985 + * serialized N times: one plugin's 5.5 KB localized object became
1986 + * ~400 KB of a 489 KB `openStationConfig` (GH#892). This replaces each
1987 + * entry's `scriptDeps` list with its handles and puts each handle's
1988 + * payload in `$map` once. The shell resolves the handles back before
1989 + * any consumer reads them; see `src/script-dep-payloads.ts`.
1990 + *
1991 + * ENTRY DEPTH ONLY. `$payload` is a payload whose top-level values are
1992 + * entry lists (`serverWidgets`, `serverCommandScripts`, ...), and only
1993 + * a `scriptDeps` sitting directly on one of those entries is touched.
1994 + * The key is the shell's there. Deeper down it is a plugin's metadata
1995 + * (`settings => [ 'scriptDeps' => ... ]`), and rewriting it would hoist
1996 + * foreign data into the map first-wins and hydrate every real
1997 + * dependency of that handle to it. Scoping by depth rather than by a
1998 + * list of keys means a new builder is covered without an edit here.
1999 + * It also leaves every other branch of the payload unassigned, so
2000 + * PHP's copy-on-write never has to copy them.
2001 + *
2002 + * Every string left in a compacted list has an entry in `$map`: a bare
2003 + * handle a builder (or a filter on one) emitted is resolved here, by
2004 + * the same rule as {@see openstation_resolve_script_dependencies()}.
2005 + * A handle with nothing to fetch and nothing to run is dropped, as it
2006 + * is there. The client can then treat a string with no map entry as a
2007 + * payload from somewhere else, not a dependency this side dropped.
2008 + *
2009 + * Runs on the finished payload, so every builder, and every filter on
2010 + * a builder's output, still sees the full shape.
2011 + *
2012 + * @param array $payload Payload whose top-level values are entry lists.
2013 + * @param array $map Handle => dependency payload, filled in place.
2014 + * @return array The payload with each entry's `scriptDeps` reduced to handles.
2015 + */
2016 +function openstation_compact_script_deps( $payload, array &$map ) {
2017 + if ( ! is_array( $payload ) ) {
2018 + return $payload;
2019 + }
2020 + foreach ( $payload as $list_key => $entries ) {
2021 + if ( ! is_array( $entries ) ) {
2022 + continue;
2023 + }
2024 + foreach ( $entries as $entry_key => $entry ) {
2025 + if ( ! is_array( $entry ) || ! isset( $entry['scriptDeps'] ) || ! is_array( $entry['scriptDeps'] ) ) {
2026 + continue;
2027 + }
2028 + $payload[ $list_key ][ $entry_key ]['scriptDeps'] = openstation_compact_script_dep_list( $entry['scriptDeps'], $map );
2029 + }
2030 + }
2031 + return $payload;
2032 +}
2033 +
2034 +/**
2035 + * One entry's `scriptDeps` list, reduced to handles.
2036 + *
2037 + * @param array $deps Dependency payloads and/or bare handles.
2038 + * @param array $map Handle => dependency payload, filled in place.
2039 + * @return array Handles, in order; anything unkeyable passes through.
2040 + */
2041 +function openstation_compact_script_dep_list( array $deps, array &$map ) {
2042 + $handles = array();
2043 + foreach ( $deps as $dep ) {
2044 + if ( is_string( $dep ) && '' !== $dep ) {
2045 + if ( ! isset( $map[ $dep ] ) ) {
2046 + $payload = openstation_resolve_script_payload( $dep );
2047 + if ( '' === $payload['url']
2048 + && empty( $payload['before'] )
2049 + && empty( $payload['after'] )
2050 + && empty( $payload['l10n'] ) ) {
2051 + continue;
2052 + }
2053 + $payload['handle'] = $dep;
2054 + $map[ $dep ] = $payload;
2055 + }
2056 + $handles[] = $dep;
2057 + continue;
2058 + }
2059 + if ( is_array( $dep ) && isset( $dep['handle'] ) && '' !== (string) $dep['handle'] ) {
2060 + $handle = (string) $dep['handle'];
2061 + if ( ! isset( $map[ $handle ] ) ) {
2062 + $map[ $handle ] = $dep;
2063 + }
2064 + $handles[] = $handle;
2065 + continue;
2066 + }
2067 + // A handle-less payload has nothing to key it by.
2068 + $handles[] = $dep;
2069 + }
2070 + return $handles;
2071 +}
2072 +
2073 +/**
1891 2074 * Resolve a registered WP script handle into the full payload the
1892 2075 * shell needs to lazy-load it without going through `wp_print_scripts()`.
1893 2076 *
1894 2077 * Returns:
@@ -1912,10 +2095,14 @@
1912 2095 * around the lazy `<script src>` in the same order
1913 2096 * `WP_Scripts::do_item()` would have used.
1914 2097 *
1915 2098 * Returns an empty payload (`array( 'url' => '' )`) when the handle
1916 - * is unregistered or has no source — callers treat that as "no
1917 - * script to load."
2099 + * is unregistered. A registered handle with no source — an alias
2100 + * carrying only inline data — also comes back with an empty `url`,
2101 + * but its `before` / `after` / `l10n` are kept: callers that load a
2102 + * bundle treat an empty `url` as "nothing to fetch", and the
2103 + * dependency walk ({@see openstation_resolve_script_dependencies()})
2104 + * still replays what the alias would have printed.
1918 2105 *
1919 2106 * Shared between `openstation_register_window()` and
1920 2107 * `openstation_register_widget()` (and every other registration that
1921 2108 * relies on lazy script loading in the shell) because all of them
@@ -1943,20 +2130,31 @@
1943 2130 return $empty;
1944 2131 }
1945 2132 $registered = $wp_scripts->registered[ $handle ];
1946 2133 $src = is_string( $registered->src ) ? $registered->src : '';
1947 - if ( '' === $src ) {
1948 - return $empty;
1949 - }
1950 2134
1951 - // Normalize relative paths + attach cache-bust ver.
1952 - $resolved = $src;
1953 - if ( 0 === strpos( $resolved, '/' ) && 0 !== strpos( $resolved, '//' ) ) {
1954 - $resolved = site_url( $resolved );
2135 + // A handle with no `src` is an ALIAS — WordPress's supported way
2136 + // to ship inline-only JavaScript (`wp_register_script( $h, false )`
2137 + // plus `wp_add_inline_script()`), and a common home for a plugin's
2138 + // config blob: registering it as a *dependency* of every bundle is
2139 + // what guarantees the config runs first, whatever the enqueue
2140 + // order. `WP_Scripts::do_item()` prints an alias's localized data
2141 + // and its before/after snippets and returns before the `<script
2142 + // src>` it does not have. The payload mirrors that: `url` stays
2143 + // empty (there is nothing to fetch) and the inline data is kept,
2144 + // so a dependency walk can replay it. Translations are not: Core
2145 + // only prints those for a handle it printed a tag for.
2146 + $resolved = '';
2147 + if ( '' !== $src ) {
2148 + // Normalize relative paths + attach cache-bust ver.
2149 + $resolved = $src;
2150 + if ( 0 === strpos( $resolved, '/' ) && 0 !== strpos( $resolved, '//' ) ) {
2151 + $resolved = site_url( $resolved );
2152 + }
2153 + if ( ! empty( $registered->ver ) ) {
2154 + $resolved = add_query_arg( 'ver', $registered->ver, $resolved );
2155 + }
1955 2156 }
1956 - if ( ! empty( $registered->ver ) ) {
1957 - $resolved = add_query_arg( 'ver', $registered->ver, $resolved );
1958 - }
1959 2157
1960 2158 // Harvest `extra` data the lazy-load path would otherwise drop.
1961 2159 $before = array();
1962 2160 $after = array();
@@ -1991,9 +2189,9 @@
1991 2189 // `wp.i18n.setLocaleData( JSON, 'domain' )` snippet that the print
1992 2190 // pipeline emits before the script body. `print_translations(
1993 2191 // $handle, false )` returns the snippet without echoing.
1994 2192 $translations = '';
1995 - if ( method_exists( $wp_scripts, 'print_translations' ) ) {
2193 + if ( '' !== $resolved && method_exists( $wp_scripts, 'print_translations' ) ) {
1996 2194 $captured = $wp_scripts->print_translations( $handle, false );
1997 2195 if ( is_string( $captured ) ) {
1998 2196 $translations = $captured;
1999 2197 }
@@ -2167,24 +2365,16 @@
2167 2365 $script_probe->done = array();
2168 2366 $script_probe->all_deps( $script_roots );
2169 2367 foreach ( $script_probe->to_do as $handle ) {
2170 2368 $payload = openstation_resolve_script_payload( $handle );
2171 - if ( '' === $payload['url'] ) {
2172 - // Src-less aggregator — keep it only for its inline data.
2173 - $registered = isset( $scripts->registered[ $handle ] ) ? $scripts->registered[ $handle ] : null;
2174 - if ( $registered ) {
2175 - foreach ( array( 'before', 'after' ) as $position ) {
2176 - if ( isset( $registered->extra[ $position ] ) && is_array( $registered->extra[ $position ] ) ) {
2177 - $payload[ $position ] = array_values( array_filter( array_map( 'strval', $registered->extra[ $position ] ) ) );
2178 - }
2179 - }
2180 - if ( ! empty( $registered->extra['data'] ) && is_string( $registered->extra['data'] ) ) {
2181 - $payload['l10n'][] = $registered->extra['data'];
2182 - }
2183 - }
2184 - if ( empty( $payload['before'] ) && empty( $payload['after'] ) && empty( $payload['l10n'] ) ) {
2185 - continue;
2186 - }
2369 + // A src-less aggregator is kept only for its inline data — the
2370 + // resolver harvests that for an alias — and dropped when it
2371 + // carries none.
2372 + if ( '' === $payload['url']
2373 + && empty( $payload['before'] )
2374 + && empty( $payload['after'] )
2375 + && empty( $payload['l10n'] ) ) {
2376 + continue;
2187 2377 }
2188 2378 // Core's `initializeCommandPalette( {…} )` inline embeds the
2189 2379 // serialized admin-menu command list — ~20 KB that the boot
2190 2380 // page ALREADY carries as `window.__openStationMenuCommands`
@@ -2358,8 +2548,12 @@
2358 2548 * harvested `wp_localize_script` / `wp_add_inline_script` /
2359 2549 * translations, see `openstation_resolve_script_payload()` — lives
2360 2550 * ONCE per handle in `scriptData`, and the shell joins the two on
2361 2551 * receipt (`hydrateServerEntries()` in `src/native-windows.ts`).
2552 + * Each loadable handle's entry also names its dependency closure in
2553 + * `deps` (ordered handles, every one of them a key of the same map)
2554 + * so the lazy loader can bring a bundle's declared packages — and
2555 + * a src-less alias carrying its config — into the tab before it.
2362 2556 *
2363 2557 * The split exists because script data is a property of the HANDLE,
2364 2558 * not of the window: every App Framework window rides
2365 2559 * `openstation-app-runtime`, and inlining each entry's resolved copy
@@ -2376,9 +2570,9 @@
2376 2570 * Style data stays inline on the entries — it never had a
2377 2571 * duplication problem worth a second map ( companion styles across
2378 2572 * the whole registry total ~2 KB ).
2379 2573 *
2380 - * @return array{windows:array[],scriptData:array<string,array{url:string,before:string[],after:string[],l10n:string[],translations:string}>}
2574 + * @return array{windows:array[],scriptData:array<string,array{url:string,before:string[],after:string[],l10n:string[],translations:string,deps:string[]}>}
2381 2575 */
2382 2576 function openstation_collect_native_windows_payload() {
2383 2577 $empty = array(
2384 2578 'windows' => array(),
@@ -2387,44 +2581,82 @@
2387 2581 if ( ! function_exists( 'openstation_native_window_registry' ) ) {
2388 2582 return $empty;
2389 2583 }
2390 2584
2391 - // Every native window is site-scoped, reading the current site's
2392 - // REST API, so in the network admin a `users.php` tile meaning
2393 - // "everyone on the network" would open one site's user list.
2394 - //
2395 - // This is also what disarms the client-side URL remaps there: they
2396 - // match on the tail of a pathname (`endsWith( '/users.php' )`) and
2397 - // the network admin serves same-named files one directory down, but
2398 - // with nothing registered `openById()` finds no window and the
2399 - // remap falls through to the iframe.
2400 - if ( is_network_admin() ) {
2401 - return $empty;
2402 - }
2403 -
2404 2585 $registry = openstation_native_window_registry();
2405 2586 if ( ! is_array( $registry ) ) {
2406 2587 return $empty;
2407 2588 }
2408 2589
2590 + // A window says which admin offers it (`admin` in its registration:
2591 + // `site`, `network` or `any`). Every native window OpenStation
2592 + // ships is site-scoped, reading the current site's REST API, so in
2593 + // the network admin a `users.php` tile meaning "everyone on the
2594 + // network" would open one site's user list; those stay off the
2595 + // network shell. A window that declares `network` (the Network app)
2596 + // is offered there and nowhere else.
2597 + //
2598 + // Dropping the site windows there is also what disarms the
2599 + // client-side URL remaps: they match on the tail of a pathname
2600 + // (`endsWith( '/users.php' )`) and the network admin serves
2601 + // same-named files one directory down, but with nothing registered
2602 + // `openById()` finds no window and the remap falls through to the
2603 + // iframe.
2604 + $registry = array_filter( $registry, 'openstation_native_window_offered_here' );
2605 +
2409 2606 $script_data = array();
2410 2607
2608 + // Handles resolved as a bundle to LOAD (a window's script, a
2609 + // companion, a tab) and what that visit answered — the handle, or
2610 + // '' for nothing to load — as opposed to reached only as
2611 + // somebody's dependency. A handle can be both — resolved as a
2612 + // dependency first, then named as a window's own script — and
2613 + // only the bundle visit computes its own closure.
2614 + $resolved_as_bundle = array();
2615 +
2411 2616 // Resolve a handle into the map, once. Returns the handle when it
2412 2617 // resolved to something loadable, '' when it did not (never
2413 2618 // registered, no src) — the same silent drop the inline shape
2414 2619 // applied to companions and tab scripts.
2415 - $collect_handle = static function ( $handle ) use ( &$script_data ) {
2620 + //
2621 + // The handle's dependency closure rides along as `deps`: an
2622 + // ordered handle list, each of which lands in the same map. A
2623 + // bundle delivered lazily never goes through WordPress's own
2624 + // dependency resolution — the loader injects one URL — so a
2625 + // window declaring `wp-api-fetch` found `wp.apiFetch` undefined,
2626 + // and one whose config rides a src-less alias handle (a common
2627 + // shape: `wp_register_script( $h, false )` plus
2628 + // `wp_add_inline_script()`, declared as the bundle's dependency
2629 + // so it always runs first) booted with no config at all. Anything
2630 + // the document already ran is skipped on the client, so a page
2631 + // that carried the packages anyway pays nothing.
2632 + $collect_handle = static function ( $handle ) use ( &$script_data, &$resolved_as_bundle ) {
2416 2633 $handle = (string) $handle;
2417 2634 if ( '' === $handle ) {
2418 2635 return '';
2419 2636 }
2420 - if ( isset( $script_data[ $handle ] ) ) {
2421 - return $handle;
2637 + if ( isset( $resolved_as_bundle[ $handle ] ) ) {
2638 + return $resolved_as_bundle[ $handle ];
2422 2639 }
2423 - $payload = openstation_resolve_script_payload( $handle );
2640 + $payload = isset( $script_data[ $handle ] )
2641 + ? $script_data[ $handle ]
2642 + : openstation_resolve_script_payload( $handle );
2424 2643 if ( '' === $payload['url'] ) {
2644 + $resolved_as_bundle[ $handle ] = '';
2425 2645 return '';
2426 2646 }
2647 + $resolved_as_bundle[ $handle ] = $handle;
2648 + $deps = array();
2649 + foreach ( openstation_resolve_script_dependencies( $handle ) as $dep ) {
2650 + $dep_handle = (string) $dep['handle'];
2651 + unset( $dep['handle'] );
2652 + if ( ! isset( $script_data[ $dep_handle ] ) ) {
2653 + $dep['deps'] = array();
2654 + $script_data[ $dep_handle ] = $dep;
2655 + }
2656 + $deps[] = $dep_handle;
2657 + }
2658 + $payload['deps'] = $deps;
2427 2659 $script_data[ $handle ] = $payload;
2428 2660 return $handle;
2429 2661 };
2430 2662
@@ -2572,8 +2804,9 @@
2572 2804 'styleHandle' => $style_handle,
2573 2805 'styleInline' => $style_payload['inline'],
2574 2806 'companionStyles' => $companion_styles,
2575 2807 'tabs' => $tab_descriptors,
2808 + 'menuPages' => isset( $entry['menu_pages'] ) ? array_values( (array) $entry['menu_pages'] ) : array(),
2576 2809 );
2577 2810 }
2578 2811
2579 2812 // Append each handle's synthesized config set to its map entry —
@@ -2674,17 +2907,45 @@
2674 2907 return file_exists( ABSPATH . 'wp-admin/' . $file );
2675 2908 }
2676 2909
2677 2910 /**
2911 + * The admin URL a menu slug resolves against.
2912 + *
2913 + * Follows the admin the request is in: the network admin's own URL there,
2914 + * because its globals carry network slugs (`sites.php`, `settings.php`)
2915 + * that exist only under `wp-admin/network/`, and the site admin's
2916 + * everywhere else.
2917 + *
2918 + * The same answer `self_admin_url()` gives, without its filter. That
2919 + * filter receives the path, so a host can use it to send one screen
2920 + * somewhere else, and WordPress.com points `plugin-install.php` at its own
2921 + * installer. Resolved through it, the wp-admin original of a menu row the
2922 + * host replaced reads as off-site, and the dock drops it along with the
2923 + * replacement, which is how Plugins > Add Plugin disappears there.
2924 + *
2925 + * @param string $path Optional. Path relative to the admin URL.
2926 + * @return string Absolute admin URL.
2927 + */
2928 +function openstation_menu_admin_url( $path = '' ) {
2929 + if ( is_network_admin() ) {
2930 + return network_admin_url( $path );
2931 + }
2932 + if ( is_user_admin() ) {
2933 + return user_admin_url( $path );
2934 + }
2935 + return admin_url( $path );
2936 +}
2937 +
2938 +/**
2678 2939 * Converts a menu item slug to a full admin URL.
2679 2940 *
2680 - * Resolution goes through `self_admin_url()`, not `admin_url()`: in the
2681 - * network admin the same globals carry network slugs (`sites.php`,
2682 - * `settings.php`) that exist only under `wp-admin/network/`.
2941 + * Resolution goes through {@see openstation_menu_admin_url()}, which
2942 + * follows the admin the request is in without passing through the
2943 + * filterable `self_admin_url()`.
2683 2944 *
2684 2945 * Handles three slug shapes:
2685 2946 * 1. Direct file references (`edit.php`, `upload.php`) — passed
2686 - * through `self_admin_url()` as-is.
2947 + * through `openstation_menu_admin_url()` as-is.
2687 2948 * 2. Plain plugin page slugs (`my-plugin`) — routed through
2688 2949 * `admin.php?page=<slug>` with the slug `rawurlencode()`d.
2689 2950 * 3. Plugin page slugs that embed extra query parameters
2690 2951 * (`wc-admin&path=/customers`) — split on the first `&`, the
@@ -2745,9 +3006,9 @@
2745 3006 if (
2746 3007 false !== strpos( $slug, '.php' ) &&
2747 3008 ( ! isset( $_parent_pages[ $slug ] ) || openstation_is_admin_file_slug( $slug ) )
2748 3009 ) {
2749 - return esc_url_raw( self_admin_url( $slug ) );
3010 + return esc_url_raw( openstation_menu_admin_url( $slug ) );
2750 3011 }
2751 3012
2752 3013 // Plugin page slug with embedded query parameters
2753 3014 // (e.g., 'wc-admin&path=/customers'). Split the page slug from
@@ -2787,9 +3048,9 @@
2787 3048 $host = add_query_arg( 'page', $slug, $parent_slug );
2788 3049 }
2789 3050 }
2790 3051
2791 - $url = self_admin_url( $host );
3052 + $url = openstation_menu_admin_url( $host );
2792 3053 if ( ! empty( $extra_args ) ) {
2793 3054 $url = add_query_arg( $extra_args, $url );
2794 3055 }
2795 3056 return esc_url_raw( $url );