PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / trunk
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin vtrunk
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 0.8.6 0.8.5 0.8.4 All 31 releases
desktop-mode / includes / components.php

components.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin trunk, at includes/components.php

405 lines 12.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OpenStation — PHP helpers for plugin authors.
4 *
5 * Two companion helpers live here:
6 *
7 * - {@see openstation_component()} prints a `<os-*>` tag with
8 * safely-escaped attributes (plus its style-array
9 * serializers). The intent is explicit (we're rendering a kit
10 * component, not arbitrary HTML) and the escape discipline is
11 * automatic.
12 *
13 * - {@see openstation_enqueue_script()} wraps
14 * `wp_enqueue_script()` with the `openstation` + `wp-hooks`
15 * dependencies pre-wired, so shell-extending scripts always
16 * load after `wp.os.*` and `wp.hooks` are available.
17 *
18 * {@see openstation_register_window()} moved to
19 * `includes/registries/native-windows.php`.
20 *
21 * @package OpenStation
22 */
23
24 defined( 'ABSPATH' ) || exit;
25
26 /**
27 * Output a `<os-*>` component with safely escaped attributes.
28 *
29 * ```php
30 * openstation_component( 'os-button', array(
31 * 'variant' => 'primary',
32 * 'data-op' => 'add',
33 * 'aria-label' => __( 'Add', 'my-plugin' ),
34 * ), '+' );
35 * ```
36 *
37 * Attribute values flow through `esc_attr()` — no HTML injection
38 * surface. Content is passed through verbatim; callers that want
39 * to render user text should pre-escape with `esc_html()` /
40 * `wp_kses()` themselves.
41 *
42 * Boolean-style attributes (present with a `true` value or an
43 * empty string) render as bare attributes (`disabled`,
44 * `fill-cell`) — matches the HTML5 boolean-attribute convention
45 * every `<os-*>` follows.
46 *
47 * ## Inline styles
48 *
49 * The `style` key accepts either the usual string value or an
50 * associative array of CSS-property → value pairs. The array form
51 * auto-serializes to a CSS declaration list and auto-units bare
52 * integers on length-shaped properties (padding, margin, width,
53 * …) so `'padding' => 0` produces `padding: 0` and
54 * `'padding' => 16` produces `padding: 16px`.
55 *
56 * ```php
57 * openstation_component( 'os-stack', array(
58 * 'gap' => 12,
59 * 'style' => array(
60 * 'padding' => 0,
61 * 'background' => 'rgba(0,0,0,0.04)',
62 * 'border-radius' => 8,
63 * ),
64 * ), $children );
65 * // <os-stack gap="12" style="padding: 0; background: rgba(0,0,0,0.04); border-radius: 8px">
66 * ```
67 *
68 * Plain string form (for one-line overrides) keeps working:
69 *
70 * ```php
71 * openstation_component( 'os-stack', array(
72 * 'style' => 'padding: 0; margin-top: 16px',
73 * ), $children );
74 * ```
75 *
76 * @param string $tag Tag name, e.g. `os-button`.
77 * Whitelisted to the `os-*` prefix
78 * to prevent the helper being
79 * misused as a generic HTML emitter.
80 * @param array<string,mixed> $attrs Attribute key/value pairs.
81 * `style` may be a string or an
82 * associative array (see above).
83 * @param string $content Inner HTML. Pass pre-escaped.
84 */
85 function openstation_component( $tag, $attrs = array(), $content = '' ) {
86 $tag = strtolower( (string) $tag );
87 if ( ! preg_match( '/^os-[a-z][a-z0-9-]*$/', $tag ) ) {
88 // Fail loud in debug so a typo surfaces immediately; silently
89 // drop the output in production so a plugin with a bad tag
90 // doesn't blow up the page.
91 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
92 _doing_it_wrong(
93 __FUNCTION__,
94 sprintf(
95 /* translators: %s: the attempted tag name. */
96 esc_html__( 'openstation_component() only accepts tags with the os- prefix; got "%s".', 'desktop-mode' ),
97 esc_html( $tag )
98 ),
99 '0.5.0'
100 );
101 }
102 return;
103 }
104
105 $attr_parts = array();
106 foreach ( (array) $attrs as $key => $value ) {
107 $key = (string) $key;
108 if ( ! preg_match( '/^[A-Za-z_][A-Za-z0-9_:.-]*$/', $key ) ) {
109 // Silently skip attribute names that don't match the HTML5
110 // name grammar. Same debug-vs-production split as the tag.
111 continue;
112 }
113 if ( false === $value || null === $value ) {
114 continue;
115 }
116 // Style array — serialize to a CSS declaration list. Plain
117 // string values fall through to the generic attribute path
118 // below so `'style' => 'padding:0'` keeps working.
119 if ( 'style' === strtolower( $key ) && is_array( $value ) ) {
120 $serialized = openstation_serialize_style_array( $value );
121 if ( '' === $serialized ) {
122 continue;
123 }
124 $attr_parts[] = sprintf(
125 'style="%s"',
126 esc_attr( $serialized )
127 );
128 continue;
129 }
130 if ( true === $value || '' === $value ) {
131 // Boolean attribute — render bare.
132 $attr_parts[] = esc_attr( $key );
133 continue;
134 }
135 if ( is_array( $value ) || is_object( $value ) ) {
136 // Wrong-shape value on a non-style key. Without this
137 // guard PHP's string cast would emit `key="Array"` /
138 // `key="Object"` — embarrassing in production, silent
139 // in debug. Surface it loudly under WP_DEBUG and drop
140 // the attribute everywhere else.
141 _doing_it_wrong(
142 __FUNCTION__,
143 sprintf(
144 /* translators: 1: attribute name, 2: tag name. */
145 esc_html__( 'Attribute "%1$s" on <%2$s> received a non-scalar value (array/object). Only the `style` attribute accepts an array; other attributes must be strings, booleans, or null. The attribute was skipped.', 'desktop-mode' ),
146 esc_html( $key ),
147 esc_html( $tag )
148 ),
149 '0.5.2'
150 );
151 continue;
152 }
153 $attr_parts[] = sprintf(
154 '%s="%s"',
155 esc_attr( $key ),
156 esc_attr( (string) $value )
157 );
158 }
159
160 $attr_str = $attr_parts ? ' ' . implode( ' ', $attr_parts ) : '';
161
162 printf(
163 '<%1$s%2$s>%3$s</%1$s>',
164 // `$tag` is validated above against the os- allowlist; safe.
165 $tag, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
166 // `$attr_str` is pre-escaped via esc_attr() for each component.
167 $attr_str, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
168 // `$content` is the caller's responsibility to pre-escape.
169 $content // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
170 );
171 }
172
173 /**
174 * CSS properties that treat bare integers as pixels. Mirrors
175 * the length-shaped property list used by plugin JS code when
176 * interpreting raw numeric values — keeping the same list in
177 * one place so PHP `'padding' => 16` and JS `padding: 16` make
178 * the same visual decision.
179 */
180 const OPENSTATION_LENGTH_CSS_PROPERTIES = array(
181 'width',
182 'height',
183 'min-width',
184 'min-height',
185 'max-width',
186 'max-height',
187 'padding',
188 'padding-top',
189 'padding-right',
190 'padding-bottom',
191 'padding-left',
192 'padding-inline',
193 'padding-inline-start',
194 'padding-inline-end',
195 'padding-block',
196 'padding-block-start',
197 'padding-block-end',
198 'margin',
199 'margin-top',
200 'margin-right',
201 'margin-bottom',
202 'margin-left',
203 'margin-inline',
204 'margin-inline-start',
205 'margin-inline-end',
206 'margin-block',
207 'margin-block-start',
208 'margin-block-end',
209 'gap',
210 'row-gap',
211 'column-gap',
212 'border-width',
213 'border-top-width',
214 'border-right-width',
215 'border-bottom-width',
216 'border-left-width',
217 'border-radius',
218 'border-top-left-radius',
219 'border-top-right-radius',
220 'border-bottom-left-radius',
221 'border-bottom-right-radius',
222 'top',
223 'right',
224 'bottom',
225 'left',
226 'inset',
227 'inset-inline-start',
228 'inset-inline-end',
229 'inset-block-start',
230 'inset-block-end',
231 'font-size',
232 'letter-spacing',
233 'word-spacing',
234 'text-indent',
235 'outline-width',
236 'outline-offset',
237 );
238
239 /**
240 * Serialize an associative array of CSS declarations into a
241 * `prop: value; prop: value` string for the `style` attribute.
242 *
243 * Property names are validated as CSS-shaped (kebab-case letters,
244 * digits, hyphens); malformed names are silently dropped. Bare
245 * integer values on length-shaped properties auto-unit to `px`
246 * so callers can write `'padding' => 16` without remembering the
247 * unit. The literal `0` is left unit-less because CSS treats it
248 * as dimensionally valid on any property.
249 *
250 * @param array<string,mixed> $styles
251 * @return string CSS declaration list, or empty string when no
252 * valid declarations were produced.
253 */
254 function openstation_serialize_style_array( $styles ) {
255 if ( ! is_array( $styles ) ) {
256 return '';
257 }
258 $parts = array();
259 foreach ( $styles as $prop => $value ) {
260 $prop = strtolower( trim( (string) $prop ) );
261 if ( ! preg_match( '/^-?[a-z][a-z0-9-]*$/', $prop ) ) {
262 continue;
263 }
264 if ( false === $value || null === $value ) {
265 continue;
266 }
267 $serialized = openstation_format_css_value( $prop, $value );
268 if ( '' === $serialized ) {
269 continue;
270 }
271 $parts[] = $prop . ': ' . $serialized;
272 }
273 return implode( '; ', $parts );
274 }
275
276 /**
277 * Serialize a raw PHP value into a CSS declaration value.
278 *
279 * Handles the two conveniences callers want from an ergonomic
280 * style array:
281 *
282 * - Integer + length-shaped property → append `px`
283 * (`'padding' => 16` → `16px`).
284 * - Integer `0` → keep unit-less (`0` is valid everywhere).
285 *
286 * Everything else (strings, floats already unitted, calc(…)
287 * expressions, color keywords) passes through verbatim.
288 *
289 * @param string $property CSS property name.
290 * @param mixed $value Raw value (int, float, string).
291 * @return string CSS value, or empty string when $value is
292 * not serializable.
293 */
294 function openstation_format_css_value( $property, $value ) {
295 if ( is_bool( $value ) || null === $value ) {
296 return '';
297 }
298 $text = trim( (string) $value );
299 if ( '' === $text ) {
300 return '';
301 }
302 if ( preg_match( '/^-?\d+(\.\d+)?$/', $text ) ) {
303 if ( '0' === $text ) {
304 return '0';
305 }
306 if ( in_array( $property, OPENSTATION_LENGTH_CSS_PROPERTIES, true ) ) {
307 return $text . 'px';
308 }
309 }
310 return $text;
311 }
312
313
314 // Native-windows registry (register_window, allowed_html,
315 // template-html builder, enqueue + render hooks) was moved to
316 // `includes/registries/native-windows.php`.
317
318
319
320 // Widgets registry was moved to
321 // `includes/registries/widgets.php`.
322
323
324
325 // Wallpapers registry was moved to
326 // `includes/registries/wallpapers.php`.
327
328
329 // Desktop-icons registry was moved to
330 // `includes/registries/icons.php`.
331
332
333
334 // Native-window tabs registry was moved to
335 // `includes/registries/window-tabs.php`.
336
337
338 /**
339 * Enqueue a plugin script that extends the desktop shell.
340 *
341 * Thin wrapper around `wp_enqueue_script` that pre-wires the correct
342 * dependencies so the script:
343 *
344 * - Runs AFTER `openstation` (the shell bundle) so `wp.os.*` is
345 * guaranteed available.
346 * - Runs AFTER `wp-hooks` so `wp.hooks.addAction( 'os.init', ... )`
347 * works without the plugin author having to remember that dep.
348 *
349 * Intended to be called from `admin_enqueue_scripts` — the wrapper
350 * itself does not add an `is_admin()` guard.
351 *
352 * Drop-in replacement for the boilerplate:
353 *
354 * ```php
355 * add_action( 'admin_enqueue_scripts', function () {
356 * wp_enqueue_script(
357 * 'my-plugin',
358 * plugins_url( 'my-plugin.js', __FILE__ ),
359 * array( 'openstation', 'wp-hooks' ),
360 * '1.0.0',
361 * true
362 * );
363 * } );
364 * ```
365 *
366 * which becomes:
367 *
368 * ```php
369 * add_action( 'admin_enqueue_scripts', function () {
370 * openstation_enqueue_script(
371 * 'my-plugin',
372 * plugins_url( 'my-plugin.js', __FILE__ ),
373 * array(), // extra deps on top of the desktop defaults
374 * '1.0.0'
375 * );
376 * } );
377 * ```
378 *
379 * @param string $handle Script handle.
380 * @param string $src Full URL of the script, or path relative
381 * to the WordPress root directory.
382 * @param string[] $extra_deps Additional dependency handles. `openstation`
383 * and `wp-hooks` are always prepended.
384 * @param string|bool|null $version Version string, or `false` for none.
385 * Defaults to `OPENSTATION_VERSION` so plugin authors
386 * don't have to busy-track cache busting.
387 * @param bool $in_footer Whether to enqueue in the footer. Defaults
388 * to `true` — the shell is always in head.
389 * @return void
390 */
391 function openstation_enqueue_script( $handle, $src, $extra_deps = array(), $version = null, $in_footer = true ) {
392 $deps = array_merge(
393 array( 'openstation', 'wp-hooks' ),
394 is_array( $extra_deps ) ? $extra_deps : array()
395 );
396
397 wp_enqueue_script(
398 $handle,
399 $src,
400 $deps,
401 null === $version ? OPENSTATION_VERSION : $version,
402 $in_footer
403 );
404 }
405