PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.11
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.11
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 0.8.8 All 35 releases
← All changes | includes/core/payload.php +293 -61 1.1.71.1.11 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 */
@@ -1662,8 +1706,15 @@
1662 1706 'url' => network_admin_url( 'update-core.php' ),
1663 1707 );
1664 1708 }
1665 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 +
1666 1717 // A cheap structural fingerprint of the admin menu the shell uses to
1667 1718 // decide whether a live refresh is warranted. Shipped in every full
1668 1719 // payload so the shell can seed / update its last-known signature
1669 1720 // without recomputing it client-side (which would risk drift from
@@ -1670,8 +1721,13 @@
1670 1721 // the server's capability-gated view). See
1671 1722 // openstation_menu_signature().
1672 1723 $payload['menuSig'] = openstation_menu_signature();
1673 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 +
1674 1730 return $payload;
1675 1731 }
1676 1732
1677 1733 /**
@@ -1865,8 +1921,12 @@
1865 1921 if ( $dep_handle === $handle ) {
1866 1922 continue;
1867 1923 }
1868 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.
1869 1929 if ( '' === $payload['url']
1870 1930 && empty( $payload['before'] )
1871 1931 && empty( $payload['after'] )
1872 1932 && empty( $payload['l10n'] ) ) {
@@ -1887,8 +1947,102 @@
1887 1947 return $out;
1888 1948 }
1889 1949
1890 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 +/**
1891 2045 * Resolve a registered WP script handle into the full payload the
1892 2046 * shell needs to lazy-load it without going through `wp_print_scripts()`.
1893 2047 *
1894 2048 * Returns:
@@ -1912,10 +2066,14 @@
1912 2066 * around the lazy `<script src>` in the same order
1913 2067 * `WP_Scripts::do_item()` would have used.
1914 2068 *
1915 2069 * 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."
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.
1918 2076 *
1919 2077 * Shared between `openstation_register_window()` and
1920 2078 * `openstation_register_widget()` (and every other registration that
1921 2079 * relies on lazy script loading in the shell) because all of them
@@ -1943,20 +2101,31 @@
1943 2101 return $empty;
1944 2102 }
1945 2103 $registered = $wp_scripts->registered[ $handle ];
1946 2104 $src = is_string( $registered->src ) ? $registered->src : '';
1947 - if ( '' === $src ) {
1948 - return $empty;
1949 - }
1950 2105
1951 - // Normalize relative paths + attach cache-bust ver.
1952 - $resolved = $src;
1953 - if ( 0 === strpos( $resolved, '/' ) && 0 !== strpos( $resolved, '//' ) ) {
1954 - $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 + }
1955 2127 }
1956 - if ( ! empty( $registered->ver ) ) {
1957 - $resolved = add_query_arg( 'ver', $registered->ver, $resolved );
1958 - }
1959 2128
1960 2129 // Harvest `extra` data the lazy-load path would otherwise drop.
1961 2130 $before = array();
1962 2131 $after = array();
@@ -1991,9 +2160,9 @@
1991 2160 // `wp.i18n.setLocaleData( JSON, 'domain' )` snippet that the print
1992 2161 // pipeline emits before the script body. `print_translations(
1993 2162 // $handle, false )` returns the snippet without echoing.
1994 2163 $translations = '';
1995 - if ( method_exists( $wp_scripts, 'print_translations' ) ) {
2164 + if ( '' !== $resolved && method_exists( $wp_scripts, 'print_translations' ) ) {
1996 2165 $captured = $wp_scripts->print_translations( $handle, false );
1997 2166 if ( is_string( $captured ) ) {
1998 2167 $translations = $captured;
1999 2168 }
@@ -2167,24 +2336,16 @@
2167 2336 $script_probe->done = array();
2168 2337 $script_probe->all_deps( $script_roots );
2169 2338 foreach ( $script_probe->to_do as $handle ) {
2170 2339 $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 - }
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;
2187 2348 }
2188 2349 // Core's `initializeCommandPalette( {…} )` inline embeds the
2189 2350 // serialized admin-menu command list — ~20 KB that the boot
2190 2351 // page ALREADY carries as `window.__openStationMenuCommands`
@@ -2358,8 +2519,12 @@
2358 2519 * harvested `wp_localize_script` / `wp_add_inline_script` /
2359 2520 * translations, see `openstation_resolve_script_payload()` — lives
2360 2521 * ONCE per handle in `scriptData`, and the shell joins the two on
2361 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.
2362 2527 *
2363 2528 * The split exists because script data is a property of the HANDLE,
2364 2529 * not of the window: every App Framework window rides
2365 2530 * `openstation-app-runtime`, and inlining each entry's resolved copy
@@ -2376,9 +2541,9 @@
2376 2541 * Style data stays inline on the entries — it never had a
2377 2542 * duplication problem worth a second map ( companion styles across
2378 2543 * the whole registry total ~2 KB ).
2379 2544 *
2380 - * @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[]}>}
2381 2546 */
2382 2547 function openstation_collect_native_windows_payload() {
2383 2548 $empty = array(
2384 2549 'windows' => array(),
@@ -2387,44 +2552,82 @@
2387 2552 if ( ! function_exists( 'openstation_native_window_registry' ) ) {
2388 2553 return $empty;
2389 2554 }
2390 2555
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 2556 $registry = openstation_native_window_registry();
2405 2557 if ( ! is_array( $registry ) ) {
2406 2558 return $empty;
2407 2559 }
2408 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 +
2409 2577 $script_data = array();
2410 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 +
2411 2587 // Resolve a handle into the map, once. Returns the handle when it
2412 2588 // resolved to something loadable, '' when it did not (never
2413 2589 // registered, no src) — the same silent drop the inline shape
2414 2590 // applied to companions and tab scripts.
2415 - $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 ) {
2416 2604 $handle = (string) $handle;
2417 2605 if ( '' === $handle ) {
2418 2606 return '';
2419 2607 }
2420 - if ( isset( $script_data[ $handle ] ) ) {
2421 - return $handle;
2608 + if ( isset( $resolved_as_bundle[ $handle ] ) ) {
2609 + return $resolved_as_bundle[ $handle ];
2422 2610 }
2423 - $payload = openstation_resolve_script_payload( $handle );
2611 + $payload = isset( $script_data[ $handle ] )
2612 + ? $script_data[ $handle ]
2613 + : openstation_resolve_script_payload( $handle );
2424 2614 if ( '' === $payload['url'] ) {
2615 + $resolved_as_bundle[ $handle ] = '';
2425 2616 return '';
2426 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;
2427 2630 $script_data[ $handle ] = $payload;
2428 2631 return $handle;
2429 2632 };
2430 2633
@@ -2572,8 +2775,9 @@
2572 2775 'styleHandle' => $style_handle,
2573 2776 'styleInline' => $style_payload['inline'],
2574 2777 'companionStyles' => $companion_styles,
2575 2778 'tabs' => $tab_descriptors,
2779 + 'menuPages' => isset( $entry['menu_pages'] ) ? array_values( (array) $entry['menu_pages'] ) : array(),
2576 2780 );
2577 2781 }
2578 2782
2579 2783 // Append each handle's synthesized config set to its map entry —
@@ -2674,17 +2878,45 @@
2674 2878 return file_exists( ABSPATH . 'wp-admin/' . $file );
2675 2879 }
2676 2880
2677 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 +/**
2678 2910 * Converts a menu item slug to a full admin URL.
2679 2911 *
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/`.
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()`.
2683 2915 *
2684 2916 * Handles three slug shapes:
2685 2917 * 1. Direct file references (`edit.php`, `upload.php`) — passed
2686 - * through `self_admin_url()` as-is.
2918 + * through `openstation_menu_admin_url()` as-is.
2687 2919 * 2. Plain plugin page slugs (`my-plugin`) — routed through
2688 2920 * `admin.php?page=<slug>` with the slug `rawurlencode()`d.
2689 2921 * 3. Plugin page slugs that embed extra query parameters
2690 2922 * (`wc-admin&path=/customers`) — split on the first `&`, the
@@ -2745,9 +2977,9 @@
2745 2977 if (
2746 2978 false !== strpos( $slug, '.php' ) &&
2747 2979 ( ! isset( $_parent_pages[ $slug ] ) || openstation_is_admin_file_slug( $slug ) )
2748 2980 ) {
2749 - return esc_url_raw( self_admin_url( $slug ) );
2981 + return esc_url_raw( openstation_menu_admin_url( $slug ) );
2750 2982 }
2751 2983
2752 2984 // Plugin page slug with embedded query parameters
2753 2985 // (e.g., 'wc-admin&path=/customers'). Split the page slug from
@@ -2787,9 +3019,9 @@
2787 3019 $host = add_query_arg( 'page', $slug, $parent_slug );
2788 3020 }
2789 3021 }
2790 3022
2791 - $url = self_admin_url( $host );
3023 + $url = openstation_menu_admin_url( $host );
2792 3024 if ( ! empty( $extra_args ) ) {
2793 3025 $url = add_query_arg( $extra_args, $url );
2794 3026 }
2795 3027 return esc_url_raw( $url );