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