PluginProbe
Gutenberg / trunk
Gutenberg vtrunk
24.1.0 24.0.0 23.9.1 23.9.0 23.8.0 23.7.2 23.7.1 23.7.0 23.6.1 23.6.2 23.6.0 23.5.3 23.5.2 23.5.1 23.5.0 23.4.0 23.3.2 23.3.1 23.3.0 23.2.0 23.2.1 23.2.2 23.1.1 23.1.0 23.0.1 All 404 releases
gutenberg / lib / experimental / dashboard-widgets / widget-types.php

widget-types.php in Gutenberg trunk, at lib/experimental/dashboard-widgets/widget-types.php

505 lines 15.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Widget Types: server-side registry and REST exposure.
4 *
5 * Hydrates `WP_Widget_Type_Registry` from the build manifest at `init`,
6 * and exposes the registry to the client through the
7 * `/wp/v2/widget-modules` REST endpoint. The JS layer reads the endpoint
8 * via core-data and dynamically imports each widget's render module on
9 * the consumer side.
10 *
11 * @package gutenberg
12 */
13
14 require_once __DIR__ . '/class-wp-widget-type.php';
15 require_once __DIR__ . '/class-wp-widget-type-registry.php';
16 require_once __DIR__ . '/class-wp-rest-widget-modules-controller.php';
17
18 /**
19 * Returns the i18n schema describing which widget metadata fields are
20 * translatable and the gettext context to use for each.
21 *
22 * Read once from widget-i18n.json and memoized for the rest of the request.
23 * Decoded as objects, not associative arrays: that is how
24 * `translate_settings_using_i18n_schema()` tells keyed maps apart from
25 * lists.
26 *
27 * @return object Map of translatable field name to gettext context.
28 */
29 function gutenberg_get_widget_metadata_i18n_schema() {
30 static $i18n_schema = null;
31
32 if ( null === $i18n_schema ) {
33 $schema = wp_json_file_decode( __DIR__ . '/widget-i18n.json' );
34 $i18n_schema = is_object( $schema ) ? $schema : new stdClass();
35 }
36
37 return $i18n_schema;
38 }
39
40 /**
41 * Translates a widget's user-facing metadata strings.
42 *
43 * Runs `title`, `description`, `help`, `actions`, `attributes`, and
44 * `keywords` through the widget i18n schema using the widget's `textdomain`,
45 * leaving every other key untouched. A no-op when the widget declares no
46 * `textdomain`.
47 *
48 * @param array $widget Widget data from the build manifest.
49 * @return array Widget data with its translatable strings localized.
50 */
51 function gutenberg_translate_widget_metadata( $widget ) {
52 $textdomain = $widget['textdomain'] ?? null;
53 if ( ! $textdomain ) {
54 return $widget;
55 }
56
57 $i18n_schema = gutenberg_get_widget_metadata_i18n_schema();
58
59 foreach ( array( 'title', 'description', 'help', 'actions', 'attributes', 'keywords' ) as $field ) {
60 if ( isset( $widget[ $field ], $i18n_schema->$field ) ) {
61 $widget[ $field ] = translate_settings_using_i18n_schema( $i18n_schema->$field, $widget[ $field ], $textdomain );
62 }
63 }
64
65 return $widget;
66 }
67
68 /**
69 * Constrains a widget help note to its allowed shape: `content` keeps
70 * only `em`/`strong` markup, and links are dropped unless they carry a
71 * `label` and an `href` that survives `esc_url_raw()`.
72 *
73 * @param array|null $help Help note from the build manifest.
74 * @return array|null Sanitized help note, or null when there is no content.
75 */
76 function gutenberg_sanitize_widget_help( $help ) {
77 if ( ! is_array( $help ) || empty( $help['content'] ) || ! is_string( $help['content'] ) ) {
78 return null;
79 }
80
81 $sanitized = array(
82 'content' => wp_kses(
83 $help['content'],
84 array(
85 'em' => array(),
86 'strong' => array(),
87 )
88 ),
89 );
90
91 if ( ! empty( $help['links'] ) && is_array( $help['links'] ) ) {
92 $links = array();
93 foreach ( $help['links'] as $link ) {
94 if ( is_array( $link ) && ! empty( $link['label'] ) && ! empty( $link['href'] ) ) {
95 $href = esc_url_raw( $link['href'] );
96
97 if ( $href ) {
98 $links[] = array(
99 'label' => $link['label'],
100 'href' => $href,
101 );
102 }
103 }
104 }
105
106 if ( $links ) {
107 $sanitized['links'] = $links;
108 }
109 }
110
111 return $sanitized;
112 }
113
114 /**
115 * Resolves a widget-local file href to a plugin URL.
116 *
117 * Leaves absolute, scheme-relative, root-relative, and single-segment admin
118 * `.php` hrefs unchanged. Returns '' for local path traversal and for
119 * relative hrefs that are not a file under `widgets/{dir}/` (so
120 * `esc_url_raw()` cannot invent `http://filename`). Query strings on local
121 * filenames are not stripped: `report.csv?v=2` will not resolve as a file.
122 *
123 * @param string $href Action href.
124 * @param string $dir_name Widget directory name.
125 * @return string Plugin URL, original href, or ''.
126 */
127 function gutenberg_resolve_widget_action_href( $href, $dir_name ) {
128 if ( ! is_string( $href ) || '' === $href ) {
129 return '';
130 }
131
132 // Absolute, scheme-relative, or schemed — including URLs with `..` in the path.
133 if ( preg_match( '#^([a-z][a-z0-9+.-]*:)?//#i', $href ) || str_contains( $href, ':' ) ) {
134 return $href;
135 }
136
137 // Root-relative paths (e.g. /wp-admin/…, /report.csv).
138 if ( str_starts_with( $href, '/' ) ) {
139 return $href;
140 }
141
142 if ( str_contains( $href, '..' ) ) {
143 return '';
144 }
145
146 $path_only = preg_split( '/[?#]/', $href, 2 )[0];
147 if ( str_ends_with( strtolower( $path_only ), '.php' ) ) {
148 // Single-segment admin entry points stay as-is. Deeper relative
149 // paths would come out of `esc_url_raw()` as `http://` URLs, and
150 // PHP files never resolve as local widget assets.
151 return str_contains( $path_only, '/' ) ? '' : $href;
152 }
153
154 if ( ! is_string( $dir_name ) || '' === $dir_name ) {
155 return '';
156 }
157
158 $candidate = 'widgets/' . $dir_name . '/' . $href;
159
160 if ( is_file( gutenberg_dir_path() . $candidate ) ) {
161 return gutenberg_url( $candidate );
162 }
163
164 return '';
165 }
166
167 /**
168 * Sanitizes widget actions to `id` / `label` / `href` (via `esc_url_raw()`),
169 * plus optional `download` / `openInNewTab` / `icon` / `relevance`. Drops
170 * incomplete or unsafe entries; dropped hrefs are reported through
171 * `_doing_it_wrong()`. With `$dir_name`, resolves widget-local file hrefs
172 * first. A malformed `icon` or `relevance` drops the key, never the action;
173 * a `download` filename that sanitizes to nothing becomes `true`, the
174 * download under its original name.
175 *
176 * This is the registration gate for manifest-sourced widget types. Definitions
177 * registered only on the client do not pass through it; any future CPT/API
178 * source should reuse this helper at that boundary.
179 *
180 * @param array|null $actions Actions from the build manifest.
181 * @param string $dir_name Optional widget directory for local asset hrefs.
182 * @return array|null Sanitized actions, or null.
183 */
184 function gutenberg_sanitize_widget_actions( $actions, $dir_name = '' ) {
185 if ( ! is_array( $actions ) ) {
186 return null;
187 }
188
189 $sanitized = array();
190 foreach ( $actions as $action ) {
191 if (
192 ! is_array( $action ) ||
193 ! isset( $action['id'], $action['label'], $action['href'] ) ||
194 ! is_string( $action['id'] ) ||
195 ! is_string( $action['label'] ) ||
196 ! is_string( $action['href'] ) ||
197 '' === $action['id'] ||
198 '' === $action['label'] ||
199 '' === $action['href']
200 ) {
201 continue;
202 }
203
204 $href = gutenberg_resolve_widget_action_href( $action['href'], $dir_name );
205 $href = esc_url_raw( $href );
206 if ( ! $href ) {
207 _doing_it_wrong(
208 __FUNCTION__,
209 sprintf(
210 /* translators: 1: Widget action id. 2: Declared action href. */
211 __( 'Dropped widget action "%1$s": href "%2$s" is neither an allowed URL nor an existing widget file.', 'gutenberg' ),
212 $action['id'],
213 $action['href']
214 ),
215 '23.7.0'
216 );
217 continue;
218 }
219
220 $entry = array(
221 'id' => $action['id'],
222 'label' => $action['label'],
223 'href' => $href,
224 );
225
226 if ( isset( $action['download'] ) ) {
227 if ( is_bool( $action['download'] ) ) {
228 $entry['download'] = $action['download'];
229 } else {
230 /*
231 * A filename that sanitizes to nothing keeps the download
232 * under the original name; only `false` means navigation.
233 */
234 $filename = sanitize_file_name( (string) $action['download'] );
235 $entry['download'] = '' !== $filename ? $filename : true;
236 }
237 }
238
239 if ( isset( $action['openInNewTab'] ) ) {
240 $entry['openInNewTab'] = (bool) $action['openInNewTab'];
241 }
242
243 if ( isset( $action['icon'] ) ) {
244 $icon = gutenberg_sanitize_widget_icon( $action['icon'] );
245 if ( $icon ) {
246 $entry['icon'] = $icon;
247 }
248 }
249
250 if ( isset( $action['relevance'] ) && in_array( $action['relevance'], array( 'high', 'medium', 'low' ), true ) ) {
251 $entry['relevance'] = $action['relevance'];
252 }
253
254 $sanitized[] = $entry;
255 }
256
257 return $sanitized ? $sanitized : null;
258 }
259
260 /**
261 * Sanitizes a widget attribute schema to the JSON-expressible subset of a
262 * DataViews `Field` per entry: a string `id` (required, unique), string
263 * `type` / `label` / `header` / `description` / `placeholder`, boolean
264 * `readOnly` / `isDisabled` / `enableSorting` / `enableHiding` /
265 * `enableGlobalSearch`, `elements` as `value` / `label` / `description`
266 * triples, `filterBy`, `format`, `isValid` without `custom`, `Edit` as a
267 * control name or config, and a `relevance` of `high` / `medium` / `low`. A
268 * malformed or empty key drops, never the entry; an entry without a usable
269 * `id`, or repeating one, drops.
270 *
271 * This is the registration gate for manifest-sourced widget types, the same
272 * boundary `gutenberg_sanitize_widget_actions()` guards.
273 *
274 * @param array|null $attributes Attribute schema from the build manifest.
275 * @return array|null Sanitized schema, or null.
276 */
277 function gutenberg_sanitize_widget_attributes( $attributes ) {
278 if ( ! is_array( $attributes ) ) {
279 return null;
280 }
281
282 $string_keys = array( 'type', 'label', 'header', 'description', 'placeholder' );
283 $boolean_keys = array( 'readOnly', 'isDisabled', 'enableSorting', 'enableHiding', 'enableGlobalSearch' );
284
285 $sanitized = array();
286 $seen = array();
287 foreach ( $attributes as $attribute ) {
288 if (
289 ! is_array( $attribute ) ||
290 ! isset( $attribute['id'] ) ||
291 ! is_string( $attribute['id'] ) ||
292 '' === $attribute['id'] ||
293 isset( $seen[ $attribute['id'] ] )
294 ) {
295 continue;
296 }
297 $seen[ $attribute['id'] ] = true;
298
299 $entry = array( 'id' => $attribute['id'] );
300
301 foreach ( $string_keys as $key ) {
302 if ( isset( $attribute[ $key ] ) && is_string( $attribute[ $key ] ) && '' !== $attribute[ $key ] ) {
303 $entry[ $key ] = $attribute[ $key ];
304 }
305 }
306
307 foreach ( $boolean_keys as $key ) {
308 if ( isset( $attribute[ $key ] ) && is_bool( $attribute[ $key ] ) ) {
309 $entry[ $key ] = $attribute[ $key ];
310 }
311 }
312
313 if ( isset( $attribute['elements'] ) && is_array( $attribute['elements'] ) ) {
314 $elements = array();
315 foreach ( $attribute['elements'] as $element ) {
316 if (
317 ! is_array( $element ) ||
318 ! array_key_exists( 'value', $element ) ||
319 ! ( null === $element['value'] || is_scalar( $element['value'] ) ) ||
320 ! isset( $element['label'] ) ||
321 ! is_string( $element['label'] )
322 ) {
323 continue;
324 }
325
326 $option = array(
327 'value' => $element['value'],
328 'label' => $element['label'],
329 );
330 if ( isset( $element['description'] ) && is_string( $element['description'] ) ) {
331 $option['description'] = $element['description'];
332 }
333 $elements[] = $option;
334 }
335
336 if ( $elements ) {
337 $entry['elements'] = $elements;
338 }
339 }
340
341 if (
342 isset( $attribute['filterBy'] ) &&
343 ( false === $attribute['filterBy'] || ( is_array( $attribute['filterBy'] ) && array() !== $attribute['filterBy'] ) )
344 ) {
345 $entry['filterBy'] = $attribute['filterBy'];
346 }
347
348 if ( ! empty( $attribute['format'] ) && is_array( $attribute['format'] ) ) {
349 $entry['format'] = $attribute['format'];
350 }
351
352 if ( isset( $attribute['isValid'] ) && is_array( $attribute['isValid'] ) ) {
353 $rules = $attribute['isValid'];
354 unset( $rules['custom'] );
355 if ( $rules ) {
356 $entry['isValid'] = $rules;
357 }
358 }
359
360 if (
361 isset( $attribute['Edit'] ) &&
362 ( ( is_string( $attribute['Edit'] ) && '' !== $attribute['Edit'] ) || ( is_array( $attribute['Edit'] ) && array() !== $attribute['Edit'] ) )
363 ) {
364 $entry['Edit'] = $attribute['Edit'];
365 }
366
367 if ( isset( $attribute['relevance'] ) && in_array( $attribute['relevance'], array( 'high', 'medium', 'low' ), true ) ) {
368 $entry['relevance'] = $attribute['relevance'];
369 }
370
371 $sanitized[] = $entry;
372 }
373
374 return $sanitized ? $sanitized : null;
375 }
376
377 /**
378 * Constrains a widget icon reference to a registered icon name
379 * (`collection/icon-name`). Anything else drops silently, so authoring
380 * forms not accepted yet degrade to no icon rather than warn.
381 *
382 * @param string|null $icon Icon reference from the build manifest.
383 * @return string|null The icon name, or null when the shape does not match.
384 */
385 function gutenberg_sanitize_widget_icon( $icon ) {
386 if ( ! is_string( $icon ) || '' === $icon ) {
387 return null;
388 }
389
390 if ( ! preg_match( '#^[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?/[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?$#', $icon ) ) {
391 return null;
392 }
393
394 return $icon;
395 }
396
397 /**
398 * Hydrates the widget type registry from the build manifest.
399 *
400 * Iterates the widgets discovered by the build pipeline (via
401 * `gutenberg_get_registered_widget_modules()`) and registers each one in
402 * `WP_Widget_Type_Registry`. The manifest is the single source of widget
403 * authorship in this codebase; this loop is a deterministic copy of it
404 * into the in-memory registry, with no filters in between.
405 */
406 function gutenberg_register_widget_types() {
407 if ( ! function_exists( 'gutenberg_get_registered_widget_modules' ) ) {
408 return;
409 }
410
411 $registry = WP_Widget_Type_Registry::get_instance();
412
413 foreach ( gutenberg_get_registered_widget_modules() as $widget ) {
414 if ( empty( $widget['name'] ) || $registry->is_registered( $widget['name'] ) ) {
415 continue;
416 }
417
418 $widget = gutenberg_translate_widget_metadata( $widget );
419
420 $registry->register(
421 $widget['name'],
422 array(
423 'render_module' => $widget['render_module'] ?? null,
424 'widget_module' => $widget['widget_module'] ?? null,
425 'presentation' => $widget['presentation'] ?? null,
426 'category' => $widget['category'] ?? null,
427 'title' => $widget['title'] ?? null,
428 'description' => $widget['description'] ?? null,
429 'help' => gutenberg_sanitize_widget_help( $widget['help'] ?? null ),
430 'icon' => gutenberg_sanitize_widget_icon( $widget['icon'] ?? null ),
431 'actions' => gutenberg_sanitize_widget_actions(
432 $widget['actions'] ?? null,
433 $widget['dir_name'] ?? ''
434 ),
435 'attributes' => gutenberg_sanitize_widget_attributes( $widget['attributes'] ?? null ),
436 'keywords' => $widget['keywords'] ?? null,
437 )
438 );
439 }
440 }
441
442 if ( did_action( 'init' ) ) {
443 gutenberg_register_widget_types();
444 } else {
445 add_action( 'init', 'gutenberg_register_widget_types' );
446 }
447
448 /**
449 * Returns all widget types registered in the widget type registry.
450 *
451 * Convenience accessor around `WP_Widget_Type_Registry::get_all_registered()`
452 * for callers that prefer a function-based API.
453 *
454 * @return WP_Widget_Type[] Associative array of `$name => $widget_type`
455 * pairs.
456 */
457 function gutenberg_get_registered_widget_types() {
458 return WP_Widget_Type_Registry::get_instance()->get_all_registered();
459 }
460
461 /**
462 * Registers the REST controller that exposes the widget type registry.
463 */
464 function gutenberg_register_widget_modules_rest_controller() {
465 $controller = new WP_REST_Widget_Modules_Controller();
466 $controller->register_routes();
467 }
468 add_action( 'rest_api_init', 'gutenberg_register_widget_modules_rest_controller' );
469
470 /**
471 * Adds the registered widget modules to the dashboard page's boot
472 * dependencies.
473 *
474 * The wp-build page templates expose a generic
475 * `{page-id}-wp-admin_boot_dependencies` filter. The dashboard hooks
476 * it to make every registered widget render and metadata module
477 * available in the page's import map for dynamic `import()` calls.
478 *
479 * Both the render module and the metadata module are added as
480 * 'dynamic' dependencies so they are reachable from the import map but
481 * not eagerly executed.
482 *
483 * @param array $boot_dependencies Boot dependencies for the page.
484 * @return array Updated boot dependencies.
485 */
486 function gutenberg_add_widget_modules_to_dashboard_boot_deps( $boot_dependencies ) {
487 foreach ( gutenberg_get_registered_widget_types() as $widget_type ) {
488 if ( $widget_type->render_module ) {
489 $boot_dependencies[] = array(
490 'import' => 'dynamic',
491 'id' => $widget_type->render_module,
492 );
493 }
494 if ( $widget_type->widget_module ) {
495 $boot_dependencies[] = array(
496 'import' => 'dynamic',
497 'id' => $widget_type->widget_module,
498 );
499 }
500 }
501
502 return $boot_dependencies;
503 }
504 add_filter( 'dashboard-wp-admin_boot_dependencies', 'gutenberg_add_widget_modules_to_dashboard_boot_deps' );
505