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 +1278 -173 1.1.1 → 1.1.13 View file →
@@ -54,8 +54,16 @@
54 54 if ( ! empty( $item[1] ) && ! current_user_can( $item[1] ) ) {
55 55 continue;
56 56 }
57 57
58 + // Skip menus something took out of the classic sidebar. A dock
59 + // that shows what wp-admin hides isn't a faithful mirror of the
60 + // menu, and on WordPress.com it double-renders every entry
61 + // Jetpack replaced with a Calypso link.
62 + if ( openstation_menu_item_is_hidden( $item ) ) {
63 + continue;
64 + }
65 +
58 66 $title = openstation_menu_item_title( $item[0] );
59 67
60 68 // Extract badge count from the title HTML.
61 69 $badge = 0;
@@ -83,9 +91,21 @@
83 91
84 92 // Determine the icon. Menu entries can set `$item[6]` to anything
85 93 // — a dashicon class, a remote URL, a data:URI, 'none', or 'div'
86 94 // — so normalize before we serialize it for the shell JS.
87 - $icon = openstation_sanitize_dock_icon( $item[6] ?? '' );
95 + //
96 + // A blanked value falls back to whatever the row carried before
97 + // anything on `admin_menu` rewrote it, which is how plugin
98 + // artwork survives Jetpack's SVG-to-stylesheet move on
99 + // WordPress.com — see `openstation_snapshot_menu_icons()`.
100 + $raw_icon = (string) ( $item[6] ?? '' );
101 + if ( '' === $raw_icon || 'none' === $raw_icon || 'div' === $raw_icon ) {
102 + $snapshot = openstation_menu_icon_snapshot();
103 + if ( isset( $snapshot[ $item[2] ] ) ) {
104 + $raw_icon = $snapshot[ $item[2] ];
105 + }
106 + }
107 + $icon = openstation_sanitize_dock_icon( $raw_icon );
88 108
89 109 // Build the full URL for the menu item.
90 110 //
91 111 // `$parent_url` is the slug-derived URL (`admin.php?page=<slug>`
@@ -93,11 +113,20 @@
93 113 // reference value the self-link strip below compares against.
94 114 // The effective `$url` we ship to the shell can be rewritten
95 115 // further down to the first visible submenu's URL — see the
96 116 // note after the loop.
97 - $parent_url = openstation_menu_item_url( $item[2] );
98 - $url = $parent_url;
117 + $parent_url = openstation_menu_item_url( $item[2] );
118 + $parent_external = openstation_menu_item_is_external( $parent_url );
99 119
120 + // A menu owned by a regular plugin is allowed to keep off-site
121 + // children — a docs or support link under a plugin's own menu is
122 + // a normal thing to ship, and the flyout marks it as leaving the
123 + // site. Everything else drops them: a Core menu whose child was
124 + // repointed off-site (WordPress.com does this to Appearance →
125 + // Themes) gets its wp-admin original back instead, below.
126 + $plugin_file = openstation_resolve_menu_plugin_file( $item[2] );
127 + $allow_external_subs = null !== $plugin_file && ! $parent_external;
128 +
100 129 // Build submenu items.
101 130 //
102 131 // WordPress auto-prepends a self-link entry to every parent
103 132 // menu's `$submenu[$slug]` (the first child shares the parent's
@@ -113,11 +142,18 @@
113 142 //
114 143 // Detection by URL (post-`openstation_menu_item_url()` normalize)
115 144 // rather than slug equality covers plugins that register a child
116 145 // at a different slug pointing at the parent's URL.
117 - $sub_items = array();
118 - $first_visible_sub_url = null;
119 - $self_label = '';
146 + //
147 + // Two passes, because the second decision depends on the first:
148 + // a `hide-if-js` row is normally noise, but when it is the
149 + // wp-admin original of an off-site row we just dropped, it is
150 + // the route back to the page Core intended. The original takes
151 + // the replacement's place in the list, so the menu reads the way
152 + // it would have if nothing had swapped the row out.
153 + $rows = array();
154 + $restore_slots = array();
155 + $dropped_off_site = 0;
120 156 if ( ! empty( $submenu[ $item[2] ] ) ) {
121 157 foreach ( $submenu[ $item[2] ] as $sub_item ) {
122 158 if ( ! empty( $sub_item[1] ) && ! current_user_can( $sub_item[1] ) ) {
123 159 continue;
@@ -127,61 +163,211 @@
127 163 // class; the semantics are "shown by default; hide only
128 164 // when `<body class=\"no-customize-support\">`". The
129 165 // Customizer is supported inside chromeless iframes, so
130 166 // these entries belong in the dock.
131 - $sub_url = openstation_menu_item_url( $sub_item[2] );
132 - // Capture the first capability-passing submenu URL so
133 - // we can use it as the parent's effective URL below
134 - // (mirrors `wp-admin/menu-header.php`). Captured BEFORE
135 - // the self-link strip so plugins whose first submenu IS
136 - // the auto-prepended self-link land on the parent URL
137 - // (a no-op rewrite — preserves existing behavior).
138 - if ( null === $first_visible_sub_url ) {
139 - $first_visible_sub_url = $sub_url;
167 + $sub_url = openstation_menu_item_url( $sub_item[2] );
168 + $sub_external = openstation_menu_item_is_external( $sub_url );
169 +
170 + if ( $sub_external && ! $allow_external_subs ) {
171 + ++$dropped_off_site;
172 + // Leave a slot behind, in case the wp-admin row this
173 + // entry displaced is still in the list.
174 + $dropped_title = openstation_menu_item_title( $sub_item[0] );
175 + if ( '' !== $dropped_title && ! isset( $restore_slots[ $dropped_title ] ) ) {
176 + $rows[] = array( 'restore' => $dropped_title );
177 + $restore_slots[ $dropped_title ] = count( $rows ) - 1;
178 + }
179 + continue;
140 180 }
141 - // Self-link strip — `$sub_url === $parent_url` covers
142 - // WP's auto-prepended entry AND any plugin-registered
143 - // alias that happens to land on the parent URL.
144 - if ( $sub_url === $parent_url ) {
145 - // Keep its LABEL, though. The stripped entry is a
146 - // real row in wp-admin's own menu ("All Posts",
147 - // "All Pages"), and the constellation flyout lists
148 - // it as the first thing the menu opens — a list of
149 - // a menu's pages that omits its main page reads as
150 - // a bug.
151 - //
152 - // Carried separately rather than left in `submenu`
153 - // because `submenu` has two other consumers that
154 - // need it to mean "distinct child links only": the
155 - // in-window tab strip, which would grow a duplicate
156 - // first tab, and the right-click popover, which is
157 - // suppressed on `length === 0`.
158 - //
159 - // First one only — a plugin can register several
160 - // aliases onto the parent URL, and the canonical
161 - // self-link is the one WordPress prepends.
162 - if ( '' === $self_label ) {
163 - $self_label = openstation_menu_item_title( $sub_item[0] );
181 +
182 + $rows[] = array(
183 + 'raw_title' => $sub_item[0],
184 + 'slug' => (string) $sub_item[2],
185 + 'url' => $sub_url,
186 + 'external' => $sub_external,
187 + 'hidden' => openstation_menu_item_is_hidden( $sub_item ),
188 + );
189 + }
190 + }
191 +
192 + // Second pass. A hidden row moves into the slot its replacement
193 + // left; one whose replacement was the top-level slug itself
194 + // stays where it is (there is no slot — the menu row is not part
195 + // of this list). Every other hidden row, and every slot nothing
196 + // claimed, drops out.
197 + $restored = array();
198 + $keep = array_fill( 0, count( $rows ), true );
199 + foreach ( $rows as $i => $row ) {
200 + if ( isset( $row['restore'] ) || ! $row['hidden'] ) {
201 + continue;
202 + }
203 + $keep[ $i ] = false;
204 + $row_title = openstation_menu_item_title( $row['raw_title'] );
205 + if ( '' === $row_title || isset( $restored[ $row_title ] ) ) {
206 + continue;
207 + }
208 + if ( isset( $restore_slots[ $row_title ] ) ) {
209 + $rows[ $restore_slots[ $row_title ] ] = $row;
210 + $restored[ $row_title ] = true;
211 + } elseif ( $parent_external && $row_title === $title ) {
212 + // The menu's own row, hidden in place. WordPress builds
213 + // a parent's self-link by copying the menu row's first
214 + // four fields, so its label is the menu's label, which
215 + // is what makes the comparison hold.
216 + $keep[ $i ] = true;
217 + $restored[ $row_title ] = true;
218 + }
219 + }
220 +
221 + // Last resort for a menu whose own slug points off-site: if
222 + // nothing on-site survived, take the first hidden on-site row
223 + // rather than lose the menu. The label comparison above is the
224 + // precise answer and covers the ordinary case, but it breaks the
225 + // moment a host relabels the menu row without relabelling the
226 + // self-link it already generated. Showing a row someone hid
227 + // beats dropping a working menu off the dock.
228 + if ( $parent_external ) {
229 + $has_on_site = false;
230 + foreach ( $rows as $i => $row ) {
231 + if ( ! isset( $row['restore'] ) && $keep[ $i ] && ! $row['external'] ) {
232 + $has_on_site = true;
233 + break;
234 + }
235 + }
236 + if ( ! $has_on_site ) {
237 + foreach ( $rows as $i => $row ) {
238 + if ( isset( $row['restore'] ) || ! $row['hidden'] || $row['external'] ) {
239 + continue;
164 240 }
165 - continue;
241 + $keep[ $i ] = true;
242 + break;
166 243 }
167 - // Skip entries with no resolvable title. Plugins (e.g.
168 - // WooCommerce's `wc-addons` Extensions row) register
169 - // `menu_title => null` to hide a row from classic admin's
170 - // left menu while keeping the page reachable. Without
171 - // this guard the dock renders an empty, label-less tab
172 - // that visually duplicates a sibling entry.
173 - $sub_title = openstation_menu_item_title( $sub_item[0] );
174 - if ( '' === $sub_title ) {
175 - continue;
244 + }
245 + }
246 +
247 + $kept_rows = array();
248 + foreach ( $rows as $i => $row ) {
249 + if ( isset( $row['restore'] ) || ! $keep[ $i ] ) {
250 + continue;
251 + }
252 + $kept_rows[] = $row;
253 + }
254 + $rows = $kept_rows;
255 +
256 + // When the top-level slug itself points off-site, the menu's
257 + // identity is now whichever child survived — adopt it before the
258 + // self-link strip runs, so a restored original collapses into
259 + // `selfLabel` instead of becoming a child that duplicates its
260 + // own parent.
261 + //
262 + // Identity travels with it. Everything below keys off the menu's
263 + // slug — whether it's a Core menu, whether a plugin owns it,
264 + // whether it opens more than one window, and which slug the
265 + // `openstation_dock_item` filter is told about. Left on the
266 + // off-site slug, a rescued Plugins tile reads as a plugin menu
267 + // owned by whoever registered the replacement, sorts to the far
268 + // end of the dock, and offers to deactivate them.
269 + $identity_slug = (string) $item[2];
270 + if ( $parent_external ) {
271 + foreach ( $rows as $row ) {
272 + if ( ! $row['external'] ) {
273 + $parent_url = $row['url'];
274 + $identity_slug = $row['slug'];
275 + break;
176 276 }
177 - $sub_items[] = array(
178 - 'title' => $sub_title,
179 - 'url' => $sub_url,
180 - );
181 277 }
182 278 }
183 279
280 + // A menu that only ever pointed at its children, and whose
281 + // children we just took away. Checked only for menus the
282 + // off-site rule actually touched, so a menu registering its page
283 + // hook in some way we don't recognise is left exactly as it was.
284 + $parent_is_container = $dropped_off_site > 0
285 + && ! $parent_external
286 + && ! openstation_menu_slug_has_page( $item[2] );
287 +
288 + $url = $parent_url;
289 + $sub_items = array();
290 + $first_visible_sub_url = null;
291 + $has_self_link = false;
292 + $self_label = '';
293 + foreach ( $rows as $row ) {
294 + $sub_url = $row['url'];
295 + if ( $parent_is_container && $sub_url === $parent_url ) {
296 + // A row pointing back at a menu with no page is a dead
297 + // end, not a way back — it can't name the menu and it
298 + // can't stand in for it.
299 + continue;
300 + }
301 + // Capture the first capability-passing submenu URL so
302 + // we can use it as the parent's effective URL below
303 + // (mirrors `wp-admin/menu-header.php`). Captured BEFORE
304 + // the self-link strip so plugins whose first submenu IS
305 + // the auto-prepended self-link land on the parent URL
306 + // (a no-op rewrite — preserves existing behavior). Never
307 + // an off-site child, which would take the whole tile with
308 + // it when the final external check runs.
309 + if ( null === $first_visible_sub_url && ! $row['external'] ) {
310 + $first_visible_sub_url = $sub_url;
311 + }
312 + // Self-link strip — `$sub_url === $parent_url` covers
313 + // WP's auto-prepended entry AND any plugin-registered
314 + // alias that happens to land on the parent URL.
315 + if ( $sub_url === $parent_url ) {
316 + $has_self_link = true;
317 + // Keep its LABEL, though. The stripped entry is a
318 + // real row in wp-admin's own menu ("All Posts",
319 + // "All Pages"), and the constellation flyout lists
320 + // it as the first thing the menu opens — a list of
321 + // a menu's pages that omits its main page reads as
322 + // a bug.
323 + //
324 + // Carried separately rather than left in `submenu`
325 + // because `submenu` has two other consumers that
326 + // need it to mean "distinct child links only": the
327 + // in-window tab strip, which would grow a duplicate
328 + // first tab, and the right-click popover, which is
329 + // suppressed on `length === 0`.
330 + //
331 + // First one only — a plugin can register several
332 + // aliases onto the parent URL, and the canonical
333 + // self-link is the one WordPress prepends.
334 + if ( '' === $self_label ) {
335 + $self_label = openstation_menu_item_title( $row['raw_title'] );
336 + }
337 + continue;
338 + }
339 + // Skip entries with no resolvable title. Plugins (e.g.
340 + // WooCommerce's `wc-addons` Extensions row) register
341 + // `menu_title => null` to hide a row from classic admin's
342 + // left menu while keeping the page reachable. Without
343 + // this guard the dock renders an empty, label-less tab
344 + // that visually duplicates a sibling entry.
345 + $sub_title = openstation_menu_item_title( $row['raw_title'] );
346 + if ( '' === $sub_title ) {
347 + continue;
348 + }
349 + $sub_entry = array(
350 + 'title' => $sub_title,
351 + 'url' => $sub_url,
352 + // The registered slug, kept for the window-tab merge
353 + // below and stripped again before the payload ships.
354 + 'slug' => (string) $row['slug'],
355 + );
356 + if ( $row['external'] ) {
357 + // Consumers that route a URL into a window skip these;
358 + // the ones that can hand a link to the browser mark
359 + // them as leaving the site.
360 + //
361 + // `offSite` rather than `external`: the window's tab
362 + // strip already calls plugin-opened sub-iframe tabs
363 + // "external" (`data-kind="external"`), and that is a
364 + // different thing entirely.
365 + $sub_entry['offSite'] = true;
366 + }
367 + $sub_items[] = $sub_entry;
368 + }
369 +
184 370 // Mirror `wp-admin/menu-header.php`: when a parent menu has any
185 371 // visible submenu, classic admin rewrites the parent's
186 372 // clickable URL to the first submenu's URL. Plugins like
187 373 // WooCommerce rely on this — their top-level slug
@@ -189,12 +375,74 @@
189 375 // directly. The real landing page is the first submenu
190 376 // (`?page=wc-admin` for WC). Without this rewrite the dock
191 377 // icon points users at a broken URL that classic admin would
192 378 // never have linked to.
193 - if ( null !== $first_visible_sub_url ) {
379 + //
380 + // A menu that registered a self-link has a working page of its
381 + // own and keeps it, wherever in the list that link sits. Only
382 + // the WooCommerce shape — no self-link at all — needs a child to
383 + // stand in. Position matters here because a restored wp-admin
384 + // row inherits the slot its off-site replacement held, which on
385 + // WordPress.com puts `plugin-install.php` first under Plugins.
386 + if ( null !== $first_visible_sub_url && ! $has_self_link ) {
194 387 $url = $first_visible_sub_url;
195 388 }
196 389
390 + // Nothing on this menu resolves to a page we can open. Hosts
391 + // that link their own control panel from the admin menu
392 + // (WordPress.com's My Home, Theme Showcase, Hosting) land here,
393 + // and so does a Core menu whose slug was repointed off-site with
394 + // no wp-admin child left to fall back to.
395 + if ( openstation_menu_item_is_external( $url ) ) {
396 + continue;
397 + }
398 +
399 + // A container menu with nothing left to stand in for it. Its
400 + // URL resolves to core's "Cannot load <slug>." page, which is a
401 + // worse tile than no tile.
402 + if ( $parent_is_container && $url === $parent_url ) {
403 + continue;
404 + }
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 +
197 445 $dock_item = array(
198 446 'id' => sanitize_key( $item[5] ?? $item[2] ),
199 447 'title' => $title,
200 448 'icon' => $icon,
@@ -205,12 +453,14 @@
205 453 // surfaces that list a menu's pages and want its main page
206 454 // named the way wp-admin names it. Empty when the menu had
207 455 // no self-link to strip.
208 456 'selfLabel' => $self_label,
209 - 'multi' => openstation_dock_item_is_multi( $item[2] ),
210 - 'placement' => openstation_dock_placement( $item[2] ),
211 - 'isCore' => openstation_is_core_menu_slug( $item[2] ),
212 - 'pluginFile' => openstation_resolve_menu_plugin_file( $item[2] ),
457 + 'multi' => openstation_dock_item_is_multi( $identity_slug ),
458 + 'placement' => openstation_dock_placement( $identity_slug ),
459 + 'isCore' => openstation_is_core_menu_slug( $identity_slug ),
460 + 'pluginFile' => $identity_slug === (string) $item[2]
461 + ? $plugin_file
462 + : openstation_resolve_menu_plugin_file( $identity_slug ),
213 463 'pluginName' => null,
214 464 );
215 465 if ( $dock_item['pluginFile'] ) {
216 466 $dock_item['pluginName'] = openstation_plugin_display_name( $dock_item['pluginFile'] );
@@ -221,9 +471,9 @@
221 471 *
222 472 * @param array $dock_item The dock item data.
223 473 * @param string $menu_slug The menu slug.
224 474 */
225 - $dock_item = apply_filters( 'openstation_dock_item', $dock_item, $item[2] );
475 + $dock_item = apply_filters( 'openstation_dock_item', $dock_item, $identity_slug );
226 476
227 477 $items[] = $dock_item;
228 478 }
229 479
@@ -235,8 +485,177 @@
235 485 return apply_filters( 'openstation_dock_items', $items );
236 486 }
237 487
238 488 /**
489 + * Whether a resolved menu URL points at a host other than this site's.
490 + *
491 + * OpenStation opens admin pages inside iframes, and an off-site URL
492 + * cannot load in one — the remote origin's `X-Frame-Options` /
493 + * `frame-ancestors` header refuses it. Hosts that extend the admin
494 + * menu with links to their own control panel (WordPress.com registers
495 + * My Home, Theme Showcase, Hosting and friends as `wordpress.com`
496 + * URLs) would therefore fill the dock with tiles that can only ever
497 + * escape to a browser tab, which breaks the shell's navigation model.
498 + * Those entries are dropped from the payload instead.
499 + *
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.
504 + *
505 + * @param string $url Absolute URL, as returned by `openstation_menu_item_url()`.
506 + * @return bool True when the URL is off-site.
507 + */
508 +function openstation_menu_item_is_external( $url ) {
509 + $host = wp_parse_url( (string) $url, PHP_URL_HOST );
510 + $external = false;
511 +
512 + if ( $host ) {
513 + $ours = array();
514 + foreach ( array( openstation_menu_admin_url(), admin_url(), home_url() ) as $known ) {
515 + $known_host = wp_parse_url( $known, PHP_URL_HOST );
516 + if ( $known_host ) {
517 + $ours[] = strtolower( $known_host );
518 + }
519 + }
520 + $external = ! in_array( strtolower( $host ), $ours, true );
521 + }
522 +
523 + /**
524 + * Filters whether an admin-menu URL counts as off-site.
525 + *
526 + * @param bool $external Whether the URL points off-site.
527 + * @param string $url The resolved menu URL.
528 + */
529 + return (bool) apply_filters( 'openstation_menu_item_is_external', $external, $url );
530 +}
531 +
532 +/**
533 + * Whether a `$menu` / `$submenu` row carries the `hide-if-js` class.
534 + *
535 + * Core never sets it on a menu row, so it reads as "some other code
536 + * took this entry out of the sidebar". Jetpack's admin-menu
537 + * customisation on WordPress.com uses it heavily: rather than replace
538 + * a Core entry with its wordpress.com counterpart, it marks the
539 + * original `hide-if-js` and appends a duplicate pointing at Calypso.
540 + * Honouring the class is what keeps those pairs from rendering twice
541 + * in the dock.
542 + *
543 + * @param array $item A `$menu` or `$submenu` row.
544 + * @return bool True when the row is hidden from the classic sidebar.
545 + */
546 +function openstation_menu_item_is_hidden( $item ) {
547 + return ! empty( $item[4] ) && false !== strpos( (string) $item[4], 'hide-if-js' );
548 +}
549 +
550 +/**
551 + * Whether a top-level menu slug has a page of its own behind it.
552 + *
553 + * `add_menu_page()` accepts a `null` callback, which registers a menu
554 + * that is nothing but a container for its children — WordPress links
555 + * such a parent to its first submenu and `admin.php` refuses the slug
556 + * directly with "Cannot load <slug>." WordPress.com's Upgrades menu is
557 + * one: `paid-upgrades.php` has no callback and no self-link, and every
558 + * child is a wordpress.com URL. Drop the children and the tile is left
559 + * pointing at core's error page.
560 + *
561 + * Two ways a slug earns a page: it names a real file under `wp-admin/`,
562 + * or something is listening on its page hook — the same `has_action()`
563 + * test `get_plugin_page_hook()` makes before `admin.php` gives up.
564 + * Anything we can't answer counts as a page, so an unusual registration
565 + * costs a menu nothing.
566 + *
567 + * @param string $slug The menu slug from `$menu[$i][2]`.
568 + * @return bool False only when the slug is provably a container.
569 + */
570 +function openstation_menu_slug_has_page( $slug ) {
571 + if ( openstation_is_admin_file_slug( $slug ) ) {
572 + return true;
573 + }
574 +
575 + if ( ! function_exists( 'get_plugin_page_hookname' ) ) {
576 + return true;
577 + }
578 +
579 + $hookname = get_plugin_page_hookname( $slug, '' );
580 + if ( empty( $hookname ) ) {
581 + return true;
582 + }
583 +
584 + return has_action( $hookname );
585 +}
586 +
587 +/**
588 + * Lazy accessor for the pre-rewrite menu icon snapshot: `slug → icon`.
589 + *
590 + * Populated by {@see openstation_snapshot_menu_icons()}.
591 + *
592 + * @return array<string,string>
593 + */
594 +function &openstation_menu_icon_snapshot() {
595 + static $map = null;
596 + if ( null === $map ) {
597 + $map = array();
598 + }
599 + return $map;
600 +}
601 +
602 +/**
603 + * Record the first real icon each menu row is seen wearing.
604 + *
605 + * A menu row's icon is not final when it is registered. Anything on
606 + * `admin_menu` can rewrite `$menu[ $i ][6]`, and the rewrite that hurts
607 + * is to `'none'` — the row keeps its picture in the sidebar, painted
608 + * from a stylesheet instead, and the menu array stops carrying it. The
609 + * dock reads the array, so those menus arrived wearing a generic gear.
610 + * Jetpack's `override_svg_icons()` does this to every SVG-data-URI icon
611 + * on WordPress.com, which is where it was found, but nothing about the
612 + * move is specific to that host.
613 + *
614 + * Rather than sit at one priority chosen to undercut one known rewriter,
615 + * sample repeatedly and **never overwrite**: the map keeps the earliest
616 + * real icon each slug had, whenever it appeared and whoever blanked it
617 + * afterwards. Write-once is safe because the map is only ever consulted
618 + * as a fallback — a menu that genuinely changes its icon still ships the
619 + * live value.
620 + *
621 + * A slug that had no real icon at any sample point is simply absent, and
622 + * the caller lands on the generic fallback it would have had anyway.
623 + */
624 +function openstation_snapshot_menu_icons() {
625 + global $menu;
626 +
627 + if ( ! is_array( $menu ) ) {
628 + return;
629 + }
630 +
631 + $map = &openstation_menu_icon_snapshot();
632 +
633 + foreach ( $menu as $item ) {
634 + if ( empty( $item[2] ) || empty( $item[6] ) ) {
635 + continue;
636 + }
637 + $slug = (string) $item[2];
638 + if ( isset( $map[ $slug ] ) ) {
639 + continue;
640 + }
641 + $icon = (string) $item[6];
642 + if ( 'none' === $icon || 'div' === $icon ) {
643 + continue;
644 + }
645 + $map[ $slug ] = $icon;
646 + }
647 +}
648 +// Spread across the hook rather than parked just below any one
649 +// rewriter: registrations and rewrites both happen at arbitrary
650 +// priorities, and only a sample taken before a given rewrite can see
651 +// what it overwrote.
652 +foreach ( array( 11, 100, 1000, 99998, PHP_INT_MAX ) as $openstation_icon_snapshot_priority ) {
653 + add_action( 'admin_menu', 'openstation_snapshot_menu_icons', $openstation_icon_snapshot_priority );
654 +}
655 +unset( $openstation_icon_snapshot_priority );
656 +
657 +/**
239 658 * Sanitizes a dock icon value for safe injection into the shell JS.
240 659 *
241 660 * Menu items can set their icon to one of:
242 661 *
@@ -353,12 +772,13 @@
353 772
354 773 /**
355 774 * Filters whether a dock item supports multiple open windows.
356 775 *
357 - * Return true to let the user open more than one window of this page.
358 - * A "+" affordance appears on the dock icon and a "Open another" action
359 - * becomes available in the window's title-bar menu. Singletons (false)
360 - * 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.
361 781 *
362 782 * @param bool $multi Whether this page is multi-capable.
363 783 * @param string $menu_slug The menu slug (e.g. `edit.php?post_type=page`).
364 784 */
@@ -442,8 +862,19 @@
442 862 'link-manager.php', // Link manager (legacy)
443 863 'update-core.php', // Dashboard > Updates
444 864 );
445 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 +
446 877 return in_array( $base, $core_files, true );
447 878 }
448 879
449 880 /**
@@ -596,9 +1027,9 @@
596 1027 if ( ! is_string( $file ) || '' === $file ) {
597 1028 return null;
598 1029 }
599 1030 $plugins_dir = wp_normalize_path( WP_PLUGIN_DIR );
600 - $norm = wp_normalize_path( $file );
1031 + $norm = openstation_plugin_link_path( wp_normalize_path( $file ) );
601 1032 if ( 0 !== strpos( $norm, $plugins_dir . '/' ) ) {
602 1033 return null;
603 1034 }
604 1035 if ( ! function_exists( 'get_plugins' ) ) {
@@ -1067,8 +1498,9 @@
1067 1498 $norm = wp_normalize_path( (string) $frame['file'] );
1068 1499 if ( '' !== $self_dir && 0 === strpos( $norm, $self_dir ) ) {
1069 1500 continue;
1070 1501 }
1502 + $norm = openstation_plugin_link_path( $norm );
1071 1503 foreach ( $dirs as $dir ) {
1072 1504 if ( 0 === strpos( $norm, $dir ) ) {
1073 1505 return $norm;
1074 1506 }
@@ -1076,8 +1508,36 @@
1076 1508 }
1077 1509 return null;
1078 1510 }
1079 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 +
1080 1540 add_action(
1081 1541 'registered_post_type',
1082 1542 static function ( $post_type ) {
1083 1543 openstation_record_type_registrant( $post_type, 'post_type' );
@@ -1208,11 +1668,16 @@
1208 1668 }
1209 1669
1210 1670 $dock = array_merge( $core, $plugin );
1211 1671
1672 + // One collector call feeds both halves: the slim entry list and
1673 + // the handle-keyed script data the shell joins them with.
1674 + $native_windows = openstation_collect_native_windows_payload();
1675 +
1212 1676 $payload = array(
1213 - 'dockItems' => $dock,
1214 - 'nativeWindows' => openstation_build_native_windows_payload(),
1677 + 'dockItems' => $dock,
1678 + 'nativeWindows' => $native_windows['windows'],
1679 + 'nativeWindowScriptData' => $native_windows['scriptData'],
1215 1680 );
1216 1681
1217 1682 // Optional per-surface payload builders — each module ships a
1218 1683 // zero-arg `openstation_build_*_payload()`; modules that aren't
@@ -1270,8 +1735,15 @@
1270 1735 'url' => network_admin_url( 'update-core.php' ),
1271 1736 );
1272 1737 }
1273 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 +
1274 1746 // A cheap structural fingerprint of the admin menu the shell uses to
1275 1747 // decide whether a live refresh is warranted. Shipped in every full
1276 1748 // payload so the shell can seed / update its last-known signature
1277 1749 // without recomputing it client-side (which would risk drift from
@@ -1278,8 +1750,13 @@
1278 1750 // the server's capability-gated view). See
1279 1751 // openstation_menu_signature().
1280 1752 $payload['menuSig'] = openstation_menu_signature();
1281 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 +
1282 1759 return $payload;
1283 1760 }
1284 1761
1285 1762 /**
@@ -1354,8 +1831,247 @@
1354 1831 return md5( implode( "\n", $parts ) );
1355 1832 }
1356 1833
1357 1834 /**
1835 + * A handle's dependency closure, in load order.
1836 + *
1837 + * Post-order depth-first: a handle is emitted only after everything it
1838 + * declares, which is the order `WP_Scripts::do_item()` would have
1839 + * printed them in. A handle is marked visited *before* its own
1840 + * dependencies are walked, so a dependency cycle unwinds instead of
1841 + * recursing forever, and an unregistered handle is skipped rather than
1842 + * being fatal — it contributes nothing and stops nothing.
1843 + *
1844 + * **Deliberately not `WP_Dependencies::all_deps()`.** Three reasons,
1845 + * each of which has bitten this codebase:
1846 + *
1847 + * 1. `WP_Scripts::all_deps()` applies `print_scripts_array` to its
1848 + * result whenever `$recursion` is falsy. That filter is where the
1849 + * chromeless palette trim and the asset guard live, so resolving a
1850 + * payload through it would run a print-time trim across a dependency
1851 + * list and let the guard splice this plugin's own bundles into it.
1852 + * Called from inside one of those filters it is an infinite loop.
1853 + *
1854 + * 2. Passing `$recursion = true` silences that filter but changes the
1855 + * contract: the first handle that fails aborts the entire call
1856 + * (`return false`), abandoning every handle after it in the list. The
1857 + * caller is left with a `$to_do` that is a truncated prefix of the real
1858 + * closure and indistinguishable from a complete one — a silent, partial
1859 + * answer conditional on unrelated registrations elsewhere on the page.
1860 + * A lazily-delivered bundle resolved that way loses packages it
1861 + * declared and throws on an undefined global at mount, which is the
1862 + * exact bug this whole mechanism exists to prevent.
1863 + *
1864 + * 3. `all_deps()` reports missing dependencies through
1865 + * `_doing_it_wrong()`. This is read-only analysis; the real print pass
1866 + * raises those anyway, and raising them twice turns someone else's
1867 + * pre-existing warning into our noise.
1868 + *
1869 + * O(V+E) over the graph, allocates one set, and clones nothing.
1870 + *
1871 + * @param WP_Dependencies $dependencies The scripts or styles registry.
1872 + * @param string[] $handles Roots to walk.
1873 + * @return string[] Registered handles, dependencies before dependents.
1874 + */
1875 +function openstation_script_dependency_closure( $dependencies, $handles ) {
1876 + $seen = array();
1877 + $out = array();
1878 + openstation_collect_script_dependency_closure( $dependencies, (array) $handles, $seen, $out );
1879 +
1880 + return $out;
1881 +}
1882 +
1883 +/**
1884 + * Recursive half of {@see openstation_script_dependency_closure()}.
1885 + *
1886 + * @param WP_Dependencies $dependencies The scripts or styles registry.
1887 + * @param string[] $handles Handles to walk.
1888 + * @param array $seen Handle => true, by reference.
1889 + * @param string[] $out Ordered result, by reference.
1890 + */
1891 +function openstation_collect_script_dependency_closure( $dependencies, $handles, &$seen, &$out ) {
1892 + foreach ( (array) $handles as $handle ) {
1893 + if ( isset( $seen[ $handle ] ) ) {
1894 + continue;
1895 + }
1896 + // Marked BEFORE recursing, so a cycle meets itself as visited
1897 + // and unwinds rather than recursing forever.
1898 + $seen[ $handle ] = true;
1899 + if ( ! isset( $dependencies->registered[ $handle ] ) ) {
1900 + continue;
1901 + }
1902 + openstation_collect_script_dependency_closure(
1903 + $dependencies,
1904 + $dependencies->registered[ $handle ]->deps,
1905 + $seen,
1906 + $out
1907 + );
1908 + $out[] = $handle;
1909 + }
1910 +}
1911 +
1912 +/**
1913 + * Resolve a handle's dependency closure, in load order.
1914 + *
1915 + * **Why a lazily-delivered handle needs this at all.** WordPress
1916 + * normally resolves a script's dependencies when it enqueues it — the
1917 + * packages a bundle declares are on the page before its own body runs.
1918 + * A handle that is only ever delivered lazily never goes through that:
1919 + * `loadVendorScript()` injects one URL, and a bundle declaring
1920 + * `wp-api-fetch` found `wp.apiFetch` undefined at mount.
1921 + *
1922 + * That used to work by accident. Core's ⌘K palette was enqueued on
1923 + * every admin page and its closure is the whole Gutenberg runtime, so
1924 + * `wp.apiFetch`, `wp.element` and friends happened to be globals.
1925 + * Deferring the palette took the accident away and left the contract
1926 + * exposed — see `docs/migration-wp-package-globals.md`.
1927 + *
1928 + * The closure comes from {@see openstation_script_dependency_closure()}
1929 + * rather than `WP_Dependencies::all_deps()`; that function's docblock
1930 + * records why, and the short version is that `all_deps()` answers a
1931 + * question like this one with a silently truncated list. The handle
1932 + * itself is excluded — the caller loads it separately, after these.
1933 + *
1934 + * @param string $handle Script handle.
1935 + * @return array<int,array<string,mixed>> Ordered dependency payloads.
1936 + */
1937 +function openstation_resolve_script_dependencies( $handle ) {
1938 + $handle = (string) $handle;
1939 + $wp_scripts = wp_scripts();
1940 + if ( '' === $handle || ! $wp_scripts || ! isset( $wp_scripts->registered[ $handle ] ) ) {
1941 + return array();
1942 + }
1943 + $deps = $wp_scripts->registered[ $handle ]->deps;
1944 + if ( empty( $deps ) ) {
1945 + return array();
1946 + }
1947 +
1948 + $out = array();
1949 + foreach ( openstation_script_dependency_closure( $wp_scripts, $deps ) as $dep_handle ) {
1950 + if ( $dep_handle === $handle ) {
1951 + continue;
1952 + }
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.
1958 + if ( '' === $payload['url']
1959 + && empty( $payload['before'] )
1960 + && empty( $payload['after'] )
1961 + && empty( $payload['l10n'] ) ) {
1962 + continue;
1963 + }
1964 + // The handle rides along because the shell needs it to decide
1965 + // whether the page already has this package. A URL is not
1966 + // enough: with Core's script concatenation on — the wp-admin
1967 + // default — every package below `wp-includes/js/` is served
1968 + // from one `load-scripts.php` blob and has no `<script src>`
1969 + // of its own to match against. Re-running `wp-hooks` because
1970 + // we could not see it replaces `window.wp.hooks`, and every
1971 + // subscriber registered at boot goes deaf. See
1972 + // `src/script-presence.ts`.
1973 + $payload['handle'] = (string) $dep_handle;
1974 + $out[] = $payload;
1975 + }
1976 + return $out;
1977 +}
1978 +
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 +/**
1358 2074 * Resolve a registered WP script handle into the full payload the
1359 2075 * shell needs to lazy-load it without going through `wp_print_scripts()`.
1360 2076 *
1361 2077 * Returns:
@@ -1379,10 +2095,14 @@
1379 2095 * around the lazy `<script src>` in the same order
1380 2096 * `WP_Scripts::do_item()` would have used.
1381 2097 *
1382 2098 * Returns an empty payload (`array( 'url' => '' )`) when the handle
1383 - * is unregistered or has no source — callers treat that as "no
1384 - * 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.
1385 2105 *
1386 2106 * Shared between `openstation_register_window()` and
1387 2107 * `openstation_register_widget()` (and every other registration that
1388 2108 * relies on lazy script loading in the shell) because all of them
@@ -1410,20 +2130,31 @@
1410 2130 return $empty;
1411 2131 }
1412 2132 $registered = $wp_scripts->registered[ $handle ];
1413 2133 $src = is_string( $registered->src ) ? $registered->src : '';
1414 - if ( '' === $src ) {
1415 - return $empty;
1416 - }
1417 2134
1418 - // Normalize relative paths + attach cache-bust ver.
1419 - $resolved = $src;
1420 - if ( 0 === strpos( $resolved, '/' ) && 0 !== strpos( $resolved, '//' ) ) {
1421 - $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 + }
1422 2156 }
1423 - if ( ! empty( $registered->ver ) ) {
1424 - $resolved = add_query_arg( 'ver', $registered->ver, $resolved );
1425 - }
1426 2157
1427 2158 // Harvest `extra` data the lazy-load path would otherwise drop.
1428 2159 $before = array();
1429 2160 $after = array();
@@ -1458,9 +2189,9 @@
1458 2189 // `wp.i18n.setLocaleData( JSON, 'domain' )` snippet that the print
1459 2190 // pipeline emits before the script body. `print_translations(
1460 2191 // $handle, false )` returns the snippet without echoing.
1461 2192 $translations = '';
1462 - if ( method_exists( $wp_scripts, 'print_translations' ) ) {
2193 + if ( '' !== $resolved && method_exists( $wp_scripts, 'print_translations' ) ) {
1463 2194 $captured = $wp_scripts->print_translations( $handle, false );
1464 2195 if ( is_string( $captured ) ) {
1465 2196 $translations = $captured;
1466 2197 }
@@ -1547,8 +2278,192 @@
1547 2278 );
1548 2279 }
1549 2280
1550 2281 /**
2282 + * Build the deferred command-palette asset manifest.
2283 + *
2284 + * `wp_enqueue_command_palette_assets()` (WP 6.9+) enqueues
2285 + * `wp-commands` + `wp-core-commands` and attaches the inline
2286 + * `wp.coreCommands.initializeCommandPalette( … )` call that seeds the
2287 + * `core/commands` store. Its transitive dependency chain is the whole
2288 + * Gutenberg runtime — `wp-block-editor`, `wp-components`, React,
2289 + * `wp-core-data`, some forty bundles, ~800 KB gzipped — which the
2290 + * shell used to pay on EVERY boot so that the ⌘K palette's baseline
2291 + * commands existed if the user ever opened it.
2292 + *
2293 + * This builder lets Core do exactly what it would have done — the
2294 + * menu-command serialization and the inline init included — then
2295 + * UNWINDS the enqueue: it snapshots the script/style queues, calls
2296 + * the Core function, diffs out the roots it added, restores the
2297 + * queues so nothing prints at boot, and resolves the full ordered
2298 + * dependency chain on CLONES (the live `$to_do` is never touched).
2299 + * Each handle in the chain is harvested into the same
2300 + * url/before/after/l10n/translations shape the native-window lazy
2301 + * loader uses, and the shell replays the list — in order — the first
2302 + * time the palette is invoked (`src/commands/palette-assets.ts`).
2303 + *
2304 + * Handles with no `src` (pure aggregators) are kept whenever they
2305 + * carry inline data; dropping them would lose middleware and locale
2306 + * setup the chain depends on. Handles the boot page already printed
2307 + * are skipped client-side, by handle as well as by path so that a
2308 + * package Core concatenated into `load-scripts.php` is recognized
2309 + * (`src/script-presence.ts`) — the manifest deliberately lists them
2310 + * anyway, because which ones those are differs per site and per
2311 + * screen. Each entry therefore carries its `handle`, and that is
2312 + * load-bearing rather than informational.
2313 + *
2314 + * Returns `null` on pre-6.9 sites (no Core palette to defer).
2315 + *
2316 + * @return array{scripts:array<int,array<string,mixed>>,styles:array<int,array<string,mixed>>}|null
2317 + */
2318 +function openstation_build_command_palette_assets_payload() {
2319 + if ( ! function_exists( 'wp_enqueue_command_palette_assets' ) ) {
2320 + return null;
2321 + }
2322 + $scripts = wp_scripts();
2323 + $styles = wp_styles();
2324 + if ( ! $scripts || ! $styles ) {
2325 + return null;
2326 + }
2327 +
2328 + // `wp_enqueue_command_palette_assets()` reads `$submenu` without
2329 + // guarding the global — initialize defensively (test contexts,
2330 + // edge-case admin requests where the menu wasn't built yet).
2331 + global $menu, $submenu;
2332 + // phpcs:disable WordPress.WP.GlobalVariablesOverride.Prohibited -- initializing an unset global to its documented empty shape, not replacing a built menu.
2333 + if ( ! isset( $submenu ) || ! is_array( $submenu ) ) {
2334 + $submenu = array();
2335 + }
2336 + if ( ! isset( $menu ) || ! is_array( $menu ) ) {
2337 + $menu = array();
2338 + }
2339 + // phpcs:enable WordPress.WP.GlobalVariablesOverride.Prohibited
2340 +
2341 + $script_queue_before = $scripts->queue;
2342 + $style_queue_before = $styles->queue;
2343 +
2344 + wp_enqueue_command_palette_assets();
2345 +
2346 + $script_roots = array_values( array_diff( $scripts->queue, $script_queue_before ) );
2347 + $style_roots = array_values( array_diff( $styles->queue, $style_queue_before ) );
2348 +
2349 + // Unwind: the boot page must not print any of it. The inline init
2350 + // stays attached to the `wp-core-commands` HANDLE — that is the
2351 + // point: the harvest below captures it, and if some other screen
2352 + // legitimately enqueues the handle, it prints as Core intended.
2353 + $scripts->queue = $script_queue_before;
2354 + $styles->queue = $style_queue_before;
2355 +
2356 + $out = array(
2357 + 'scripts' => array(),
2358 + 'styles' => array(),
2359 + );
2360 +
2361 + // Ordered dependency chains, resolved on clones so the request's
2362 + // real `$to_do` / `$done` state is untouched.
2363 + $script_probe = clone $scripts;
2364 + $script_probe->to_do = array();
2365 + $script_probe->done = array();
2366 + $script_probe->all_deps( $script_roots );
2367 + foreach ( $script_probe->to_do as $handle ) {
2368 + $payload = openstation_resolve_script_payload( $handle );
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;
2377 + }
2378 + // Core's `initializeCommandPalette( {…} )` inline embeds the
2379 + // serialized admin-menu command list — ~20 KB that the boot
2380 + // page ALREADY carries as `window.__openStationMenuCommands`
2381 + // (the shell harvester's lookup, attached as a `before`
2382 + // inline on the main bundle, and the richer of the two: its
2383 + // URL derivation routes legacy file-path slugs through
2384 + // `menu_page_url()` where Core's regex takes them literally).
2385 + // Ship the list once: strip Core's embedded copy and
2386 + // synthesize the same call against the global, which is
2387 + // guaranteed present long before the manifest replays — it
2388 + // prints at boot, the replay waits for the first ⌘K.
2389 + if ( 'wp-core-commands' === $handle ) {
2390 + foreach ( array( 'before', 'after' ) as $position ) {
2391 + $payload[ $position ] = array_values(
2392 + array_filter(
2393 + $payload[ $position ],
2394 + static function ( $snippet ) {
2395 + return false === strpos( (string) $snippet, 'initializeCommandPalette(' );
2396 + }
2397 + )
2398 + );
2399 + }
2400 + $payload['after'][] = sprintf(
2401 + 'wp.coreCommands.initializeCommandPalette({"is_network_admin":%s,"menu_commands":window.__openStationMenuCommands||[]});',
2402 + is_network_admin() ? 'true' : 'false'
2403 + );
2404 + }
2405 +
2406 + $out['scripts'][] = array(
2407 + 'handle' => (string) $handle,
2408 + 'url' => $payload['url'],
2409 + 'before' => $payload['before'],
2410 + 'after' => $payload['after'],
2411 + 'l10n' => $payload['l10n'],
2412 + 'translations' => $payload['translations'],
2413 + );
2414 + }
2415 +
2416 + $style_probe = clone $styles;
2417 + $style_probe->to_do = array();
2418 + $style_probe->done = array();
2419 + $style_probe->all_deps( $style_roots );
2420 + foreach ( $style_probe->to_do as $handle ) {
2421 + $style_payload = openstation_resolve_style_payload( $handle );
2422 + if ( '' === $style_payload['url'] ) {
2423 + continue;
2424 + }
2425 + $out['styles'][] = array(
2426 + 'handle' => (string) $handle,
2427 + 'url' => $style_payload['url'],
2428 + 'inline' => $style_payload['inline'],
2429 + );
2430 + }
2431 +
2432 + return $out;
2433 +}
2434 +
2435 +/**
2436 + * Resolve a list of style handles into the `deferredStyles` config
2437 + * map: handle → `array( 'url' => …, 'inline' => string[] )`.
2438 + *
2439 + * For shell surfaces that render on demand but are NOT native
2440 + * windows — the Preferences panel, the AI assistant, the bug-report
2441 + * window — so the `styles` companion mechanism can't carry their
2442 + * CSS. The shell reads this map off `openStationConfig.deferredStyles`
2443 + * and injects each sheet the first time its surface opens
2444 + * (`ensureDeferredStyle()` in `src/deferred-styles.ts`).
2445 + *
2446 + * Handles that resolve to nothing (never registered) are dropped, so
2447 + * the client map only ever holds injectable entries.
2448 + *
2449 + * @param string[] $handles Registered style handles.
2450 + * @return array<string, array{url:string, inline:string[]}>
2451 + */
2452 +function openstation_build_deferred_styles( $handles ) {
2453 + $out = array();
2454 + foreach ( (array) $handles as $handle ) {
2455 + $handle = (string) $handle;
2456 + $payload = openstation_resolve_style_payload( $handle );
2457 + if ( '' === $payload['url'] ) {
2458 + continue;
2459 + }
2460 + $out[ $handle ] = $payload;
2461 + }
2462 + return $out;
2463 +}
2464 +
2465 +/**
1551 2466 * Fire a `_doing_it_wrong()` notice exactly once per handle per
1552 2467 * request. Shared by every `openstation_build_desktop_*_scripts_payload()`
1553 2468 * caller — payload builders run on every shell-config rebuild
1554 2469 * (multiple times per page load via REST + admin-bar refresh +
@@ -1577,9 +2492,9 @@
1577 2492 _doing_it_wrong(
1578 2493 esc_html( $function_name ),
1579 2494 sprintf(
1580 2495 /* translators: 1: kind ("Command"/"Settings-tab"/"Title-bar button"), 2: handle. */
1581 - 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' ),
2496 + 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' ),
1582 2497 esc_html( $kind ),
1583 2498 esc_html( $handle )
1584 2499 ),
1585 2500 '0.8.1'
@@ -1621,28 +2536,153 @@
1621 2536 openstation_warn_unresolvable_script_handle( '', '', '__flush__' );
1622 2537 }
1623 2538
1624 2539 /**
1625 - * Serialize the server-declared native-window registry into the
1626 - * payload shape the shell consumes. For each entry registered via
1627 - * `openstation_register_window()`, we capture: the window's
1628 - * metadata (id/title/icon/placement/dimensions/autofocus), the
1629 - * rendered template HTML (by running the template callback into an
1630 - * output buffer), and the URL of the enqueued script handle (so
1631 - * mid-session activations can load the plugin's JS dynamically
1632 - * without a full shell reload).
2540 + * Collect the native-window payload: slim per-window entries plus a
2541 + * handle-keyed script-data map.
1633 2542 *
1634 - * @return array[]
2543 + * For each entry registered via `openstation_register_window()` the
2544 + * `windows` list captures the window's metadata
2545 + * (id/title/icon/placement/dimensions/autofocus), the rendered
2546 + * template HTML, and the HANDLE NAMES of its script, companions and
2547 + * tab scripts. The resolved data those handles stand for — URL plus
2548 + * harvested `wp_localize_script` / `wp_add_inline_script` /
2549 + * translations, see `openstation_resolve_script_payload()` — lives
2550 + * ONCE per handle in `scriptData`, and the shell joins the two on
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.
2556 + *
2557 + * The split exists because script data is a property of the HANDLE,
2558 + * not of the window: every App Framework window rides
2559 + * `openstation-app-runtime`, and inlining each entry's resolved copy
2560 + * serialized the same localize blobs and the same shared config set
2561 + * four times over — `scriptL10n` alone was ~100 KB of the boot
2562 + * payload, most of it repetition. The synthesized
2563 + * `openStationWindowConfig[ id ]` assignments group by handle for
2564 + * the same reason they used to ride every sharing entry: the shell
2565 + * fetches a URL once, and a bundle can serve one window from inside
2566 + * another (the Users window mounts the Profile form, which reads the
2567 + * user-edit config), so whichever entry loads the bundle must
2568 + * deliver the whole handle's config set.
2569 + *
2570 + * Style data stays inline on the entries — it never had a
2571 + * duplication problem worth a second map ( companion styles across
2572 + * the whole registry total ~2 KB ).
2573 + *
2574 + * @return array{windows:array[],scriptData:array<string,array{url:string,before:string[],after:string[],l10n:string[],translations:string,deps:string[]}>}
1635 2575 */
1636 -function openstation_build_native_windows_payload() {
2576 +function openstation_collect_native_windows_payload() {
2577 + $empty = array(
2578 + 'windows' => array(),
2579 + 'scriptData' => array(),
2580 + );
1637 2581 if ( ! function_exists( 'openstation_native_window_registry' ) ) {
1638 - return array();
2582 + return $empty;
1639 2583 }
2584 +
1640 2585 $registry = openstation_native_window_registry();
1641 2586 if ( ! is_array( $registry ) ) {
1642 - return array();
2587 + return $empty;
1643 2588 }
1644 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 +
2606 + $script_data = array();
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 +
2616 + // Resolve a handle into the map, once. Returns the handle when it
2617 + // resolved to something loadable, '' when it did not (never
2618 + // registered, no src) — the same silent drop the inline shape
2619 + // applied to companions and tab scripts.
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 ) {
2633 + $handle = (string) $handle;
2634 + if ( '' === $handle ) {
2635 + return '';
2636 + }
2637 + if ( isset( $resolved_as_bundle[ $handle ] ) ) {
2638 + return $resolved_as_bundle[ $handle ];
2639 + }
2640 + $payload = isset( $script_data[ $handle ] )
2641 + ? $script_data[ $handle ]
2642 + : openstation_resolve_script_payload( $handle );
2643 + if ( '' === $payload['url'] ) {
2644 + $resolved_as_bundle[ $handle ] = '';
2645 + return '';
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;
2659 + $script_data[ $handle ] = $payload;
2660 + return $handle;
2661 + };
2662 +
2663 + // Synthesized `openStationWindowConfig[ id ]` assignments, grouped
2664 + // by script handle (see the function docblock). Collected first so
2665 + // they can be appended to each handle's map entry exactly once,
2666 + // after its own harvested data — the same order the print pipeline
2667 + // would have used.
2668 + $config_snippets_by_handle = array();
2669 + foreach ( $registry as $entry ) {
2670 + $handle = isset( $entry['script'] ) ? (string) $entry['script'] : '';
2671 + if ( '' === $handle || ! is_callable( $entry['template'] ) ) {
2672 + continue;
2673 + }
2674 + $window_config = openstation_filter_native_window_config( $entry );
2675 + if ( empty( $window_config ) ) {
2676 + continue;
2677 + }
2678 + $config_snippets_by_handle[ $handle ][ $entry['id'] ] = sprintf(
2679 + 'window.openStationWindowConfig=window.openStationWindowConfig||{};window.openStationWindowConfig[%s]=%s;',
2680 + wp_json_encode( $entry['id'] ),
2681 + wp_json_encode( $window_config )
2682 + );
2683 + }
2684 +
1645 2685 $out = array();
1646 2686 foreach ( $registry as $entry ) {
1647 2687 if ( ! is_callable( $entry['template'] ) ) {
1648 2688 continue;
@@ -1655,40 +2695,34 @@
1655 2695 // `<template>` at mid-session plugin activation without a
1656 2696 // reload.
1657 2697 $template_html = openstation_build_native_window_template_html( $entry );
1658 2698
1659 - // Resolve script handle → full payload (URL + harvested
1660 - // `extra` data) so the shell can inject a `<script>` tag
1661 - // dynamically on mid-session activation WITHOUT dropping
1662 - // `wp_localize_script` / `wp_add_inline_script` data the way
1663 - // the bare `<script src>` lazy-load path would. See
1664 - // `openstation_resolve_script_payload()` for shape.
1665 - $script_handle = isset( $entry['script'] ) ? (string) $entry['script'] : '';
1666 - $script_payload = openstation_resolve_script_payload( $script_handle );
2699 + // `$collect_handle()` answers "is there a bundle to fetch?", and
2700 + // returns '' when the handle resolves to no URL — a src-less
2701 + // alias handle registered only to carry `preload_script` or
2702 + // inline data, for instance. That is the right answer for
2703 + // `scriptHandle`, which names something to load. It is the
2704 + // wrong answer for `ownerHandle`, which names WHO the window
2705 + // belongs to: attribution does not depend on whether the owner
2706 + // happens to ship a file. Shipping '' there broke the
2707 + // documented "always populated" contract and blanked
2708 + // `wp.os.debug.window()`.
2709 + $declared_script = isset( $entry['script'] ) ? (string) $entry['script'] : '';
2710 + $script_handle = $collect_handle( $declared_script );
2711 + $owner_handle = '' !== $script_handle ? $script_handle : $declared_script;
1667 2712
1668 2713 // Companion handles (`scripts` arg) — bundles that extend the
1669 2714 // window from outside it and must be in the tab before its
1670 - // render callback paints. Same resolved shape as the main
1671 - // script, kept as a list so the shell loads them in the
1672 - // declared order ahead of it. Handles that resolve to nothing
1673 - // (never registered) are dropped rather than shipped as an
1674 - // entry the loader would skip anyway.
2715 + // render callback paints. Kept as an ordered handle list; the
2716 + // shell loads them in declared order ahead of the window's
2717 + // own script, resolving each through `scriptData`.
1675 2718 $companion_scripts = array();
1676 2719 if ( ! empty( $entry['scripts'] ) && is_array( $entry['scripts'] ) ) {
1677 2720 foreach ( $entry['scripts'] as $companion_handle ) {
1678 - $companion_handle = (string) $companion_handle;
1679 - $companion_payload = openstation_resolve_script_payload( $companion_handle );
1680 - if ( '' === $companion_payload['url'] ) {
1681 - continue;
2721 + $companion_handle = $collect_handle( $companion_handle );
2722 + if ( '' !== $companion_handle ) {
2723 + $companion_scripts[] = $companion_handle;
1682 2724 }
1683 - $companion_scripts[] = array(
1684 - 'scriptUrl' => $companion_payload['url'],
1685 - 'scriptHandle' => $companion_handle,
1686 - 'scriptBefore' => $companion_payload['before'],
1687 - 'scriptAfter' => $companion_payload['after'],
1688 - 'scriptL10n' => $companion_payload['l10n'],
1689 - 'scriptTranslations' => $companion_payload['translations'],
1690 - );
1691 2725 }
1692 2726 }
1693 2727
1694 2728 // Resolve the optional style handle alongside the script so the
@@ -1698,85 +2732,124 @@
1698 2732 // handle isn't registered — both treated as "no styles to load."
1699 2733 $style_handle = isset( $entry['style'] ) ? (string) $entry['style'] : '';
1700 2734 $style_payload = openstation_resolve_style_payload( $style_handle );
1701 2735
1702 - // `config` arg on `openstation_register_window()` — discoverable
1703 - // alternative to `wp_localize_script`. We synthesize a localize
1704 - // snippet so it lands through the same delivery path as native
1705 - // `wp_localize_script`. The bundle reads
1706 - // `window.openStationWindowConfig[id]` (or via
1707 - // `wp.os.getWindowConfig(id)`).
1708 - if ( ! empty( $entry['config'] ) && is_array( $entry['config'] ) ) {
1709 - $script_payload['l10n'][] = sprintf(
1710 - 'window.openStationWindowConfig=window.openStationWindowConfig||{};window.openStationWindowConfig[%s]=%s;',
1711 - wp_json_encode( $entry['id'] ),
1712 - wp_json_encode( $entry['config'] )
1713 - );
2736 + // Companion style handles (`styles` arg) — stylesheets the
2737 + // shell injects on the window's FIRST OPEN, after the window's
2738 + // own style, in declared order. The styles-side mirror of
2739 + // `companionScripts`, with different timing on purpose: the
2740 + // window's own `style` lands when the window registers so a
2741 + // mid-session activation paints, but a companion exists to be
2742 + // deferred — it costs nothing until the window is actually
2743 + // shown. Unregistered handles drop, same as script companions.
2744 + $companion_styles = array();
2745 + if ( ! empty( $entry['styles'] ) && is_array( $entry['styles'] ) ) {
2746 + foreach ( $entry['styles'] as $companion_style_handle ) {
2747 + $companion_style_handle = (string) $companion_style_handle;
2748 + $companion_style_payload = openstation_resolve_style_payload( $companion_style_handle );
2749 + if ( '' === $companion_style_payload['url'] ) {
2750 + continue;
2751 + }
2752 + $companion_styles[] = array(
2753 + 'styleUrl' => $companion_style_payload['url'],
2754 + 'styleHandle' => $companion_style_handle,
2755 + 'styleInline' => $companion_style_payload['inline'],
2756 + );
2757 + }
1714 2758 }
1715 2759
1716 - // Tab metadata (label + extra script payloads) ships alongside
1717 - // the template so the shell can render a picker UI or load
1718 - // additional tab scripts when a tab's activation is late.
2760 + // Tab metadata ships alongside the template so the shell can
2761 + // render a picker UI, and each tab's script handle joins the
2762 + // map so a late tab activation can still load its bundle.
1719 2763 $tab_descriptors = array();
1720 2764 if ( function_exists( 'openstation_get_native_window_tabs' ) ) {
1721 2765 foreach ( openstation_get_native_window_tabs( $entry['id'] ) as $tab ) {
1722 - // The resolver returns the empty payload shape itself
1723 - // for an empty handle — no need to hand-write it here.
1724 - $tab_payload = openstation_resolve_script_payload( $tab['script'] );
1725 2766 $tab_descriptors[] = array(
1726 - 'value' => $tab['value'],
1727 - 'label' => $tab['label'],
1728 - 'isMain' => $tab['is_main'],
1729 - 'scriptUrl' => $tab_payload['url'],
1730 - 'scriptHandle' => $tab['script'],
1731 - 'scriptBefore' => $tab_payload['before'],
1732 - 'scriptAfter' => $tab_payload['after'],
1733 - 'scriptL10n' => $tab_payload['l10n'],
1734 - 'scriptTranslations' => $tab_payload['translations'],
2767 + 'value' => $tab['value'],
2768 + 'label' => $tab['label'],
2769 + 'isMain' => $tab['is_main'],
2770 + 'scriptHandle' => $collect_handle( $tab['script'] ),
1735 2771 );
1736 2772 }
1737 2773 }
1738 2774
1739 2775 $out[] = array(
1740 - 'id' => $entry['id'],
1741 - 'title' => $entry['title'],
1742 - 'icon' => $entry['icon'],
1743 - 'placement' => $entry['placement'],
2776 + 'id' => $entry['id'],
2777 + 'title' => $entry['title'],
2778 + 'icon' => $entry['icon'],
2779 + 'placement' => $entry['placement'],
2780 + // `'app'` or `'control'` — the navigation kind, which
2781 + // decides the launcher's default placement and its dock
2782 + // zone. See `src/nav/defaults.ts`.
2783 + 'navKind' => isset( $entry['nav_kind'] ) ? $entry['nav_kind'] : 'app',
1744 2784 // Sort key among system tiles. Absent / 0 puts a plugin's
1745 2785 // launcher ahead of the shell's own trailing cluster.
1746 - 'dockOrder' => isset( $entry['dock_order'] ) ? (int) $entry['dock_order'] : 0,
1747 - 'placeable' => ! empty( $entry['placeable'] ),
1748 - 'width' => $entry['width'],
1749 - 'height' => $entry['height'],
1750 - 'minWidth' => $entry['min_width'],
1751 - 'minHeight' => $entry['min_height'],
1752 - 'autofocus' => $entry['autofocus'],
1753 - 'templateId' => 'os-native-window-' . $entry['id'],
1754 - 'templateHtml' => $template_html,
1755 - 'scriptUrl' => $script_payload['url'],
1756 - 'scriptHandle' => $script_handle,
1757 - 'ownerHandle' => $script_handle,
1758 - 'scriptBefore' => $script_payload['before'],
1759 - 'scriptAfter' => $script_payload['after'],
1760 - 'scriptL10n' => $script_payload['l10n'],
1761 - 'scriptTranslations' => $script_payload['translations'],
1762 - 'companionScripts' => $companion_scripts,
2786 + 'dockOrder' => isset( $entry['dock_order'] ) ? (int) $entry['dock_order'] : 0,
2787 + 'placeable' => ! empty( $entry['placeable'] ),
2788 + 'width' => $entry['width'],
2789 + 'height' => $entry['height'],
2790 + 'minWidth' => $entry['min_width'],
2791 + 'minHeight' => $entry['min_height'],
2792 + 'autofocus' => $entry['autofocus'],
2793 + 'templateId' => 'os-native-window-' . $entry['id'],
2794 + 'templateHtml' => $template_html,
2795 + 'scriptHandle' => $script_handle,
2796 + 'ownerHandle' => $owner_handle,
2797 + 'companionScripts' => $companion_scripts,
1763 2798 // Whether the shell loads the bundle at boot rather than on
1764 2799 // first open. Off by default: a window's script is dead
1765 2800 // weight on every admin page until the window is actually
1766 2801 // opened.
1767 - 'preloadScript' => ! empty( $entry['preload_script'] ),
1768 - 'styleUrl' => $style_payload['url'],
1769 - 'styleHandle' => $style_handle,
1770 - 'styleInline' => $style_payload['inline'],
1771 - 'tabs' => $tab_descriptors,
2802 + 'preloadScript' => ! empty( $entry['preload_script'] ),
2803 + 'styleUrl' => $style_payload['url'],
2804 + 'styleHandle' => $style_handle,
2805 + 'styleInline' => $style_payload['inline'],
2806 + 'companionStyles' => $companion_styles,
2807 + 'tabs' => $tab_descriptors,
2808 + 'menuPages' => isset( $entry['menu_pages'] ) ? array_values( (array) $entry['menu_pages'] ) : array(),
1772 2809 );
1773 2810 }
1774 2811
1775 - return $out;
2812 + // Append each handle's synthesized config set to its map entry —
2813 + // once, after the handle's own harvested data. The snippets land
2814 + // in REGISTRY-ITERATION order for every consumer of the handle;
2815 + // the old per-entry shape put each window's own config first, an
2816 + // ordering nothing could observe (each snippet assigns a distinct
2817 + // `openStationWindowConfig[ id ]` key and none reads another), so
2818 + // it is deliberately not preserved. Configs for handles that
2819 + // resolved to nothing are undeliverable and drop, exactly as they
2820 + // always did.
2821 + foreach ( $config_snippets_by_handle as $handle => $snippets ) {
2822 + if ( ! isset( $script_data[ $handle ] ) ) {
2823 + continue;
2824 + }
2825 + foreach ( $snippets as $snippet ) {
2826 + $script_data[ $handle ]['l10n'][] = $snippet;
2827 + }
2828 + }
2829 +
2830 + return array(
2831 + 'windows' => $out,
2832 + 'scriptData' => $script_data,
2833 + );
1776 2834 }
1777 2835
1778 2836 /**
2837 + * The `windows` half of {@see openstation_collect_native_windows_payload()}.
2838 + *
2839 + * Kept as the historical entry point — tests and older call sites
2840 + * ask for the entry list alone. Anything that also needs the
2841 + * script-data map (everything that actually LOADS a bundle) should
2842 + * call the collector and take both halves from one build.
2843 + *
2844 + * @return array[]
2845 + */
2846 +function openstation_build_native_windows_payload() {
2847 + $bundle = openstation_collect_native_windows_payload();
2848 + return $bundle['windows'];
2849 +}
2850 +
2851 +/**
1779 2852 * Cleans a `$menu` / `$submenu` title for display.
1780 2853 *
1781 2854 * Strips badge spans first (`<span class="update-plugins count-3">`),
1782 2855 * then any remaining markup. An empty result means the entry has no
@@ -1834,13 +2907,45 @@
1834 2907 return file_exists( ABSPATH . 'wp-admin/' . $file );
1835 2908 }
1836 2909
1837 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 +/**
1838 2939 * Converts a menu item slug to a full admin URL.
1839 2940 *
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()`.
2944 + *
1840 2945 * Handles three slug shapes:
1841 2946 * 1. Direct file references (`edit.php`, `upload.php`) — passed
1842 - * through `admin_url()` as-is.
2947 + * through `openstation_menu_admin_url()` as-is.
1843 2948 * 2. Plain plugin page slugs (`my-plugin`) — routed through
1844 2949 * `admin.php?page=<slug>` with the slug `rawurlencode()`d.
1845 2950 * 3. Plugin page slugs that embed extra query parameters
1846 2951 * (`wc-admin&path=/customers`) — split on the first `&`, the
@@ -1901,9 +3006,9 @@
1901 3006 if (
1902 3007 false !== strpos( $slug, '.php' ) &&
1903 3008 ( ! isset( $_parent_pages[ $slug ] ) || openstation_is_admin_file_slug( $slug ) )
1904 3009 ) {
1905 - return esc_url_raw( admin_url( $slug ) );
3010 + return esc_url_raw( openstation_menu_admin_url( $slug ) );
1906 3011 }
1907 3012
1908 3013 // Plugin page slug with embedded query parameters
1909 3014 // (e.g., 'wc-admin&path=/customers'). Split the page slug from
@@ -1943,9 +3048,9 @@
1943 3048 $host = add_query_arg( 'page', $slug, $parent_slug );
1944 3049 }
1945 3050 }
1946 3051
1947 - $url = admin_url( $host );
3052 + $url = openstation_menu_admin_url( $host );
1948 3053 if ( ! empty( $extra_args ) ) {
1949 3054 $url = add_query_arg( $extra_args, $url );
1950 3055 }
1951 3056 return esc_url_raw( $url );