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 +300 -49 1.1.9 → 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' );
@@ -1677,8 +1750,13 @@
1677 1750 // the server's capability-gated view). See
1678 1751 // openstation_menu_signature().
1679 1752 $payload['menuSig'] = openstation_menu_signature();
1680 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 +
1681 1759 return $payload;
1682 1760 }
1683 1761
1684 1762 /**
@@ -1872,8 +1950,12 @@
1872 1950 if ( $dep_handle === $handle ) {
1873 1951 continue;
1874 1952 }
1875 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.
1876 1958 if ( '' === $payload['url']
1877 1959 && empty( $payload['before'] )
1878 1960 && empty( $payload['after'] )
1879 1961 && empty( $payload['l10n'] ) ) {
@@ -1894,8 +1976,102 @@
1894 1976 return $out;
1895 1977 }
1896 1978
1897 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 +/**
1898 2074 * Resolve a registered WP script handle into the full payload the
1899 2075 * shell needs to lazy-load it without going through `wp_print_scripts()`.
1900 2076 *
1901 2077 * Returns:
@@ -1919,10 +2095,14 @@
1919 2095 * around the lazy `<script src>` in the same order
1920 2096 * `WP_Scripts::do_item()` would have used.
1921 2097 *
1922 2098 * 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."
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.
1925 2105 *
1926 2106 * Shared between `openstation_register_window()` and
1927 2107 * `openstation_register_widget()` (and every other registration that
1928 2108 * relies on lazy script loading in the shell) because all of them
@@ -1950,20 +2130,31 @@
1950 2130 return $empty;
1951 2131 }
1952 2132 $registered = $wp_scripts->registered[ $handle ];
1953 2133 $src = is_string( $registered->src ) ? $registered->src : '';
1954 - if ( '' === $src ) {
1955 - return $empty;
1956 - }
1957 2134
1958 - // Normalize relative paths + attach cache-bust ver.
1959 - $resolved = $src;
1960 - if ( 0 === strpos( $resolved, '/' ) && 0 !== strpos( $resolved, '//' ) ) {
1961 - $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 + }
1962 2156 }
1963 - if ( ! empty( $registered->ver ) ) {
1964 - $resolved = add_query_arg( 'ver', $registered->ver, $resolved );
1965 - }
1966 2157
1967 2158 // Harvest `extra` data the lazy-load path would otherwise drop.
1968 2159 $before = array();
1969 2160 $after = array();
@@ -1998,9 +2189,9 @@
1998 2189 // `wp.i18n.setLocaleData( JSON, 'domain' )` snippet that the print
1999 2190 // pipeline emits before the script body. `print_translations(
2000 2191 // $handle, false )` returns the snippet without echoing.
2001 2192 $translations = '';
2002 - if ( method_exists( $wp_scripts, 'print_translations' ) ) {
2193 + if ( '' !== $resolved && method_exists( $wp_scripts, 'print_translations' ) ) {
2003 2194 $captured = $wp_scripts->print_translations( $handle, false );
2004 2195 if ( is_string( $captured ) ) {
2005 2196 $translations = $captured;
2006 2197 }
@@ -2174,24 +2365,16 @@
2174 2365 $script_probe->done = array();
2175 2366 $script_probe->all_deps( $script_roots );
2176 2367 foreach ( $script_probe->to_do as $handle ) {
2177 2368 $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 - }
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;
2194 2377 }
2195 2378 // Core's `initializeCommandPalette( {…} )` inline embeds the
2196 2379 // serialized admin-menu command list — ~20 KB that the boot
2197 2380 // page ALREADY carries as `window.__openStationMenuCommands`
@@ -2365,8 +2548,12 @@
2365 2548 * harvested `wp_localize_script` / `wp_add_inline_script` /
2366 2549 * translations, see `openstation_resolve_script_payload()` — lives
2367 2550 * ONCE per handle in `scriptData`, and the shell joins the two on
2368 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.
2369 2556 *
2370 2557 * The split exists because script data is a property of the HANDLE,
2371 2558 * not of the window: every App Framework window rides
2372 2559 * `openstation-app-runtime`, and inlining each entry's resolved copy
@@ -2383,9 +2570,9 @@
2383 2570 * Style data stays inline on the entries — it never had a
2384 2571 * duplication problem worth a second map ( companion styles across
2385 2572 * the whole registry total ~2 KB ).
2386 2573 *
2387 - * @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[]}>}
2388 2575 */
2389 2576 function openstation_collect_native_windows_payload() {
2390 2577 $empty = array(
2391 2578 'windows' => array(),
@@ -2417,24 +2604,59 @@
2417 2604 $registry = array_filter( $registry, 'openstation_native_window_offered_here' );
2418 2605
2419 2606 $script_data = array();
2420 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 +
2421 2616 // Resolve a handle into the map, once. Returns the handle when it
2422 2617 // resolved to something loadable, '' when it did not (never
2423 2618 // registered, no src) — the same silent drop the inline shape
2424 2619 // applied to companions and tab scripts.
2425 - $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 ) {
2426 2633 $handle = (string) $handle;
2427 2634 if ( '' === $handle ) {
2428 2635 return '';
2429 2636 }
2430 - if ( isset( $script_data[ $handle ] ) ) {
2431 - return $handle;
2637 + if ( isset( $resolved_as_bundle[ $handle ] ) ) {
2638 + return $resolved_as_bundle[ $handle ];
2432 2639 }
2433 - $payload = openstation_resolve_script_payload( $handle );
2640 + $payload = isset( $script_data[ $handle ] )
2641 + ? $script_data[ $handle ]
2642 + : openstation_resolve_script_payload( $handle );
2434 2643 if ( '' === $payload['url'] ) {
2644 + $resolved_as_bundle[ $handle ] = '';
2435 2645 return '';
2436 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;
2437 2659 $script_data[ $handle ] = $payload;
2438 2660 return $handle;
2439 2661 };
2440 2662
@@ -2582,8 +2804,9 @@
2582 2804 'styleHandle' => $style_handle,
2583 2805 'styleInline' => $style_payload['inline'],
2584 2806 'companionStyles' => $companion_styles,
2585 2807 'tabs' => $tab_descriptors,
2808 + 'menuPages' => isset( $entry['menu_pages'] ) ? array_values( (array) $entry['menu_pages'] ) : array(),
2586 2809 );
2587 2810 }
2588 2811
2589 2812 // Append each handle's synthesized config set to its map entry —
@@ -2684,17 +2907,45 @@
2684 2907 return file_exists( ABSPATH . 'wp-admin/' . $file );
2685 2908 }
2686 2909
2687 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 +/**
2688 2939 * Converts a menu item slug to a full admin URL.
2689 2940 *
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/`.
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()`.
2693 2944 *
2694 2945 * Handles three slug shapes:
2695 2946 * 1. Direct file references (`edit.php`, `upload.php`) — passed
2696 - * through `self_admin_url()` as-is.
2947 + * through `openstation_menu_admin_url()` as-is.
2697 2948 * 2. Plain plugin page slugs (`my-plugin`) — routed through
2698 2949 * `admin.php?page=<slug>` with the slug `rawurlencode()`d.
2699 2950 * 3. Plugin page slugs that embed extra query parameters
2700 2951 * (`wc-admin&path=/customers`) — split on the first `&`, the
@@ -2755,9 +3006,9 @@
2755 3006 if (
2756 3007 false !== strpos( $slug, '.php' ) &&
2757 3008 ( ! isset( $_parent_pages[ $slug ] ) || openstation_is_admin_file_slug( $slug ) )
2758 3009 ) {
2759 - return esc_url_raw( self_admin_url( $slug ) );
3010 + return esc_url_raw( openstation_menu_admin_url( $slug ) );
2760 3011 }
2761 3012
2762 3013 // Plugin page slug with embedded query parameters
2763 3014 // (e.g., 'wc-admin&path=/customers'). Split the page slug from
@@ -2797,9 +3048,9 @@
2797 3048 $host = add_query_arg( 'page', $slug, $parent_slug );
2798 3049 }
2799 3050 }
2800 3051
2801 - $url = self_admin_url( $host );
3052 + $url = openstation_menu_admin_url( $host );
2802 3053 if ( ! empty( $extra_args ) ) {
2803 3054 $url = add_query_arg( $extra_args, $url );
2804 3055 }
2805 3056 return esc_url_raw( $url );