PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.12
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.12
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 0.8.9 All 36 releases
← All changes | includes/core/payload.php +325 -51 1.1.5 → 1.1.12 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,10 +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 - * Both `admin_url()` and `home_url()` hosts count as ours: a site can
459 - * run its admin on a different domain than its front end.
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.
460 504 *
461 505 * @param string $url Absolute URL, as returned by `openstation_menu_item_url()`.
462 506 * @return bool True when the URL is off-site.
463 507 */
@@ -466,9 +510,9 @@
466 510 $external = false;
467 511
468 512 if ( $host ) {
469 513 $ours = array();
470 - foreach ( array( admin_url(), home_url() ) as $known ) {
514 + foreach ( array( openstation_menu_admin_url(), admin_url(), home_url() ) as $known ) {
471 515 $known_host = wp_parse_url( $known, PHP_URL_HOST );
472 516 if ( $known_host ) {
473 517 $ours[] = strtolower( $known_host );
474 518 }
@@ -728,12 +772,13 @@
728 772
729 773 /**
730 774 * Filters whether a dock item supports multiple open windows.
731 775 *
732 - * Return true to let the user open more than one window of this page.
733 - * A "+" affordance appears on the dock icon and a "Open another" action
734 - * becomes available in the window's title-bar menu. Singletons (false)
735 - * 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.
736 781 *
737 782 * @param bool $multi Whether this page is multi-capable.
738 783 * @param string $menu_slug The menu slug (e.g. `edit.php?post_type=page`).
739 784 */
@@ -817,8 +862,19 @@
817 862 'link-manager.php', // Link manager (legacy)
818 863 'update-core.php', // Dashboard > Updates
819 864 );
820 865
866 + // The two top-level network menus the site admin has no filename
867 + // for: without them, Sites and Settings sat in the apps zone while
868 + // Dashboard, Users, Themes and Plugins — whose filenames the site
869 + // admin shares — grouped correctly. Gated on the context, since
870 + // `settings.php` is plausible enough as a plugin's own top-level
871 + // slug that claiming it everywhere would misfile it.
872 + if ( is_network_admin() ) {
873 + $core_files[] = 'sites.php';
874 + $core_files[] = 'settings.php';
875 + }
876 +
821 877 return in_array( $base, $core_files, true );
822 878 }
823 879
824 880 /**
@@ -1650,8 +1706,15 @@
1650 1706 'url' => network_admin_url( 'update-core.php' ),
1651 1707 );
1652 1708 }
1653 1709
1710 + // The site switcher's rows: on a network, the instances this shell
1711 + // may switch to (`openstation_multisite_payload()`), null elsewhere.
1712 + // The Network app spends a menu refresh after every action that
1713 + // changes them (add, remove, join, leave, sync), so the row above
1714 + // overview's desktop tiles follows the registry without a reload.
1715 + $payload['multisite'] = openstation_multisite_payload();
1716 +
1654 1717 // A cheap structural fingerprint of the admin menu the shell uses to
1655 1718 // decide whether a live refresh is warranted. Shipped in every full
1656 1719 // payload so the shell can seed / update its last-known signature
1657 1720 // without recomputing it client-side (which would risk drift from
@@ -1658,8 +1721,13 @@
1658 1721 // the server's capability-gated view). See
1659 1722 // openstation_menu_signature().
1660 1723 $payload['menuSig'] = openstation_menu_signature();
1661 1724
1725 + // Each script dependency's payload once, entries carry handles.
1726 + $script_dep_payloads = array();
1727 + $payload = openstation_compact_script_deps( $payload, $script_dep_payloads );
1728 + $payload['scriptDepPayloads'] = (object) $script_dep_payloads;
1729 +
1662 1730 return $payload;
1663 1731 }
1664 1732
1665 1733 /**
@@ -1853,8 +1921,12 @@
1853 1921 if ( $dep_handle === $handle ) {
1854 1922 continue;
1855 1923 }
1856 1924 $payload = openstation_resolve_script_payload( $dep_handle );
1925 + // An alias (no `src`) stays in the list when it carries inline
1926 + // data — that data is the whole reason it was declared, and a
1927 + // plugin's config blob commonly rides one. Nothing to fetch
1928 + // AND nothing to run is the only thing dropped.
1857 1929 if ( '' === $payload['url']
1858 1930 && empty( $payload['before'] )
1859 1931 && empty( $payload['after'] )
1860 1932 && empty( $payload['l10n'] ) ) {
@@ -1859,8 +1931,17 @@
1859 1931 && empty( $payload['after'] )
1860 1932 && empty( $payload['l10n'] ) ) {
1861 1933 continue;
1862 1934 }
1935 + // The handle rides along because the shell needs it to decide
1936 + // whether the page already has this package. A URL is not
1937 + // enough: with Core's script concatenation on — the wp-admin
1938 + // default — every package below `wp-includes/js/` is served
1939 + // from one `load-scripts.php` blob and has no `<script src>`
1940 + // of its own to match against. Re-running `wp-hooks` because
1941 + // we could not see it replaces `window.wp.hooks`, and every
1942 + // subscriber registered at boot goes deaf. See
1943 + // `src/script-presence.ts`.
1863 1944 $payload['handle'] = (string) $dep_handle;
1864 1945 $out[] = $payload;
1865 1946 }
1866 1947 return $out;
@@ -1866,8 +1947,102 @@
1866 1947 return $out;
1867 1948 }
1868 1949
1869 1950 /**
1951 + * Move every entry's `scriptDeps` payloads into one map.
1952 + *
1953 + * {@see openstation_resolve_script_dependencies()} returns the full
1954 + * payload of each dependency (URL, l10n, before/after), and ~20 entry
1955 + * builders call it. A dependency shared by N entries was therefore
1956 + * serialized N times: one plugin's 5.5 KB localized object became
1957 + * ~400 KB of a 489 KB `openStationConfig` (GH#892). This replaces each
1958 + * entry's `scriptDeps` list with its handles and puts each handle's
1959 + * payload in `$map` once. The shell resolves the handles back before
1960 + * any consumer reads them; see `src/script-dep-payloads.ts`.
1961 + *
1962 + * ENTRY DEPTH ONLY. `$payload` is a payload whose top-level values are
1963 + * entry lists (`serverWidgets`, `serverCommandScripts`, ...), and only
1964 + * a `scriptDeps` sitting directly on one of those entries is touched.
1965 + * The key is the shell's there. Deeper down it is a plugin's metadata
1966 + * (`settings => [ 'scriptDeps' => ... ]`), and rewriting it would hoist
1967 + * foreign data into the map first-wins and hydrate every real
1968 + * dependency of that handle to it. Scoping by depth rather than by a
1969 + * list of keys means a new builder is covered without an edit here.
1970 + * It also leaves every other branch of the payload unassigned, so
1971 + * PHP's copy-on-write never has to copy them.
1972 + *
1973 + * Every string left in a compacted list has an entry in `$map`: a bare
1974 + * handle a builder (or a filter on one) emitted is resolved here, by
1975 + * the same rule as {@see openstation_resolve_script_dependencies()}.
1976 + * A handle with nothing to fetch and nothing to run is dropped, as it
1977 + * is there. The client can then treat a string with no map entry as a
1978 + * payload from somewhere else, not a dependency this side dropped.
1979 + *
1980 + * Runs on the finished payload, so every builder, and every filter on
1981 + * a builder's output, still sees the full shape.
1982 + *
1983 + * @param array $payload Payload whose top-level values are entry lists.
1984 + * @param array $map Handle => dependency payload, filled in place.
1985 + * @return array The payload with each entry's `scriptDeps` reduced to handles.
1986 + */
1987 +function openstation_compact_script_deps( $payload, array &$map ) {
1988 + if ( ! is_array( $payload ) ) {
1989 + return $payload;
1990 + }
1991 + foreach ( $payload as $list_key => $entries ) {
1992 + if ( ! is_array( $entries ) ) {
1993 + continue;
1994 + }
1995 + foreach ( $entries as $entry_key => $entry ) {
1996 + if ( ! is_array( $entry ) || ! isset( $entry['scriptDeps'] ) || ! is_array( $entry['scriptDeps'] ) ) {
1997 + continue;
1998 + }
1999 + $payload[ $list_key ][ $entry_key ]['scriptDeps'] = openstation_compact_script_dep_list( $entry['scriptDeps'], $map );
2000 + }
2001 + }
2002 + return $payload;
2003 +}
2004 +
2005 +/**
2006 + * One entry's `scriptDeps` list, reduced to handles.
2007 + *
2008 + * @param array $deps Dependency payloads and/or bare handles.
2009 + * @param array $map Handle => dependency payload, filled in place.
2010 + * @return array Handles, in order; anything unkeyable passes through.
2011 + */
2012 +function openstation_compact_script_dep_list( array $deps, array &$map ) {
2013 + $handles = array();
2014 + foreach ( $deps as $dep ) {
2015 + if ( is_string( $dep ) && '' !== $dep ) {
2016 + if ( ! isset( $map[ $dep ] ) ) {
2017 + $payload = openstation_resolve_script_payload( $dep );
2018 + if ( '' === $payload['url']
2019 + && empty( $payload['before'] )
2020 + && empty( $payload['after'] )
2021 + && empty( $payload['l10n'] ) ) {
2022 + continue;
2023 + }
2024 + $payload['handle'] = $dep;
2025 + $map[ $dep ] = $payload;
2026 + }
2027 + $handles[] = $dep;
2028 + continue;
2029 + }
2030 + if ( is_array( $dep ) && isset( $dep['handle'] ) && '' !== (string) $dep['handle'] ) {
2031 + $handle = (string) $dep['handle'];
2032 + if ( ! isset( $map[ $handle ] ) ) {
2033 + $map[ $handle ] = $dep;
2034 + }
2035 + $handles[] = $handle;
2036 + continue;
2037 + }
2038 + // A handle-less payload has nothing to key it by.
2039 + $handles[] = $dep;
2040 + }
2041 + return $handles;
2042 +}
2043 +
2044 +/**
1870 2045 * Resolve a registered WP script handle into the full payload the
1871 2046 * shell needs to lazy-load it without going through `wp_print_scripts()`.
1872 2047 *
1873 2048 * Returns:
@@ -1891,10 +2066,14 @@
1891 2066 * around the lazy `<script src>` in the same order
1892 2067 * `WP_Scripts::do_item()` would have used.
1893 2068 *
1894 2069 * Returns an empty payload (`array( 'url' => '' )`) when the handle
1895 - * is unregistered or has no source — callers treat that as "no
1896 - * script to load."
2070 + * is unregistered. A registered handle with no source — an alias
2071 + * carrying only inline data — also comes back with an empty `url`,
2072 + * but its `before` / `after` / `l10n` are kept: callers that load a
2073 + * bundle treat an empty `url` as "nothing to fetch", and the
2074 + * dependency walk ({@see openstation_resolve_script_dependencies()})
2075 + * still replays what the alias would have printed.
1897 2076 *
1898 2077 * Shared between `openstation_register_window()` and
1899 2078 * `openstation_register_widget()` (and every other registration that
1900 2079 * relies on lazy script loading in the shell) because all of them
@@ -1922,20 +2101,31 @@
1922 2101 return $empty;
1923 2102 }
1924 2103 $registered = $wp_scripts->registered[ $handle ];
1925 2104 $src = is_string( $registered->src ) ? $registered->src : '';
1926 - if ( '' === $src ) {
1927 - return $empty;
1928 - }
1929 2105
1930 - // Normalize relative paths + attach cache-bust ver.
1931 - $resolved = $src;
1932 - if ( 0 === strpos( $resolved, '/' ) && 0 !== strpos( $resolved, '//' ) ) {
1933 - $resolved = site_url( $resolved );
2106 + // A handle with no `src` is an ALIAS — WordPress's supported way
2107 + // to ship inline-only JavaScript (`wp_register_script( $h, false )`
2108 + // plus `wp_add_inline_script()`), and a common home for a plugin's
2109 + // config blob: registering it as a *dependency* of every bundle is
2110 + // what guarantees the config runs first, whatever the enqueue
2111 + // order. `WP_Scripts::do_item()` prints an alias's localized data
2112 + // and its before/after snippets and returns before the `<script
2113 + // src>` it does not have. The payload mirrors that: `url` stays
2114 + // empty (there is nothing to fetch) and the inline data is kept,
2115 + // so a dependency walk can replay it. Translations are not: Core
2116 + // only prints those for a handle it printed a tag for.
2117 + $resolved = '';
2118 + if ( '' !== $src ) {
2119 + // Normalize relative paths + attach cache-bust ver.
2120 + $resolved = $src;
2121 + if ( 0 === strpos( $resolved, '/' ) && 0 !== strpos( $resolved, '//' ) ) {
2122 + $resolved = site_url( $resolved );
2123 + }
2124 + if ( ! empty( $registered->ver ) ) {
2125 + $resolved = add_query_arg( 'ver', $registered->ver, $resolved );
2126 + }
1934 2127 }
1935 - if ( ! empty( $registered->ver ) ) {
1936 - $resolved = add_query_arg( 'ver', $registered->ver, $resolved );
1937 - }
1938 2128
1939 2129 // Harvest `extra` data the lazy-load path would otherwise drop.
1940 2130 $before = array();
1941 2131 $after = array();
@@ -1970,9 +2160,9 @@
1970 2160 // `wp.i18n.setLocaleData( JSON, 'domain' )` snippet that the print
1971 2161 // pipeline emits before the script body. `print_translations(
1972 2162 // $handle, false )` returns the snippet without echoing.
1973 2163 $translations = '';
1974 - if ( method_exists( $wp_scripts, 'print_translations' ) ) {
2164 + if ( '' !== $resolved && method_exists( $wp_scripts, 'print_translations' ) ) {
1975 2165 $captured = $wp_scripts->print_translations( $handle, false );
1976 2166 if ( is_string( $captured ) ) {
1977 2167 $translations = $captured;
1978 2168 }
@@ -2083,12 +2273,15 @@
2083 2273 * time the palette is invoked (`src/commands/palette-assets.ts`).
2084 2274 *
2085 2275 * Handles with no `src` (pure aggregators) are kept whenever they
2086 2276 * carry inline data; dropping them would lose middleware and locale
2087 - * setup the chain depends on. Handles another plugin already
2088 - * enqueued at boot print normally and are skipped client-side by a
2089 - * same-path DOM sniff — the manifest deliberately lists them anyway,
2090 - * because which ones those are differs per site and per screen.
2277 + * setup the chain depends on. Handles the boot page already printed
2278 + * are skipped client-side, by handle as well as by path so that a
2279 + * package Core concatenated into `load-scripts.php` is recognized
2280 + * (`src/script-presence.ts`) — the manifest deliberately lists them
2281 + * anyway, because which ones those are differs per site and per
2282 + * screen. Each entry therefore carries its `handle`, and that is
2283 + * load-bearing rather than informational.
2091 2284 *
2092 2285 * Returns `null` on pre-6.9 sites (no Core palette to defer).
2093 2286 *
2094 2287 * @return array{scripts:array<int,array<string,mixed>>,styles:array<int,array<string,mixed>>}|null
@@ -2143,24 +2336,16 @@
2143 2336 $script_probe->done = array();
2144 2337 $script_probe->all_deps( $script_roots );
2145 2338 foreach ( $script_probe->to_do as $handle ) {
2146 2339 $payload = openstation_resolve_script_payload( $handle );
2147 - if ( '' === $payload['url'] ) {
2148 - // Src-less aggregator — keep it only for its inline data.
2149 - $registered = isset( $scripts->registered[ $handle ] ) ? $scripts->registered[ $handle ] : null;
2150 - if ( $registered ) {
2151 - foreach ( array( 'before', 'after' ) as $position ) {
2152 - if ( isset( $registered->extra[ $position ] ) && is_array( $registered->extra[ $position ] ) ) {
2153 - $payload[ $position ] = array_values( array_filter( array_map( 'strval', $registered->extra[ $position ] ) ) );
2154 - }
2155 - }
2156 - if ( ! empty( $registered->extra['data'] ) && is_string( $registered->extra['data'] ) ) {
2157 - $payload['l10n'][] = $registered->extra['data'];
2158 - }
2159 - }
2160 - if ( empty( $payload['before'] ) && empty( $payload['after'] ) && empty( $payload['l10n'] ) ) {
2161 - continue;
2162 - }
2340 + // A src-less aggregator is kept only for its inline data — the
2341 + // resolver harvests that for an alias — and dropped when it
2342 + // carries none.
2343 + if ( '' === $payload['url']
2344 + && empty( $payload['before'] )
2345 + && empty( $payload['after'] )
2346 + && empty( $payload['l10n'] ) ) {
2347 + continue;
2163 2348 }
2164 2349 // Core's `initializeCommandPalette( {…} )` inline embeds the
2165 2350 // serialized admin-menu command list — ~20 KB that the boot
2166 2351 // page ALREADY carries as `window.__openStationMenuCommands`
@@ -2278,9 +2463,9 @@
2278 2463 _doing_it_wrong(
2279 2464 esc_html( $function_name ),
2280 2465 sprintf(
2281 2466 /* translators: 1: kind ("Command"/"Settings-tab"/"Title-bar button"), 2: handle. */
2282 - esc_html__( '%1$s script handle "%2$s" is not registered with WordPress (no `wp_register_script` call found). The script will not load.', 'desktop-mode' ),
2467 + 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' ),
2283 2468 esc_html( $kind ),
2284 2469 esc_html( $handle )
2285 2470 ),
2286 2471 '0.8.1'
@@ -2334,12 +2519,16 @@
2334 2519 * harvested `wp_localize_script` / `wp_add_inline_script` /
2335 2520 * translations, see `openstation_resolve_script_payload()` — lives
2336 2521 * ONCE per handle in `scriptData`, and the shell joins the two on
2337 2522 * receipt (`hydrateServerEntries()` in `src/native-windows.ts`).
2523 + * Each loadable handle's entry also names its dependency closure in
2524 + * `deps` (ordered handles, every one of them a key of the same map)
2525 + * so the lazy loader can bring a bundle's declared packages — and
2526 + * a src-less alias carrying its config — into the tab before it.
2338 2527 *
2339 2528 * The split exists because script data is a property of the HANDLE,
2340 - * not of the window: Posts, Pages, Users and Profile all ride
2341 - * `os-posts-window`, and inlining each entry's resolved copy
2529 + * not of the window: every App Framework window rides
2530 + * `openstation-app-runtime`, and inlining each entry's resolved copy
2342 2531 * serialized the same localize blobs and the same shared config set
2343 2532 * four times over — `scriptL10n` alone was ~100 KB of the boot
2344 2533 * payload, most of it repetition. The synthesized
2345 2534 * `openStationWindowConfig[ id ]` assignments group by handle for
@@ -2352,9 +2541,9 @@
2352 2541 * Style data stays inline on the entries — it never had a
2353 2542 * duplication problem worth a second map ( companion styles across
2354 2543 * the whole registry total ~2 KB ).
2355 2544 *
2356 - * @return array{windows:array[],scriptData:array<string,array{url:string,before:string[],after:string[],l10n:string[],translations:string}>}
2545 + * @return array{windows:array[],scriptData:array<string,array{url:string,before:string[],after:string[],l10n:string[],translations:string,deps:string[]}>}
2357 2546 */
2358 2547 function openstation_collect_native_windows_payload() {
2359 2548 $empty = array(
2360 2549 'windows' => array(),
@@ -2362,31 +2551,83 @@
2362 2551 );
2363 2552 if ( ! function_exists( 'openstation_native_window_registry' ) ) {
2364 2553 return $empty;
2365 2554 }
2555 +
2366 2556 $registry = openstation_native_window_registry();
2367 2557 if ( ! is_array( $registry ) ) {
2368 2558 return $empty;
2369 2559 }
2370 2560
2561 + // A window says which admin offers it (`admin` in its registration:
2562 + // `site`, `network` or `any`). Every native window OpenStation
2563 + // ships is site-scoped, reading the current site's REST API, so in
2564 + // the network admin a `users.php` tile meaning "everyone on the
2565 + // network" would open one site's user list; those stay off the
2566 + // network shell. A window that declares `network` (the Network app)
2567 + // is offered there and nowhere else.
2568 + //
2569 + // Dropping the site windows there is also what disarms the
2570 + // client-side URL remaps: they match on the tail of a pathname
2571 + // (`endsWith( '/users.php' )`) and the network admin serves
2572 + // same-named files one directory down, but with nothing registered
2573 + // `openById()` finds no window and the remap falls through to the
2574 + // iframe.
2575 + $registry = array_filter( $registry, 'openstation_native_window_offered_here' );
2576 +
2371 2577 $script_data = array();
2372 2578
2579 + // Handles resolved as a bundle to LOAD (a window's script, a
2580 + // companion, a tab) and what that visit answered — the handle, or
2581 + // '' for nothing to load — as opposed to reached only as
2582 + // somebody's dependency. A handle can be both — resolved as a
2583 + // dependency first, then named as a window's own script — and
2584 + // only the bundle visit computes its own closure.
2585 + $resolved_as_bundle = array();
2586 +
2373 2587 // Resolve a handle into the map, once. Returns the handle when it
2374 2588 // resolved to something loadable, '' when it did not (never
2375 2589 // registered, no src) — the same silent drop the inline shape
2376 2590 // applied to companions and tab scripts.
2377 - $collect_handle = static function ( $handle ) use ( &$script_data ) {
2591 + //
2592 + // The handle's dependency closure rides along as `deps`: an
2593 + // ordered handle list, each of which lands in the same map. A
2594 + // bundle delivered lazily never goes through WordPress's own
2595 + // dependency resolution — the loader injects one URL — so a
2596 + // window declaring `wp-api-fetch` found `wp.apiFetch` undefined,
2597 + // and one whose config rides a src-less alias handle (a common
2598 + // shape: `wp_register_script( $h, false )` plus
2599 + // `wp_add_inline_script()`, declared as the bundle's dependency
2600 + // so it always runs first) booted with no config at all. Anything
2601 + // the document already ran is skipped on the client, so a page
2602 + // that carried the packages anyway pays nothing.
2603 + $collect_handle = static function ( $handle ) use ( &$script_data, &$resolved_as_bundle ) {
2378 2604 $handle = (string) $handle;
2379 2605 if ( '' === $handle ) {
2380 2606 return '';
2381 2607 }
2382 - if ( isset( $script_data[ $handle ] ) ) {
2383 - return $handle;
2608 + if ( isset( $resolved_as_bundle[ $handle ] ) ) {
2609 + return $resolved_as_bundle[ $handle ];
2384 2610 }
2385 - $payload = openstation_resolve_script_payload( $handle );
2611 + $payload = isset( $script_data[ $handle ] )
2612 + ? $script_data[ $handle ]
2613 + : openstation_resolve_script_payload( $handle );
2386 2614 if ( '' === $payload['url'] ) {
2615 + $resolved_as_bundle[ $handle ] = '';
2387 2616 return '';
2388 2617 }
2618 + $resolved_as_bundle[ $handle ] = $handle;
2619 + $deps = array();
2620 + foreach ( openstation_resolve_script_dependencies( $handle ) as $dep ) {
2621 + $dep_handle = (string) $dep['handle'];
2622 + unset( $dep['handle'] );
2623 + if ( ! isset( $script_data[ $dep_handle ] ) ) {
2624 + $dep['deps'] = array();
2625 + $script_data[ $dep_handle ] = $dep;
2626 + }
2627 + $deps[] = $dep_handle;
2628 + }
2629 + $payload['deps'] = $deps;
2389 2630 $script_data[ $handle ] = $payload;
2390 2631 return $handle;
2391 2632 };
2392 2633
@@ -2534,8 +2775,9 @@
2534 2775 'styleHandle' => $style_handle,
2535 2776 'styleInline' => $style_payload['inline'],
2536 2777 'companionStyles' => $companion_styles,
2537 2778 'tabs' => $tab_descriptors,
2779 + 'menuPages' => isset( $entry['menu_pages'] ) ? array_values( (array) $entry['menu_pages'] ) : array(),
2538 2780 );
2539 2781 }
2540 2782
2541 2783 // Append each handle's synthesized config set to its map entry —
@@ -2636,13 +2878,45 @@
2636 2878 return file_exists( ABSPATH . 'wp-admin/' . $file );
2637 2879 }
2638 2880
2639 2881 /**
2882 + * The admin URL a menu slug resolves against.
2883 + *
2884 + * Follows the admin the request is in: the network admin's own URL there,
2885 + * because its globals carry network slugs (`sites.php`, `settings.php`)
2886 + * that exist only under `wp-admin/network/`, and the site admin's
2887 + * everywhere else.
2888 + *
2889 + * The same answer `self_admin_url()` gives, without its filter. That
2890 + * filter receives the path, so a host can use it to send one screen
2891 + * somewhere else, and WordPress.com points `plugin-install.php` at its own
2892 + * installer. Resolved through it, the wp-admin original of a menu row the
2893 + * host replaced reads as off-site, and the dock drops it along with the
2894 + * replacement, which is how Plugins > Add Plugin disappears there.
2895 + *
2896 + * @param string $path Optional. Path relative to the admin URL.
2897 + * @return string Absolute admin URL.
2898 + */
2899 +function openstation_menu_admin_url( $path = '' ) {
2900 + if ( is_network_admin() ) {
2901 + return network_admin_url( $path );
2902 + }
2903 + if ( is_user_admin() ) {
2904 + return user_admin_url( $path );
2905 + }
2906 + return admin_url( $path );
2907 +}
2908 +
2909 +/**
2640 2910 * Converts a menu item slug to a full admin URL.
2641 2911 *
2912 + * Resolution goes through {@see openstation_menu_admin_url()}, which
2913 + * follows the admin the request is in without passing through the
2914 + * filterable `self_admin_url()`.
2915 + *
2642 2916 * Handles three slug shapes:
2643 2917 * 1. Direct file references (`edit.php`, `upload.php`) — passed
2644 - * through `admin_url()` as-is.
2918 + * through `openstation_menu_admin_url()` as-is.
2645 2919 * 2. Plain plugin page slugs (`my-plugin`) — routed through
2646 2920 * `admin.php?page=<slug>` with the slug `rawurlencode()`d.
2647 2921 * 3. Plugin page slugs that embed extra query parameters
2648 2922 * (`wc-admin&path=/customers`) — split on the first `&`, the
@@ -2703,9 +2977,9 @@
2703 2977 if (
2704 2978 false !== strpos( $slug, '.php' ) &&
2705 2979 ( ! isset( $_parent_pages[ $slug ] ) || openstation_is_admin_file_slug( $slug ) )
2706 2980 ) {
2707 - return esc_url_raw( admin_url( $slug ) );
2981 + return esc_url_raw( openstation_menu_admin_url( $slug ) );
2708 2982 }
2709 2983
2710 2984 // Plugin page slug with embedded query parameters
2711 2985 // (e.g., 'wc-admin&path=/customers'). Split the page slug from
@@ -2745,9 +3019,9 @@
2745 3019 $host = add_query_arg( 'page', $slug, $parent_slug );
2746 3020 }
2747 3021 }
2748 3022
2749 - $url = admin_url( $host );
3023 + $url = openstation_menu_admin_url( $host );
2750 3024 if ( ! empty( $extra_args ) ) {
2751 3025 $url = add_query_arg( $extra_args, $url );
2752 3026 }
2753 3027 return esc_url_raw( $url );