PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.1
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.1
1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 All 34 releases
desktop-mode / includes / registries / native-windows.php

native-windows.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 1.1.1, at includes/registries/native-windows.php

980 lines 35.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OpenStation — Native windows registry.
4 *
5 * The largest of the five components.php registries — owns:
6 *
7 * - `openstation_register_window()` — plugin-author API
8 * - `openstation_native_window_registry()` — internal store
9 * - `openstation_native_window_allowed_html()` — wp_kses
10 * allowlist for `<template>` payloads
11 * - `openstation_build_native_window_template_html()` —
12 * wraps the registered template callback in tabs markup
13 * when the window has multiple registered tabs
14 * - `openstation_enqueue_native_window_scripts()` — enqueue
15 * hook that ships every registered window's script handle
16 * - `openstation_render_native_window_templates()` — renders
17 * the `<template>` elements the shell clones
18 *
19 * Extracted from `components.php` during the architecture-0.8.1
20 * PHP slicing (phase 6). The window-tabs registry that builds on
21 * top of this lives in `includes/registries/window-tabs.php`.
22 *
23 * @package OpenStation
24 */
25
26 defined( 'ABSPATH' ) || exit;
27
28 /**
29 * Register a PHP-owned native desktop window with one call.
30 *
31 * Under the hood this:
32 *
33 * 1. Captures the $args and stores them on a module-level
34 * registry so the relevant admin_footer + enqueue hooks fire
35 * only for the current user's openstation shell.
36 * 2. On `admin_footer` (shell-side only), emits
37 * `<template id="os-native-window-<id>">` wrapping the
38 * output of the `template` callback. Each registered window
39 * gets its own template element.
40 * 3. On `admin_enqueue_scripts` (shell-side), enqueues the
41 * caller's `script` handle if one was provided. The script
42 * registers a render callback at
43 * `window.openStationNativeWindows[<id>]`. On every window open
44 * the shell clones the registered template into the body and
45 * then invokes the callback — render is enhancement: query
46 * the body for mount points your template declared, light
47 * them up. Without a `script` the cloned template IS the
48 * window; declarative-only plugins need zero JS.
49 * 4. Passes a localized config blob to the script
50 * (`openStationNativeWindow_<id>`) carrying the window's
51 * `id`, `title`, `icon`, dimensions, and `placement`. The
52 * script then calls `wp.os.registerSystemTile()` +
53 * `wp.os.registerWindow()` to wire up the dock tile
54 * and the open-on-click behaviour.
55 *
56 * Plugins write the template callback + the render callback on
57 * the JS side; everything else is shell plumbing. Capability gate
58 * honours WP admin conventions: any `capabilities` entries must
59 * ALL match for the window to register.
60 *
61 * Note on scope: the shell doesn't auto-open windows server-side
62 * — `registerWindow` declares availability, not presence. Users
63 * click the registered tile (or your plugin calls
64 * `wp.os.windowManager.open()` programmatically) to surface
65 * the window.
66 *
67 * @param string $id Doubles as window id + dock-tile id. Must
68 * be a kebab-case-ish slug.
69 * @param array $args {
70 * Window registration options.
71 *
72 * @type string $title Window + tooltip title. Required.
73 * @type string $icon Dashicons class or URL. Required.
74 * @type callable $template Echoes the window body markup.
75 * Wrapped on `admin_footer` in a
76 * `<template id="os-native-window-
77 * <id>">`; cloned into the window
78 * body on every open. The render
79 * callback runs against the cloned
80 * body, so mount points declared in
81 * the template are guaranteed to be
82 * present.
83 * @type string $script Registered script handle that
84 * owns the JS render callback.
85 * Optional — omit for a purely
86 * declarative window whose body is
87 * exactly the cloned template.
88 * Loaded the first time the window
89 * opens, not at boot — see
90 * `$preload_script`.
91 * @type string[] $scripts Companion script handles loaded
92 * immediately before `$script`, in
93 * the order given. For a bundle that
94 * extends the window from outside it
95 * — subscribing to the window's own
96 * actions, contributing a section —
97 * and therefore has to be in the tab
98 * before the window's render callback
99 * paints. Declaring it here is what
100 * keeps it off the boot critical
101 * path: it travels with the window
102 * it extends. Default empty.
103 * @type bool $preload_script Load `$script` (and `$scripts`) at
104 * shell boot instead of on first
105 * open. Default false — a window's
106 * bundle is dead weight until the
107 * window opens, and the documented
108 * contract for it is "publish a
109 * render callback on
110 * `window.openStationNativeWindows[
111 * <id> ]`", which the shell reads at
112 * open time. Opt in only when the
113 * bundle ALSO has a boot-time job
114 * that must run whether or not the
115 * user ever opens the window — a
116 * dock badge poller, a public API it
117 * installs on `wp.os`. Prefer
118 * splitting that job into an
119 * always-loaded bundle over paying
120 * the whole window's weight on every
121 * admin page.
122 * @type int $width Initial width (px). Default 520.
123 * @type int $height Initial height (px). Default 400.
124 * @type int $min_width Minimum width (px). Default 280.
125 * @type int $min_height Minimum height (px). Default 220.
126 * @type string $placement 'dock' | 'none'. Default 'dock'.
127 * 'none' skips the tile (plugin
128 * opens the window programmatically).
129 * @type int $dock_order Sort key among system tiles,
130 * ascending; ties keep registration
131 * order. Default 0, which places the
132 * tile ahead of the shell's own
133 * trailing cluster (Mio 10, Overview
134 * 20, System 30, Exit 35, Trash 40).
135 * Needed because registration order
136 * is not something a plugin controls:
137 * tiles land when their lazy script
138 * resolves.
139 * @type bool $placeable Whether the dock tile gets a row in
140 * OpenStation Preferences → Apps &
141 * Plugins, so the user can move it to
142 * the wallpaper or hide it. Defaults
143 * to the dock either way. Default
144 * false, because most tiles are
145 * load-bearing. Opt in for a window
146 * the user can reasonably do without.
147 * Only offer this on a window that
148 * registers no desktop icon: the icon
149 * already owns a row of its own.
150 * @type string[] $capabilities User capabilities that gate the
151 * registration. ANY miss returns
152 * `WP_Error openstation_capability_denied`.
153 * @type bool|string $autofocus Passed verbatim to
154 * `NativeWindowDef.autofocus`.
155 * @type string $main_tab_label Label for the "main" tab that
156 * displays the window's own
157 * `template` output. Only rendered
158 * when at least one additional
159 * tab is registered via
160 * {@see openstation_register_window_tab()}.
161 * Defaults to the window's `title`.
162 * @type int $main_tab_padding Padding (in px) applied to the
163 * auto-generated tab-wrap around
164 * the window body. Only applies
165 * when additional tabs are
166 * registered. Default 16. Pass 0
167 * for edge-to-edge content.
168 * Filterable at runtime via
169 * `openstation_native_window_tab_wrap_padding`.
170 * @type array $config Arbitrary serializable data to ship
171 * to the bundle alongside the script
172 * tag. Read in JS via
173 * `wp.os.getWindowConfig( $id )`
174 * (or directly at
175 * `window.openStationWindowConfig[ $id ]`).
176 * Recommended over `wp_localize_script`
177 * for native-window scripts because
178 * the lazy-load path bypasses
179 * `wp_print_scripts` — passing config
180 * through this arg guarantees delivery
181 * on both eager AND lazy paths
182 * (mid-session activation). Use this
183 * for REST URLs, nonces, capability
184 * flags, anything session-bound. Empty
185 * array (default) ships nothing.
186 * }
187 * @return true|WP_Error `true` on success; `WP_Error` when any
188 * required arg is missing/invalid or a
189 * declared capability is unmet.
190 */
191 function openstation_register_window( $id, $args = array() ) {
192 $id = sanitize_key( (string) $id );
193 if ( '' === $id ) {
194 return openstation_registration_error(
195 'openstation_missing_id',
196 __( 'Native window id is required and must be a valid slug.', 'desktop-mode' )
197 );
198 }
199
200 $defaults = array(
201 'title' => '',
202 'icon' => 'dashicons-admin-generic',
203 'template' => null,
204 'script' => '',
205 'scripts' => array(),
206 'preload_script' => false,
207 // Optional WP style handle (registered with `wp_register_style()`).
208 // Resolved at payload-build time so the shell can lazy-inject a
209 // `<link rel="stylesheet">` when a peer plugin is activated
210 // mid-session — without this, the parent shell page already
211 // finished `wp_print_styles` and the plugin's CSS is missing
212 // until F5.
213 'style' => '',
214 'width' => 520,
215 'height' => 400,
216 'min_width' => 280,
217 'min_height' => 220,
218 'placement' => 'dock',
219 'dock_order' => 0,
220 'placeable' => false,
221 'capabilities' => array(),
222 'autofocus' => false,
223 'main_tab_label' => '',
224 'main_tab_padding' => '',
225 'config' => array(),
226 );
227 $args = wp_parse_args( $args, $defaults );
228
229 // Capability gate — ALL listed caps must match. Fail closed.
230 foreach ( (array) $args['capabilities'] as $cap ) {
231 if ( ! current_user_can( (string) $cap ) ) {
232 return openstation_registration_error(
233 'openstation_capability_denied',
234 sprintf(
235 /* translators: %s: capability slug. */
236 __( 'Current user lacks the %s capability required to register this native window.', 'desktop-mode' ),
237 (string) $cap
238 ),
239 array(
240 'capability' => (string) $cap,
241 'id' => $id,
242 )
243 );
244 }
245 }
246
247 // Required fields.
248 if ( '' === (string) $args['title'] ) {
249 return openstation_registration_error(
250 'openstation_missing_title',
251 __( 'Native window registration requires a non-empty `title`.', 'desktop-mode' ),
252 array( 'id' => $id )
253 );
254 }
255 if ( ! is_callable( $args['template'] ) ) {
256 return openstation_registration_error(
257 'openstation_invalid_template',
258 __( 'Native window registration requires a callable `template` that echoes the template body.', 'desktop-mode' ),
259 array( 'id' => $id )
260 );
261 }
262
263 $placement = in_array( $args['placement'], array( 'dock', 'none' ), true )
264 ? $args['placement']
265 : 'dock';
266
267 $entry = array(
268 'id' => $id,
269 'title' => (string) $args['title'],
270 'icon' => (string) $args['icon'],
271 'template' => $args['template'],
272 'script' => (string) $args['script'],
273 // Companion handles, deduped and stripped of empties so the
274 // payload builder can resolve the list without re-checking.
275 'scripts' => array_values(
276 array_unique(
277 array_filter(
278 array_map( 'strval', (array) $args['scripts'] ),
279 static function ( $handle ) {
280 return '' !== $handle;
281 }
282 )
283 )
284 ),
285 'preload_script' => (bool) $args['preload_script'],
286 'style' => (string) $args['style'],
287 'width' => (int) $args['width'],
288 'height' => (int) $args['height'],
289 'min_width' => (int) $args['min_width'],
290 'min_height' => (int) $args['min_height'],
291 'placement' => $placement,
292 // Sort key among system tiles, ascending. `0` (the default)
293 // puts a plugin's tile ahead of the shell's own trailing
294 // cluster — Mio 10, Overview 20, System 30 — which is where a
295 // launcher belongs. Trash uses 40 to sit at the very end.
296 'dock_order' => (int) $args['dock_order'],
297 'placeable' => (bool) $args['placeable'],
298 'autofocus' => $args['autofocus'],
299 'main_tab_label' => (string) $args['main_tab_label'],
300 // Stored as-is (string or int). `openstation_build_native_window_template_html`
301 // coerces to int and falls back to 16 when absent.
302 'main_tab_padding' => $args['main_tab_padding'],
303 // Bundle-bound config delivered through the same path as
304 // `wp_localize_script` `extra['data']` — see the `config` doc
305 // in this function's `$args` block and `openstation_resolve_script_payload()`
306 // for how it lands on the wire.
307 'config' => is_array( $args['config'] ) ? $args['config'] : array(),
308 );
309 openstation_native_window_registry( $id, $entry );
310
311 /**
312 * Fires after a native desktop window is successfully registered.
313 *
314 * Lets plugins react to registrations made by other plugins —
315 * e.g. a widget that auto-opens when a given window registers,
316 * or analytics tracking of which windows the current install
317 * exposes. Does NOT fire when `openstation_register_window()`
318 * returns a `WP_Error`.
319 *
320 * @param string $id The window id.
321 * @param array $entry The stored registry entry (id, title,
322 * icon, template callback, script handle,
323 * size defaults, placement, autofocus).
324 */
325 do_action( 'openstation_native_window_registered', $id, $entry );
326
327 return true;
328 }
329
330 /**
331 * Internal module-level registry for native windows registered
332 * via {@see openstation_register_window()}. Passing a second
333 * argument stores the entry; passing only the id returns the
334 * stored value (or null). Kept small and side-effect-free so
335 * tests can introspect.
336 *
337 * @internal
338 *
339 * @param string $id Window id.
340 * @param array|null $entry Entry to store, or null to just read.
341 * @return array|null Either the stored entry or the full registry
342 * (when id is empty).
343 */
344 function openstation_native_window_registry( $id = '', $entry = null ) {
345 static $store = array();
346
347 if ( '' === (string) $id ) {
348 return $store;
349 }
350 if ( null !== $entry ) {
351 $store[ $id ] = $entry;
352 }
353 return isset( $store[ $id ] ) ? $store[ $id ] : null;
354 }
355
356
357 /**
358 * Returns the `wp_kses`-shaped allowlist used to escape native-window
359 * `<template>` payloads (and the recycle-bin template) before they're
360 * emitted into the page.
361 *
362 * Templates are inert until JS clones them out of the `<template>`
363 * tag — but Plugin Check still requires escape-on-output. The list
364 * extends `wp_kses_allowed_html( 'post' )` with form controls,
365 * `<os-*>` web components, and dashicon spans, plus permissive
366 * `data-*`, `aria-*`, and component-specific attributes. Plugins
367 * registering their own native windows can extend the list via the
368 * `openstation_native_window_allowed_html` filter below.
369 *
370 * @return array<string,array<string,bool>>
371 */
372 function openstation_native_window_allowed_html() {
373 $base = wp_kses_allowed_html( 'post' );
374
375 $global_attrs = array(
376 'id' => true,
377 'class' => true,
378 'style' => true,
379 'title' => true,
380 'role' => true,
381 'tabindex' => true,
382 'hidden' => true,
383 'slot' => true,
384 'part' => true,
385 'lang' => true,
386 'dir' => true,
387 'draggable' => true,
388 'contenteditable' => true,
389 'data-*' => true,
390 'aria-*' => true,
391 // `full-width` is a layout-level flag honoured by
392 // `<os-form>` (and any future os-* container that opts in
393 // to row-spanning slotted children). Lives in the global
394 // allowlist so a plain `<div full-width>` wrapper isn't
395 // stripped by kses on its way through the template.
396 'full-width' => true,
397 );
398
399 $form_attrs = array_merge(
400 $global_attrs,
401 array(
402 'name' => true,
403 'value' => true,
404 'placeholder' => true,
405 'required' => true,
406 'disabled' => true,
407 'readonly' => true,
408 'checked' => true,
409 'selected' => true,
410 'min' => true,
411 'max' => true,
412 'step' => true,
413 'minlength' => true,
414 'maxlength' => true,
415 'pattern' => true,
416 'autocomplete' => true,
417 'autofocus' => true,
418 'multiple' => true,
419 'rows' => true,
420 'cols' => true,
421 'wrap' => true,
422 'size' => true,
423 'for' => true,
424 'form' => true,
425 'type' => true,
426 'accept' => true,
427 'list' => true,
428 'src' => true,
429 'href' => true,
430 'target' => true,
431 'rel' => true,
432 'open' => true,
433 'variant' => true,
434 )
435 );
436
437 $wpd_attrs = array_merge(
438 $form_attrs,
439 array(
440 'gap' => true,
441 'padding' => true,
442 'align' => true,
443 'justify' => true,
444 'direction' => true,
445 'wrap' => true,
446 'inset' => true,
447 'icon' => true,
448 'tone' => true,
449 'size' => true,
450 'shape' => true,
451 'badge' => true,
452 'selectable' => true,
453 'sticky-header' => true,
454 'sticky-columns' => true,
455 'hover' => true,
456 'striped' => true,
457 'bordered' => true,
458 'compact' => true,
459 'loading' => true,
460 'loading-rows' => true,
461 'columns' => true,
462 'rows' => true,
463 'sortable' => true,
464 'expandable' => true,
465 'preset' => true,
466 'label' => true,
467 'description' => true,
468 'orientation' => true,
469 'level' => true,
470 'collapsed' => true,
471 // `<os-form>` props + the `full-width` row span flag
472 // honoured by the form's slotted-child layout rule.
473 'submit-label' => true,
474 'reset-label' => true,
475 'busy' => true,
476 'error' => true,
477 'min-column' => true,
478 'show-reset' => true,
479 'reveal' => true,
480 'full-width' => true,
481 )
482 );
483
484 // Built-in HTML elements the templates rely on.
485 $extra = array(
486 'form' => $form_attrs,
487 'fieldset' => $form_attrs,
488 'legend' => $global_attrs,
489 'label' => $form_attrs,
490 'input' => $form_attrs,
491 'select' => $form_attrs,
492 'option' => $form_attrs,
493 'optgroup' => $form_attrs,
494 'textarea' => $form_attrs,
495 'button' => $form_attrs,
496 'output' => $form_attrs,
497 'datalist' => $global_attrs,
498 'progress' => $form_attrs,
499 'meter' => $form_attrs,
500 'details' => $global_attrs,
501 'summary' => $global_attrs,
502 'dialog' => $global_attrs,
503 'header' => $global_attrs,
504 'footer' => $global_attrs,
505 'main' => $global_attrs,
506 'nav' => $global_attrs,
507 'section' => $global_attrs,
508 'article' => $global_attrs,
509 'aside' => $global_attrs,
510 'figure' => $global_attrs,
511 'figcaption' => $global_attrs,
512 'time' => array_merge( $global_attrs, array( 'datetime' => true ) ),
513 'mark' => $global_attrs,
514 'small' => $global_attrs,
515 'svg' => array_merge(
516 $global_attrs,
517 array(
518 'viewbox' => true,
519 'width' => true,
520 'height' => true,
521 'fill' => true,
522 'stroke' => true,
523 'xmlns' => true,
524 )
525 ),
526 'path' => array(
527 'd' => true,
528 'fill' => true,
529 'stroke' => true,
530 'stroke-width' => true,
531 'stroke-linecap' => true,
532 'stroke-linejoin' => true,
533 'class' => true,
534 ),
535 'g' => array(
536 'class' => true,
537 'transform' => true,
538 'fill' => true,
539 ),
540 'circle' => array(
541 'cx' => true,
542 'cy' => true,
543 'r' => true,
544 'fill' => true,
545 'stroke' => true,
546 'class' => true,
547 ),
548 'rect' => array(
549 'x' => true,
550 'y' => true,
551 'width' => true,
552 'height' => true,
553 'rx' => true,
554 'ry' => true,
555 'fill' => true,
556 'stroke' => true,
557 'class' => true,
558 ),
559 'line' => array(
560 'x1' => true,
561 'y1' => true,
562 'x2' => true,
563 'y2' => true,
564 'stroke' => true,
565 'stroke-width' => true,
566 'class' => true,
567 ),
568 'polyline' => array(
569 'points' => true,
570 'fill' => true,
571 'stroke' => true,
572 'class' => true,
573 ),
574 'polygon' => array(
575 'points' => true,
576 'fill' => true,
577 'stroke' => true,
578 'class' => true,
579 ),
580 'use' => array(
581 'href' => true,
582 'class' => true,
583 ),
584 );
585
586 // `<os-*>` web components — every shipped tag plus a permissive
587 // open door for new ones added by plugin templates.
588 $wpd_tags = array(
589 'os-stack',
590 'os-cluster',
591 'os-grid',
592 'os-spacer',
593 'os-divider',
594 'os-tabs',
595 'os-tab',
596 'os-tabpanel',
597 'os-segmented',
598 'os-segment',
599 'os-button',
600 'os-icon-button',
601 'os-button-group',
602 'os-text-field',
603 'os-textarea',
604 'os-search-field',
605 'os-select',
606 'os-option',
607 'os-checkbox',
608 'os-checkbox-label',
609 'os-radio',
610 'os-radio-group',
611 'os-form',
612 'os-switch',
613 'os-slider',
614 'os-table',
615 'os-table-column',
616 'os-table-row',
617 'os-table-cell',
618 'os-card',
619 'os-list',
620 'os-list-item',
621 'os-badge',
622 'os-pill',
623 'os-tag',
624 'os-chip',
625 'os-spinner',
626 'os-skeleton',
627 'os-empty-state',
628 'os-tooltip',
629 'os-popover',
630 'os-menu',
631 'os-menu-item',
632 'os-modal',
633 'os-drawer',
634 'os-toast',
635 'os-icon',
636 'os-avatar',
637 'os-heading',
638 'os-text',
639 'os-link',
640 'os-banner',
641 'os-alert',
642 'os-callout',
643 'os-form-row',
644 'os-form-section',
645 'os-help-text',
646 'os-toolbar',
647 'os-toolbar-group',
648 );
649 foreach ( $wpd_tags as $tag ) {
650 $extra[ $tag ] = $wpd_attrs;
651 }
652
653 $allowed = array_merge( $base, $extra );
654
655 // Promote the framework's global attrs (`slot`, `part`,
656 // `full-width`, `data-*`, `aria-*`, …) to EVERY allowed tag —
657 // otherwise plain wrappers like `<div slot="header">` lose
658 // their `slot` attribute on the way through kses and get
659 // projected into the default slot instead of the named one.
660 // Caught by inspection when the Add User form's header
661 // rendered as a fields-grid cell instead of a banner above
662 // the fields. `array_merge( + )` with a kses-true value
663 // (boolean `true`) is harmless for tags whose entries are
664 // just `true` rather than an attrs map — array_merge skips
665 // non-array values.
666 foreach ( $allowed as $tag => $attrs ) {
667 if ( is_array( $attrs ) ) {
668 $allowed[ $tag ] = array_merge( $attrs, $global_attrs );
669 }
670 }
671
672 /**
673 * Filters the kses allowlist used when escaping native-window
674 * `<template>` payloads.
675 *
676 * Plugins registering their own native windows can extend the
677 * list with custom tags or attributes if their templates need
678 * markup not covered here.
679 *
680 * @param array $allowed wp_kses-shaped allowlist.
681 */
682 return (array) apply_filters( 'openstation_native_window_allowed_html', $allowed );
683 }
684
685 /**
686 * Run `wp_kses` on a native-window template body with the framework
687 * allowlist, **auto-extending the allowlist with every `<os-*>` tag
688 * the template actually uses.**
689 *
690 * The pain this fixes: each shipped `<os-*>` component had to be
691 * manually added to the `$wpd_tags` list above, and the failure mode
692 * of forgetting it was silent — kses would strip the tag, the
693 * template would render as a sea of unparented children, and you'd
694 * spend an afternoon working out why "the form has no buttons."
695 *
696 * Plugin authors registering a new component now only need to
697 * `defineComponent('os-foo', OsFoo)` on the JS side and use
698 * `<os-foo>` in their template — this helper finds the tag at
699 * render time, tags it onto the allowlist with the standard
700 * permissive attrs, and runs kses with the extended list.
701 *
702 * Every callsite in the framework that previously did the
703 * `wp_kses( $html, openstation_native_window_allowed_html() )`
704 * dance can call this instead and get tag-discovery for free.
705 *
706 * @param string $html Template HTML to sanitize.
707 * @return string Sanitized HTML.
708 */
709 function openstation_kses_native_window_template( $html ) {
710 $allowed = openstation_native_window_allowed_html();
711
712 if ( preg_match_all( '/<(os-[a-z][a-z0-9-]*)\b/i', (string) $html, $matches ) ) {
713 $unique = array_unique( array_map( 'strtolower', $matches[1] ) );
714 $wpd_attrs = isset( $allowed['os-button'] )
715 ? $allowed['os-button']
716 : array();
717 foreach ( $unique as $tag ) {
718 if ( ! isset( $allowed[ $tag ] ) ) {
719 $allowed[ $tag ] = $wpd_attrs;
720 }
721 }
722 }
723
724 return wp_kses( (string) $html, $allowed );
725 }
726
727 /**
728 * Render a native window's template HTML to a string, wrapping
729 * with tabs when the window has at least one additional tab
730 * registered. Shared by `openstation_render_native_window_templates()`
731 * (which emits the live `<template>` element) and
732 * `openstation_build_native_windows_payload()` (which captures the same
733 * string for the shell config so mid-session activation can inject
734 * the template without a reload).
735 *
736 * Single-tab windows (no additional tabs registered) render the
737 * same flat body they always did — backwards-compatible with
738 * every existing caller.
739 *
740 * @param array $entry Window registry entry.
741 * @return string Template body HTML (no outer `<template>` tag).
742 */
743 function openstation_build_native_window_template_html( $entry ) {
744 if ( ! is_array( $entry ) || ! is_callable( $entry['template'] ) ) {
745 return '';
746 }
747
748 $tabs = openstation_get_native_window_tabs( $entry['id'] );
749 $has_extras = count( $tabs ) > 1;
750
751 // Fast path — single-pane window, no wrapping.
752 if ( ! $has_extras ) {
753 ob_start();
754 call_user_func( $entry['template'] );
755 return (string) ob_get_clean();
756 }
757
758 // Multi-tab window — wrap in <os-stack> + one <os-tabpanel> per
759 // tab. The default active tab is the main one (the window's own
760 // template).
761 //
762 // The tab STRIP is deliberately absent from this markup. It is
763 // built by the shell in the window chrome, under the title bar,
764 // from the same tab metadata this function walks (the payload
765 // carries it as `tabs`). One tab strip per window, in one place,
766 // whether the window is an admin page in an iframe or a native
767 // window like this one.
768 //
769 // Plugin authors declare tab-change side effects by listening for
770 // `os-window-tab-change` on the window element; see
771 // docs/migration-window-tabs.md.
772 //
773 // The wrap's padding is plugin-controllable two ways:
774 // 1. `main_tab_padding` arg on `openstation_register_window` —
775 // a per-window override. `0` opts into edge-to-edge
776 // content.
777 // 2. `openstation_native_window_tab_wrap_padding` filter for
778 // late-bound overrides (e.g. a theme that wants every
779 // tabbed window to adopt a narrower inset).
780 // Default stays 16px so existing plugins don't shift.
781 $default_padding = isset( $entry['main_tab_padding'] )
782 && '' !== (string) $entry['main_tab_padding']
783 ? (int) $entry['main_tab_padding']
784 : 16;
785 /**
786 * Filters the padding (in px) applied to the auto-generated
787 * tab wrap around a native window's template body. The shell
788 * emits the wrap as `<os-stack padding="N">`; the CSS-as-
789 * attribute pipeline at the client translates that to
790 * `style.padding`.
791 *
792 * Return `0` for edge-to-edge content. Negative values are
793 * clamped to 0.
794 *
795 * @param int $padding Default padding in px.
796 * @param string $window_id The native window id.
797 */
798 $padding = (int) apply_filters(
799 'openstation_native_window_tab_wrap_padding',
800 $default_padding,
801 (string) $entry['id']
802 );
803 if ( $padding < 0 ) {
804 $padding = 0;
805 }
806
807 $buffer = sprintf(
808 '<os-stack gap="12" padding="%d">',
809 $padding
810 );
811
812 // Stamp `hidden` on every non-active panel directly in the
813 // emitted HTML. The shell takes over panel visibility as soon as
814 // it declares the strip, but that happens after the template is
815 // in the body — setting the attribute server-side makes first
816 // paint correct rather than flashing every pane at once.
817 foreach ( $tabs as $tab ) {
818 if ( ! is_callable( $tab['template'] ) ) {
819 continue;
820 }
821 $is_active = OPENSTATION_NATIVE_WINDOW_MAIN_TAB === $tab['value'];
822 $buffer .= sprintf(
823 '<os-tabpanel for="%s"%s>',
824 esc_attr( $tab['value'] ),
825 $is_active ? '' : ' hidden'
826 );
827 ob_start();
828 call_user_func( $tab['template'] );
829 $buffer .= (string) ob_get_clean();
830 $buffer .= '</os-tabpanel>';
831 }
832
833 $buffer .= '</os-stack>';
834 return $buffer;
835 }
836
837 /**
838 * Attach every registered native window's script data, and enqueue
839 * the handful of bundles that asked to load at boot.
840 *
841 * **A native window's bundle is not enqueued here.** It loads the
842 * first time the window opens: the shell reads the render callback
843 * off `window.openStationNativeWindows[ <id> ]` at open time, so a
844 * bundle printed at boot is weight on every admin page the window is
845 * never opened from — and between WP Explorer, Posts, Plugins,
846 * Comments, the Recycle Bin, Content Graph, Games and the agent
847 * runner that came to well over a megabyte before a single window
848 * had been clicked. `preload_script` is the opt-out for a bundle
849 * with a genuine boot-time job.
850 *
851 * What still happens for EVERY window is the data attach: the
852 * localize blob and the `config` inline. Those hang off the
853 * REGISTERED handle whether or not it is enqueued, which is exactly
854 * how the lazy path gets them — `openstation_resolve_script_payload()`
855 * harvests both into the payload for the shell to replay around the
856 * script tag it injects. Hence priority 5: `openstation_enqueue_assets()`
857 * builds that payload at 10, and data attached after it would ship a
858 * bundle with no config.
859 */
860 function openstation_enqueue_native_window_scripts() {
861 if ( ! openstation_is_enabled() || openstation_is_chromeless_request() || openstation_is_classic_request() ) {
862 return;
863 }
864 $registry = openstation_native_window_registry();
865 if ( ! is_array( $registry ) ) {
866 return;
867 }
868 foreach ( $registry as $entry ) {
869 $preload = ! empty( $entry['preload_script'] );
870
871 // Per-tab scripts stay eager. The shell has no lazy path for
872 // them — a tab's script is not part of the window's own
873 // bundle chain — so deferring here would simply break the
874 // tab. The main tab uses the window's own `script`.
875 $tabs = openstation_get_native_window_tabs( $entry['id'] );
876 foreach ( $tabs as $tab ) {
877 if ( $tab['is_main'] || empty( $tab['script'] ) ) {
878 continue;
879 }
880 wp_enqueue_script( $tab['script'] );
881 }
882
883 if ( empty( $entry['script'] ) ) {
884 continue;
885 }
886 if ( $preload ) {
887 wp_enqueue_script( $entry['script'] );
888 foreach ( (array) $entry['scripts'] as $companion ) {
889 wp_enqueue_script( $companion );
890 }
891 }
892 // Localize the config the JS side reads to register itself.
893 wp_localize_script(
894 $entry['script'],
895 'openStationNativeWindow_' . str_replace( '-', '_', $entry['id'] ),
896 array(
897 'id' => $entry['id'],
898 'title' => $entry['title'],
899 'icon' => $entry['icon'],
900 'width' => $entry['width'],
901 'height' => $entry['height'],
902 'minWidth' => $entry['min_width'],
903 'minHeight' => $entry['min_height'],
904 'placement' => $entry['placement'],
905 'autofocus' => $entry['autofocus'],
906 'templateId' => 'os-native-window-' . $entry['id'],
907 'tabs' => array_map(
908 static function ( $tab ) {
909 return array(
910 'value' => $tab['value'],
911 'label' => $tab['label'],
912 'isMain' => $tab['is_main'],
913 );
914 },
915 $tabs
916 ),
917 )
918 );
919
920 // Bundle-bound `config`, for the eager print path only.
921 // `openstation_build_native_windows_payload()` synthesizes the
922 // same assignment into the payload's `scriptL10n`, which is
923 // what delivers it on the lazy path — and it has to, because
924 // that payload is also built inside chromeless iframes, where
925 // this function returns early. Attaching here unconditionally
926 // would mean a shell page shipped the identical assignment
927 // twice: once as `before`, once as `l10n`. The bundle reads it
928 // via `wp.os.getWindowConfig( id )` or directly at
929 // `window.openStationWindowConfig[ id ]`.
930 if ( $preload && ! empty( $entry['config'] ) && is_array( $entry['config'] ) ) {
931 wp_add_inline_script(
932 $entry['script'],
933 sprintf(
934 'window.openStationWindowConfig=window.openStationWindowConfig||{};window.openStationWindowConfig[%s]=%s;',
935 wp_json_encode( $entry['id'] ),
936 wp_json_encode( $entry['config'] )
937 ),
938 'before'
939 );
940 }
941 }
942 }
943 add_action( 'admin_enqueue_scripts', 'openstation_enqueue_native_window_scripts', 5 );
944
945 /**
946 * Emit a `<template>` tag for every registered native window on
947 * `admin_footer` when the shell is active. The JS side resolves
948 * these via `document.getElementById( `os-native-window-${id}` )`
949 * and clones them into each opened window's body.
950 */
951 function openstation_render_native_window_templates() {
952 if ( ! openstation_is_enabled() || openstation_is_chromeless_request() || openstation_is_classic_request() ) {
953 return;
954 }
955 $registry = openstation_native_window_registry();
956 if ( ! is_array( $registry ) ) {
957 return;
958 }
959 foreach ( $registry as $entry ) {
960 if ( ! is_callable( $entry['template'] ) ) {
961 continue;
962 }
963 $html = openstation_build_native_window_template_html( $entry );
964 if ( '' === $html ) {
965 continue;
966 }
967 printf(
968 '<template id="os-native-window-%s">',
969 esc_attr( $entry['id'] )
970 );
971 // `openstation_kses_native_window_template()` auto-extends
972 // the allowlist with any `<os-*>` tag the template carries
973 // — so plugin authors never have to remember to register
974 // their custom component tags in the kses list.
975 echo openstation_kses_native_window_template( $html ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- helper kses-escapes.
976 echo '</template>';
977 }
978 }
979 add_action( 'admin_footer', 'openstation_render_native_window_templates', 20 );
980