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

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

1,133 lines 42.5 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 string[] $styles Companion style handles injected on
104 * the window's first open, after the
105 * window's own `$style`, in the order
106 * given — so at equal specificity a
107 * companion's overrides win, the same
108 * source-order contract an enqueue
109 * dependency gives. The styles-side
110 * mirror of `$scripts`: a stylesheet
111 * that only paints surfaces inside
112 * this window is dead weight on every
113 * document that never shows it —
114 * declared here it costs nothing at
115 * boot and never reaches chromeless
116 * iframes at all. Unlike `$style`
117 * (injected when the window registers,
118 * so mid-session activations paint),
119 * companions wait for the first open;
120 * the deferral is the point. Default
121 * empty.
122 * @type bool $preload_script Load `$script` (and `$scripts`) at
123 * shell boot instead of on first
124 * open. Default false — a window's
125 * bundle is dead weight until the
126 * window opens, and the documented
127 * contract for it is "publish a
128 * render callback on
129 * `window.openStationNativeWindows[
130 * <id> ]`", which the shell reads at
131 * open time. Opt in only when the
132 * bundle ALSO has a boot-time job
133 * that must run whether or not the
134 * user ever opens the window — a
135 * dock badge poller, a public API it
136 * installs on `wp.os`. Prefer
137 * splitting that job into an
138 * always-loaded bundle over paying
139 * the whole window's weight on every
140 * admin page.
141 * @type int $width Initial width (px). Default 520.
142 * @type int $height Initial height (px). Default 400.
143 * @type int $min_width Minimum width (px). Default 280.
144 * @type int $min_height Minimum height (px). Default 220.
145 * @type string $placement 'dock' | 'none'. Default 'dock'.
146 * 'none' skips the tile (plugin
147 * opens the window programmatically).
148 * A PROPOSED default only: the user's
149 * OpenStation Preferences → Navigation
150 * pick wins, and so does a right-click
151 * "Keep in dock".
152 * @type string $admin 'site' | 'network' | 'any'. Default
153 * 'site': offered on every site's
154 * shell and never on the network
155 * admin's, which is right for a window
156 * that reads the current site's REST
157 * API. 'network' is the network
158 * admin's shell only; 'any' is both.
159 * @type string $nav_kind 'app' | 'control'. Default 'app'.
160 * What the window IS, which decides
161 * where its launcher defaults to (apps
162 * to the desktop, controls to the
163 * dock) and which dock zone it sits
164 * in. Plugins want 'app'; 'control'
165 * is for OpenStation's own
166 * affordances.
167 * @type int $dock_order Sort key among system tiles,
168 * ascending; ties keep registration
169 * order. Default 0, which places the
170 * tile ahead of the shell's own
171 * trailing cluster (Mio 10, Overview
172 * 20, System 30, Exit 35, Trash 40).
173 * Needed because registration order
174 * is not something a plugin controls:
175 * tiles land when their lazy script
176 * resolves.
177 * @type bool $placeable Whether the dock tile gets a row in
178 * OpenStation Preferences → Apps &
179 * Plugins, so the user can move it to
180 * the wallpaper or hide it. Defaults
181 * to the dock either way. Default
182 * false, because most tiles are
183 * load-bearing. Opt in for a window
184 * the user can reasonably do without.
185 * Only offer this on a window that
186 * registers no desktop icon: the icon
187 * already owns a row of its own.
188 * @type string[] $capabilities User capabilities that gate the
189 * registration. ANY miss returns
190 * `WP_Error openstation_capability_denied`.
191 * @type bool|string $autofocus Passed verbatim to
192 * `NativeWindowDef.autofocus`.
193 * @type string $main_tab_label Label for the "main" tab that
194 * displays the window's own
195 * `template` output. Only rendered
196 * when at least one additional
197 * tab is registered via
198 * {@see openstation_register_window_tab()}.
199 * Defaults to the window's `title`.
200 * @type int $main_tab_padding Padding (in px) applied to the
201 * auto-generated tab-wrap around
202 * the window body. Only applies
203 * when additional tabs are
204 * registered. Default 16. Pass 0
205 * for edge-to-edge content.
206 * Filterable at runtime via
207 * `openstation_native_window_tab_wrap_padding`.
208 * @type array $config Arbitrary serializable data to ship
209 * to the bundle alongside the script
210 * tag. Read in JS via
211 * `wp.os.getWindowConfig( $id )`
212 * (or directly at
213 * `window.openStationWindowConfig[ $id ]`).
214 * Recommended over `wp_localize_script`
215 * for native-window scripts because
216 * the lazy-load path bypasses
217 * `wp_print_scripts` — passing config
218 * through this arg guarantees delivery
219 * on both eager AND lazy paths
220 * (mid-session activation). Use this
221 * for REST URLs, nonces, capability
222 * flags, anything session-bound. Empty
223 * array (default) ships nothing.
224 * }
225 * @return true|WP_Error `true` on success; `WP_Error` when any
226 * required arg is missing/invalid or a
227 * declared capability is unmet.
228 */
229 function openstation_register_window( $id, $args = array() ) {
230 $id = sanitize_key( (string) $id );
231 if ( '' === $id ) {
232 return openstation_registration_error(
233 'openstation_missing_id',
234 __( 'Native window id is required and must be a valid slug.', 'desktop-mode' )
235 );
236 }
237
238 $defaults = array(
239 'title' => '',
240 'icon' => 'dashicons-admin-generic',
241 'template' => null,
242 'script' => '',
243 'scripts' => array(),
244 'styles' => array(),
245 'preload_script' => false,
246 // Optional WP style handle (registered with `wp_register_style()`).
247 // Resolved at payload-build time so the shell can lazy-inject a
248 // `<link rel="stylesheet">` when a peer plugin is activated
249 // mid-session — without this, the parent shell page already
250 // finished `wp_print_styles` and the plugin's CSS is missing
251 // until F5.
252 'style' => '',
253 'width' => 520,
254 'height' => 400,
255 'min_width' => 280,
256 'min_height' => 220,
257 'placement' => 'dock',
258 'admin' => 'site',
259 'nav_kind' => 'app',
260 'dock_order' => 0,
261 'placeable' => false,
262 'capabilities' => array(),
263 'autofocus' => false,
264 'main_tab_label' => '',
265 'main_tab_padding' => '',
266 // Admin pages this window answers for, `array( id, page )` per
267 // entry. See `App::menu()` and `openstation_apps_menu_pages()`.
268 'menu_pages' => array(),
269 'config' => array(),
270 );
271 $args = wp_parse_args( $args, $defaults );
272 if ( ! in_array( $args['admin'], array( 'site', 'network', 'any' ), true ) ) {
273 $args['admin'] = 'site';
274 }
275
276 // Capability gate — ALL listed caps must match. Fail closed.
277 foreach ( (array) $args['capabilities'] as $cap ) {
278 if ( ! current_user_can( (string) $cap ) ) {
279 return openstation_registration_error(
280 'openstation_capability_denied',
281 sprintf(
282 /* translators: %s: capability slug. */
283 __( 'Current user lacks the %s capability required to register this native window.', 'desktop-mode' ),
284 (string) $cap
285 ),
286 array(
287 'capability' => (string) $cap,
288 'id' => $id,
289 )
290 );
291 }
292 }
293
294 // Required fields.
295 if ( '' === (string) $args['title'] ) {
296 return openstation_registration_error(
297 'openstation_missing_title',
298 __( 'Native window registration requires a non-empty `title`.', 'desktop-mode' ),
299 array( 'id' => $id )
300 );
301 }
302 if ( ! is_callable( $args['template'] ) ) {
303 return openstation_registration_error(
304 'openstation_invalid_template',
305 __( 'Native window registration requires a callable `template` that echoes the template body.', 'desktop-mode' ),
306 array( 'id' => $id )
307 );
308 }
309
310 $placement = in_array( $args['placement'], array( 'dock', 'none' ), true )
311 ? $args['placement']
312 : 'dock';
313
314 // What the window IS, which is what decides where its launcher
315 // goes by default and which dock zone it sits in. `'app'` for an
316 // installed app (the default, and what every plugin wants);
317 // `'control'` for an OpenStation affordance — the Trash is the
318 // only shipped one.
319 $nav_kind = in_array( $args['nav_kind'], array( 'app', 'control' ), true )
320 ? $args['nav_kind']
321 : 'app';
322
323 $entry = array(
324 'id' => $id,
325 'title' => (string) $args['title'],
326 'icon' => (string) $args['icon'],
327 'template' => $args['template'],
328 'script' => (string) $args['script'],
329 // Companion handles, deduped and stripped of empties so the
330 // payload builder can resolve the list without re-checking.
331 'scripts' => array_values(
332 array_unique(
333 array_filter(
334 array_map( 'strval', (array) $args['scripts'] ),
335 static function ( $handle ) {
336 return '' !== $handle;
337 }
338 )
339 )
340 ),
341 // Companion style handles, same dedupe/strip as `scripts`.
342 'styles' => array_values(
343 array_unique(
344 array_filter(
345 array_map( 'strval', (array) $args['styles'] ),
346 static function ( $handle ) {
347 return '' !== $handle;
348 }
349 )
350 )
351 ),
352 'preload_script' => (bool) $args['preload_script'],
353 'style' => (string) $args['style'],
354 'width' => (int) $args['width'],
355 'height' => (int) $args['height'],
356 'min_width' => (int) $args['min_width'],
357 'min_height' => (int) $args['min_height'],
358 'placement' => $placement,
359 'nav_kind' => $nav_kind,
360 // Which admin's shell offers it; see the `admin` arg.
361 'admin' => $args['admin'],
362 // Sort key among system tiles, ascending. `0` (the default)
363 // puts a plugin's tile ahead of the shell's own trailing
364 // cluster — Mio 10, Overview 20, System 30 — which is where a
365 // launcher belongs. Trash uses 40 to sit at the very end.
366 'dock_order' => (int) $args['dock_order'],
367 'placeable' => (bool) $args['placeable'],
368 'autofocus' => $args['autofocus'],
369 'main_tab_label' => (string) $args['main_tab_label'],
370 // Stored as-is (string or int). `openstation_build_native_window_template_html`
371 // coerces to int and falls back to 16 when absent.
372 'main_tab_padding' => $args['main_tab_padding'],
373 // Bundle-bound config delivered through the same path as
374 // `wp_localize_script` `extra['data']` — see the `config` doc
375 // in this function's `$args` block and `openstation_resolve_script_payload()`
376 // for how it lands on the wire.
377 // Admin pages this window answers for, `array( id, page )` per
378 // entry — dropped here once, which is why the shell saw an
379 // empty list and claimed none of them.
380 'menu_pages' => is_array( $args['menu_pages'] ) ? array_values( $args['menu_pages'] ) : array(),
381 'config' => is_array( $args['config'] ) ? $args['config'] : array(),
382 );
383 openstation_native_window_registry( $id, $entry );
384
385 /**
386 * Fires after a native desktop window is successfully registered.
387 *
388 * Lets plugins react to registrations made by other plugins —
389 * e.g. a widget that auto-opens when a given window registers,
390 * or analytics tracking of which windows the current install
391 * exposes. Does NOT fire when `openstation_register_window()`
392 * returns a `WP_Error`.
393 *
394 * @param string $id The window id.
395 * @param array $entry The stored registry entry (id, title,
396 * icon, template callback, script handle,
397 * size defaults, placement, autofocus).
398 */
399 do_action( 'openstation_native_window_registered', $id, $entry );
400
401 return true;
402 }
403
404 /**
405 * Internal module-level registry for native windows registered
406 * via {@see openstation_register_window()}. Passing a second
407 * argument stores the entry; passing only the id returns the
408 * stored value (or null). Kept small and side-effect-free so
409 * tests can introspect.
410 *
411 * @internal
412 *
413 * @param string $id Window id.
414 * @param array|null $entry Entry to store, or null to just read.
415 * @return array|null Either the stored entry or the full registry
416 * (when id is empty).
417 */
418 function openstation_native_window_registry( $id = '', $entry = null ) {
419 static $store = array();
420
421 if ( '' === (string) $id ) {
422 return $store;
423 }
424 if ( null !== $entry ) {
425 $store[ $id ] = $entry;
426 }
427 return isset( $store[ $id ] ) ? $store[ $id ] : null;
428 }
429
430
431 /**
432 * Returns the `wp_kses`-shaped allowlist used to escape native-window
433 * `<template>` payloads (and the recycle-bin template) before they're
434 * emitted into the page.
435 *
436 * Templates are inert until JS clones them out of the `<template>`
437 * tag — but Plugin Check still requires escape-on-output. The list
438 * extends `wp_kses_allowed_html( 'post' )` with form controls,
439 * `<os-*>` web components, and dashicon spans, plus permissive
440 * `data-*`, common ARIA, and component-specific attributes. Plugins
441 * registering their own native windows can extend the list via the
442 * `openstation_native_window_allowed_html` filter below.
443 *
444 * @return array<string,array<string,bool>>
445 */
446 function openstation_native_window_allowed_html() {
447 $base = wp_kses_allowed_html( 'post' );
448
449 $global_attrs = array(
450 'id' => true,
451 'class' => true,
452 'style' => true,
453 'title' => true,
454 'role' => true,
455 'tabindex' => true,
456 'hidden' => true,
457 'slot' => true,
458 'part' => true,
459 'lang' => true,
460 'dir' => true,
461 'draggable' => true,
462 'contenteditable' => true,
463 'data-*' => true,
464 // `wp_kses` only treats the `data-*` wildcard specially. ARIA
465 // attributes must be admitted by their exact names or they are
466 // silently stripped from native-window templates.
467 'aria-label' => true,
468 'aria-labelledby' => true,
469 'aria-current' => true,
470 'aria-hidden' => true,
471 // `full-width` is a layout-level flag honoured by
472 // `<os-form>` (and any future os-* container that opts in
473 // to row-spanning slotted children). Lives in the global
474 // allowlist so a plain `<div full-width>` wrapper isn't
475 // stripped by kses on its way through the template.
476 'full-width' => true,
477 );
478
479 $form_attrs = array_merge(
480 $global_attrs,
481 array(
482 'name' => true,
483 'value' => true,
484 'placeholder' => true,
485 'required' => true,
486 'disabled' => true,
487 'readonly' => true,
488 'checked' => true,
489 'selected' => true,
490 'min' => true,
491 'max' => true,
492 'step' => true,
493 'minlength' => true,
494 'maxlength' => true,
495 'pattern' => true,
496 'autocomplete' => true,
497 'autofocus' => true,
498 'multiple' => true,
499 'rows' => true,
500 'cols' => true,
501 'wrap' => true,
502 'size' => true,
503 'for' => true,
504 'form' => true,
505 'type' => true,
506 'accept' => true,
507 'list' => true,
508 'src' => true,
509 'href' => true,
510 'target' => true,
511 'rel' => true,
512 'open' => true,
513 'variant' => true,
514 )
515 );
516
517 $wpd_attrs = array_merge(
518 $form_attrs,
519 array(
520 'gap' => true,
521 'padding' => true,
522 'align' => true,
523 'justify' => true,
524 'direction' => true,
525 'wrap' => true,
526 'inset' => true,
527 'icon' => true,
528 'tone' => true,
529 'size' => true,
530 'shape' => true,
531 'badge' => true,
532 'selectable' => true,
533 'sticky-header' => true,
534 'sticky-columns' => true,
535 'hover' => true,
536 'striped' => true,
537 'bordered' => true,
538 'compact' => true,
539 'loading' => true,
540 'loading-rows' => true,
541 'empty' => true,
542 'columns' => true,
543 'rows' => true,
544 'sortable' => true,
545 'expandable' => true,
546 'preset' => true,
547 'label' => true,
548 'heading' => true,
549 'description' => true,
550 'orientation' => true,
551 'level' => true,
552 'collapsed' => true,
553 // `<os-form>` props + the `full-width` row span flag
554 // honoured by the form's slotted-child layout rule.
555 'submit-label' => true,
556 'reset-label' => true,
557 'busy' => true,
558 'error' => true,
559 'min-column' => true,
560 'show-reset' => true,
561 'reveal' => true,
562 'full-width' => true,
563 )
564 );
565
566 // Built-in HTML elements the templates rely on.
567 $extra = array(
568 'form' => $form_attrs,
569 'fieldset' => $form_attrs,
570 'legend' => $global_attrs,
571 'label' => $form_attrs,
572 'input' => $form_attrs,
573 'select' => $form_attrs,
574 'option' => $form_attrs,
575 'optgroup' => $form_attrs,
576 'textarea' => $form_attrs,
577 'button' => $form_attrs,
578 'output' => $form_attrs,
579 'datalist' => $global_attrs,
580 'progress' => $form_attrs,
581 'meter' => $form_attrs,
582 'details' => $global_attrs,
583 'summary' => $global_attrs,
584 'dialog' => $global_attrs,
585 'header' => $global_attrs,
586 'footer' => $global_attrs,
587 'main' => $global_attrs,
588 'nav' => $global_attrs,
589 'section' => $global_attrs,
590 'article' => $global_attrs,
591 'aside' => $global_attrs,
592 'figure' => $global_attrs,
593 'figcaption' => $global_attrs,
594 'time' => array_merge( $global_attrs, array( 'datetime' => true ) ),
595 'mark' => $global_attrs,
596 'small' => $global_attrs,
597 'svg' => array_merge(
598 $global_attrs,
599 array(
600 'viewbox' => true,
601 'width' => true,
602 'height' => true,
603 'fill' => true,
604 'stroke' => true,
605 'xmlns' => true,
606 )
607 ),
608 'path' => array(
609 'd' => true,
610 'fill' => true,
611 'stroke' => true,
612 'stroke-width' => true,
613 'stroke-linecap' => true,
614 'stroke-linejoin' => true,
615 'class' => true,
616 ),
617 'g' => array(
618 'class' => true,
619 'transform' => true,
620 'fill' => true,
621 ),
622 'circle' => array(
623 'cx' => true,
624 'cy' => true,
625 'r' => true,
626 'fill' => true,
627 'stroke' => true,
628 'class' => true,
629 ),
630 'rect' => array(
631 'x' => true,
632 'y' => true,
633 'width' => true,
634 'height' => true,
635 'rx' => true,
636 'ry' => true,
637 'fill' => true,
638 'stroke' => true,
639 'class' => true,
640 ),
641 'line' => array(
642 'x1' => true,
643 'y1' => true,
644 'x2' => true,
645 'y2' => true,
646 'stroke' => true,
647 'stroke-width' => true,
648 'class' => true,
649 ),
650 'polyline' => array(
651 'points' => true,
652 'fill' => true,
653 'stroke' => true,
654 'class' => true,
655 ),
656 'polygon' => array(
657 'points' => true,
658 'fill' => true,
659 'stroke' => true,
660 'class' => true,
661 ),
662 'use' => array(
663 'href' => true,
664 'class' => true,
665 ),
666 );
667
668 // `<os-*>` web components — every shipped tag plus a permissive
669 // open door for new ones added by plugin templates.
670 $wpd_tags = array(
671 'os-stack',
672 'os-cluster',
673 'os-grid',
674 'os-spacer',
675 'os-divider',
676 'os-tabs',
677 'os-tab',
678 'os-tabpanel',
679 'os-segmented',
680 'os-segment',
681 'os-button',
682 'os-icon-button',
683 'os-button-group',
684 'os-text-field',
685 'os-textarea',
686 'os-search-field',
687 'os-select',
688 'os-option',
689 'os-checkbox',
690 'os-checkbox-label',
691 'os-radio',
692 'os-radio-group',
693 'os-form',
694 'os-switch',
695 'os-slider',
696 'os-table',
697 'os-table-column',
698 'os-table-row',
699 'os-table-cell',
700 'os-card',
701 'os-list',
702 'os-list-item',
703 'os-badge',
704 'os-pill',
705 'os-tag',
706 'os-chip',
707 'os-spinner',
708 'os-skeleton',
709 'os-empty-state',
710 'os-tooltip',
711 'os-popover',
712 'os-menu',
713 'os-menu-item',
714 'os-modal',
715 'os-drawer',
716 'os-toast',
717 'os-icon',
718 'os-avatar',
719 'os-heading',
720 'os-text',
721 'os-link',
722 'os-banner',
723 'os-alert',
724 'os-callout',
725 'os-form-row',
726 'os-form-section',
727 'os-help-text',
728 'os-toolbar',
729 'os-toolbar-group',
730 );
731 foreach ( $wpd_tags as $tag ) {
732 $extra[ $tag ] = $wpd_attrs;
733 }
734
735 $allowed = array_merge( $base, $extra );
736
737 // Promote the framework's global attrs (`slot`, `part`,
738 // `full-width`, `data-*`, common ARIA, …) to EVERY allowed tag —
739 // otherwise plain wrappers like `<div slot="header">` lose
740 // their `slot` attribute on the way through kses and get
741 // projected into the default slot instead of the named one.
742 // Caught by inspection when the Add User form's header
743 // rendered as a fields-grid cell instead of a banner above
744 // the fields. `array_merge( + )` with a kses-true value
745 // (boolean `true`) is harmless for tags whose entries are
746 // just `true` rather than an attrs map — array_merge skips
747 // non-array values.
748 foreach ( $allowed as $tag => $attrs ) {
749 if ( is_array( $attrs ) ) {
750 $allowed[ $tag ] = array_merge( $attrs, $global_attrs );
751 }
752 }
753
754 /**
755 * Filters the kses allowlist used when escaping native-window
756 * `<template>` payloads.
757 *
758 * Plugins registering their own native windows can extend the
759 * list with custom tags or attributes if their templates need
760 * markup not covered here.
761 *
762 * @param array $allowed wp_kses-shaped allowlist.
763 */
764 return (array) apply_filters( 'openstation_native_window_allowed_html', $allowed );
765 }
766
767 /**
768 * Run `wp_kses` on a native-window template body with the framework
769 * allowlist, **auto-extending the allowlist with every `<os-*>` tag
770 * the template actually uses.**
771 *
772 * The pain this fixes: each shipped `<os-*>` component had to be
773 * manually added to the `$wpd_tags` list above, and the failure mode
774 * of forgetting it was silent — kses would strip the tag, the
775 * template would render as a sea of unparented children, and you'd
776 * spend an afternoon working out why "the form has no buttons."
777 *
778 * Plugin authors registering a new component now only need to
779 * `defineComponent('os-foo', OsFoo)` on the JS side and use
780 * `<os-foo>` in their template — this helper finds the tag at
781 * render time, tags it onto the allowlist with the standard
782 * permissive attrs, and runs kses with the extended list.
783 *
784 * Every callsite in the framework that previously did the
785 * `wp_kses( $html, openstation_native_window_allowed_html() )`
786 * dance can call this instead and get tag-discovery for free.
787 *
788 * @param string $html Template HTML to sanitize.
789 * @return string Sanitized HTML.
790 */
791 function openstation_kses_native_window_template( $html ) {
792 $allowed = openstation_native_window_allowed_html();
793
794 if ( preg_match_all( '/<(os-[a-z][a-z0-9-]*)\b/i', (string) $html, $matches ) ) {
795 $unique = array_unique( array_map( 'strtolower', $matches[1] ) );
796 $wpd_attrs = isset( $allowed['os-button'] )
797 ? $allowed['os-button']
798 : array();
799 foreach ( $unique as $tag ) {
800 if ( ! isset( $allowed[ $tag ] ) ) {
801 $allowed[ $tag ] = $wpd_attrs;
802 }
803 }
804 }
805
806 return wp_kses( (string) $html, $allowed );
807 }
808
809 /**
810 * Render a native window's template HTML to a string, wrapping
811 * with tabs when the window has at least one additional tab
812 * registered. Shared by `openstation_render_native_window_templates()`
813 * (which emits the live `<template>` element) and
814 * `openstation_build_native_windows_payload()` (which captures the same
815 * string for the shell config so mid-session activation can inject
816 * the template without a reload).
817 *
818 * Single-tab windows (no additional tabs registered) render the
819 * same flat body they always did — backwards-compatible with
820 * every existing caller.
821 *
822 * @param array $entry Window registry entry.
823 * @return string Template body HTML (no outer `<template>` tag).
824 */
825 function openstation_build_native_window_template_html( $entry ) {
826 if ( ! is_array( $entry ) || ! is_callable( $entry['template'] ) ) {
827 return '';
828 }
829
830 $tabs = openstation_get_native_window_tabs( $entry['id'] );
831 $has_extras = count( $tabs ) > 1;
832
833 // Fast path — single-pane window, no wrapping.
834 if ( ! $has_extras ) {
835 ob_start();
836 call_user_func( $entry['template'] );
837 return (string) ob_get_clean();
838 }
839
840 // Multi-tab window — wrap in <os-stack> + one <os-tabpanel> per
841 // tab. The default active tab is the main one (the window's own
842 // template).
843 //
844 // The tab STRIP is deliberately absent from this markup. It is
845 // built by the shell in the window chrome, under the title bar,
846 // from the same tab metadata this function walks (the payload
847 // carries it as `tabs`). One tab strip per window, in one place,
848 // whether the window is an admin page in an iframe or a native
849 // window like this one.
850 //
851 // Plugin authors declare tab-change side effects by listening for
852 // `os-window-tab-change` on the window element; see
853 // docs/migration-window-tabs.md.
854 //
855 // The wrap's padding is plugin-controllable two ways:
856 // 1. `main_tab_padding` arg on `openstation_register_window` —
857 // a per-window override. `0` opts into edge-to-edge
858 // content.
859 // 2. `openstation_native_window_tab_wrap_padding` filter for
860 // late-bound overrides (e.g. a theme that wants every
861 // tabbed window to adopt a narrower inset).
862 // Default stays 16px so existing plugins don't shift.
863 $default_padding = isset( $entry['main_tab_padding'] )
864 && '' !== (string) $entry['main_tab_padding']
865 ? (int) $entry['main_tab_padding']
866 : 16;
867 /**
868 * Filters the padding (in px) applied to the auto-generated
869 * tab wrap around a native window's template body. The shell
870 * emits the wrap as `<os-stack padding="N">`; the CSS-as-
871 * attribute pipeline at the client translates that to
872 * `style.padding`.
873 *
874 * Return `0` for edge-to-edge content. Negative values are
875 * clamped to 0.
876 *
877 * @param int $padding Default padding in px.
878 * @param string $window_id The native window id.
879 */
880 $padding = (int) apply_filters(
881 'openstation_native_window_tab_wrap_padding',
882 $default_padding,
883 (string) $entry['id']
884 );
885 if ( $padding < 0 ) {
886 $padding = 0;
887 }
888
889 $buffer = sprintf(
890 '<os-stack gap="12" padding="%d">',
891 $padding
892 );
893
894 // Stamp `hidden` on every non-active panel directly in the
895 // emitted HTML. The shell takes over panel visibility as soon as
896 // it declares the strip, but that happens after the template is
897 // in the body — setting the attribute server-side makes first
898 // paint correct rather than flashing every pane at once.
899 foreach ( $tabs as $tab ) {
900 if ( ! is_callable( $tab['template'] ) ) {
901 continue;
902 }
903 $is_active = OPENSTATION_NATIVE_WINDOW_MAIN_TAB === $tab['value'];
904 $buffer .= sprintf(
905 '<os-tabpanel for="%s"%s>',
906 esc_attr( $tab['value'] ),
907 $is_active ? '' : ' hidden'
908 );
909 ob_start();
910 call_user_func( $tab['template'] );
911 $buffer .= (string) ob_get_clean();
912 $buffer .= '</os-tabpanel>';
913 }
914
915 $buffer .= '</os-stack>';
916 return $buffer;
917 }
918
919 /**
920 * Run a native window's registered `config` through the
921 * `openstation_native_window_config` filter, normalized to an array.
922 *
923 * Called at BOTH serialization points — the eager inline-script
924 * attach in `openstation_enqueue_native_window_scripts()` and the
925 * lazy `scriptL10n` synthesis in
926 * `openstation_build_native_windows_payload()` — so the filter sees
927 * every copy of the blob that can reach a browser.
928 *
929 * @param array $entry Registry entry (needs `id`; `config` optional).
930 * @return array Filtered config. Empty array when nothing to ship.
931 */
932 function openstation_filter_native_window_config( $entry ) {
933 $config = isset( $entry['config'] ) && is_array( $entry['config'] )
934 ? $entry['config']
935 : array();
936
937 /**
938 * Filter a native window's config blob at emit time.
939 *
940 * The registry snapshots `config` when `openstation_register_window()`
941 * runs — usually `init`. This filter runs when the blob is
942 * serialized for the browser (enqueue time on the eager path,
943 * payload-build time on the lazy path), so values that depend on
944 * hooks registered later in the bootstrap can be refreshed without
945 * moving the whole registration. The WP Explorer uses it to
946 * re-collect `previewActions` so plugins may add
947 * `openstation_my_wordpress_preview_actions` callbacks any time
948 * during a normal bootstrap, not just before `init` 99.
949 *
950 * Runs per request, after the current user is determined —
951 * capability-gated values are safe to compute here.
952 *
953 * **Status: Experimental**
954 *
955 * @param array $config Config blob as registered (empty array
956 * when the window registered none).
957 * @param string $window_id Native window id.
958 */
959 $config = apply_filters( 'openstation_native_window_config', $config, (string) $entry['id'] );
960
961 return is_array( $config ) ? $config : array();
962 }
963
964 /**
965 * Attach every registered native window's script data, and enqueue
966 * the handful of bundles that asked to load at boot.
967 *
968 * **A native window's bundle is not enqueued here.** It loads the
969 * first time the window opens: the shell reads the render callback
970 * off `window.openStationNativeWindows[ <id> ]` at open time, so a
971 * bundle printed at boot is weight on every admin page the window is
972 * never opened from — and between WP Explorer, Posts, Plugins,
973 * Comments, the Recycle Bin, Content Graph, Games and the agent
974 * runner that came to well over a megabyte before a single window
975 * had been clicked. `preload_script` is the opt-out for a bundle
976 * with a genuine boot-time job.
977 *
978 * What still happens for EVERY window is the data attach: the
979 * localize blob and the `config` inline. Those hang off the
980 * REGISTERED handle whether or not it is enqueued, which is exactly
981 * how the lazy path gets them — `openstation_resolve_script_payload()`
982 * harvests both into the payload for the shell to replay around the
983 * script tag it injects. Hence priority 5: `openstation_enqueue_assets()`
984 * builds that payload at 10, and data attached after it would ship a
985 * bundle with no config.
986 */
987 function openstation_enqueue_native_window_scripts() {
988 if ( ! openstation_is_shell_request() ) {
989 return;
990 }
991 $registry = openstation_native_window_registry();
992 if ( ! is_array( $registry ) ) {
993 return;
994 }
995 foreach ( $registry as $entry ) {
996 $preload = ! empty( $entry['preload_script'] );
997
998 // Per-tab scripts stay eager. The shell has no lazy path for
999 // them — a tab's script is not part of the window's own
1000 // bundle chain — so deferring here would simply break the
1001 // tab. The main tab uses the window's own `script`.
1002 $tabs = openstation_get_native_window_tabs( $entry['id'] );
1003 foreach ( $tabs as $tab ) {
1004 if ( $tab['is_main'] || empty( $tab['script'] ) ) {
1005 continue;
1006 }
1007 wp_enqueue_script( $tab['script'] );
1008 }
1009
1010 if ( empty( $entry['script'] ) ) {
1011 continue;
1012 }
1013 if ( $preload ) {
1014 wp_enqueue_script( $entry['script'] );
1015 foreach ( (array) $entry['scripts'] as $companion ) {
1016 wp_enqueue_script( $companion );
1017 }
1018 // Preload means "everything at boot" — companion styles
1019 // ride along so the window paints styled on a preloaded
1020 // first open, same as its scripts are already parsed.
1021 if ( ! empty( $entry['styles'] ) ) {
1022 foreach ( (array) $entry['styles'] as $companion_style ) {
1023 wp_enqueue_style( $companion_style );
1024 }
1025 }
1026 }
1027 // Localize the config the JS side reads to register itself.
1028 wp_localize_script(
1029 $entry['script'],
1030 'openStationNativeWindow_' . str_replace( '-', '_', $entry['id'] ),
1031 array(
1032 'id' => $entry['id'],
1033 'title' => $entry['title'],
1034 'icon' => $entry['icon'],
1035 'width' => $entry['width'],
1036 'height' => $entry['height'],
1037 'minWidth' => $entry['min_width'],
1038 'minHeight' => $entry['min_height'],
1039 'placement' => $entry['placement'],
1040 'autofocus' => $entry['autofocus'],
1041 'templateId' => 'os-native-window-' . $entry['id'],
1042 'tabs' => array_map(
1043 static function ( $tab ) {
1044 return array(
1045 'value' => $tab['value'],
1046 'label' => $tab['label'],
1047 'isMain' => $tab['is_main'],
1048 );
1049 },
1050 $tabs
1051 ),
1052 )
1053 );
1054
1055 // Bundle-bound `config`, for the eager print path only.
1056 // `openstation_build_native_windows_payload()` synthesizes the
1057 // same assignment into the payload's `scriptL10n`, which is
1058 // what delivers it on the lazy path — and it has to, because
1059 // that payload is also built inside chromeless iframes, where
1060 // this function returns early. Attaching here unconditionally
1061 // would mean a shell page shipped the identical assignment
1062 // twice: once as `before`, once as `l10n`. The bundle reads it
1063 // via `wp.os.getWindowConfig( id )` or directly at
1064 // `window.openStationWindowConfig[ id ]`.
1065 $config = openstation_filter_native_window_config( $entry );
1066 if ( $preload && ! empty( $config ) ) {
1067 wp_add_inline_script(
1068 $entry['script'],
1069 sprintf(
1070 'window.openStationWindowConfig=window.openStationWindowConfig||{};window.openStationWindowConfig[%s]=%s;',
1071 wp_json_encode( $entry['id'] ),
1072 wp_json_encode( $config )
1073 ),
1074 'before'
1075 );
1076 }
1077 }
1078 }
1079 add_action( 'admin_enqueue_scripts', 'openstation_enqueue_native_window_scripts', 5 );
1080
1081 /**
1082 * Emit a `<template>` tag for every registered native window on
1083 * `admin_footer` when the shell is active. The JS side resolves
1084 * these via `document.getElementById( `os-native-window-${id}` )`
1085 * and clones them into each opened window's body.
1086 */
1087 function openstation_render_native_window_templates() {
1088 if ( ! openstation_is_shell_request() ) {
1089 return;
1090 }
1091 $registry = openstation_native_window_registry();
1092 if ( ! is_array( $registry ) ) {
1093 return;
1094 }
1095 foreach ( $registry as $entry ) {
1096 if ( ! is_callable( $entry['template'] ) ) {
1097 continue;
1098 }
1099 $html = openstation_build_native_window_template_html( $entry );
1100 if ( '' === $html ) {
1101 continue;
1102 }
1103 printf(
1104 '<template id="os-native-window-%s">',
1105 esc_attr( $entry['id'] )
1106 );
1107 // `openstation_kses_native_window_template()` auto-extends
1108 // the allowlist with any `<os-*>` tag the template carries
1109 // — so plugin authors never have to remember to register
1110 // their custom component tags in the kses list.
1111 echo openstation_kses_native_window_template( $html ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- helper kses-escapes.
1112 echo '</template>';
1113 }
1114 }
1115 add_action( 'admin_footer', 'openstation_render_native_window_templates', 20 );
1116
1117 /**
1118 * Whether a registered window is offered on the admin this request is
1119 * in: the network admin's shell offers `network` and `any` windows,
1120 * every site's shell offers `site` and `any`. See the `admin` arg of
1121 * {@see openstation_register_window()}.
1122 *
1123 * @param array<string,mixed> $entry Registry entry.
1124 * @return bool
1125 */
1126 function openstation_native_window_offered_here( $entry ) {
1127 $admin = isset( $entry['admin'] ) ? (string) $entry['admin'] : 'site';
1128 if ( 'any' === $admin ) {
1129 return true;
1130 }
1131 return is_network_admin() ? 'network' === $admin : 'site' === $admin;
1132 }
1133