PluginProbe
Gutenberg / 23.9.0
Gutenberg v23.9.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 12.6.0 7.4.0 All 402 releases
gutenberg / lib / experimental / dashboard-widgets / widget-types.php

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

382 lines 11.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`, and `keywords` through the
44 * widget i18n schema using the widget's `textdomain`, leaving every other key
45 * untouched. A no-op when the widget declares no `textdomain`.
46 *
47 * @param array $widget Widget data from the build manifest.
48 * @return array Widget data with its translatable strings localized.
49 */
50 function gutenberg_translate_widget_metadata( $widget ) {
51 $textdomain = $widget['textdomain'] ?? null;
52 if ( ! $textdomain ) {
53 return $widget;
54 }
55
56 $i18n_schema = gutenberg_get_widget_metadata_i18n_schema();
57
58 foreach ( array( 'title', 'description', 'help', 'actions', 'keywords' ) as $field ) {
59 if ( isset( $widget[ $field ], $i18n_schema->$field ) ) {
60 $widget[ $field ] = translate_settings_using_i18n_schema( $i18n_schema->$field, $widget[ $field ], $textdomain );
61 }
62 }
63
64 return $widget;
65 }
66
67 /**
68 * Constrains a widget help note to its allowed shape: `content` keeps
69 * only `em`/`strong` markup, and links are dropped unless they carry a
70 * `label` and an `href` that survives `esc_url_raw()`.
71 *
72 * @param array|null $help Help note from the build manifest.
73 * @return array|null Sanitized help note, or null when there is no content.
74 */
75 function gutenberg_sanitize_widget_help( $help ) {
76 if ( ! is_array( $help ) || empty( $help['content'] ) || ! is_string( $help['content'] ) ) {
77 return null;
78 }
79
80 $sanitized = array(
81 'content' => wp_kses(
82 $help['content'],
83 array(
84 'em' => array(),
85 'strong' => array(),
86 )
87 ),
88 );
89
90 if ( ! empty( $help['links'] ) && is_array( $help['links'] ) ) {
91 $links = array();
92 foreach ( $help['links'] as $link ) {
93 if ( is_array( $link ) && ! empty( $link['label'] ) && ! empty( $link['href'] ) ) {
94 $href = esc_url_raw( $link['href'] );
95
96 if ( $href ) {
97 $links[] = array(
98 'label' => $link['label'],
99 'href' => $href,
100 );
101 }
102 }
103 }
104
105 if ( $links ) {
106 $sanitized['links'] = $links;
107 }
108 }
109
110 return $sanitized;
111 }
112
113 /**
114 * Resolves a widget-local file href to a plugin URL.
115 *
116 * Leaves absolute, scheme-relative, root-relative, and single-segment admin
117 * `.php` hrefs unchanged. Returns '' for local path traversal and for
118 * relative hrefs that are not a file under `widgets/{dir}/` (so
119 * `esc_url_raw()` cannot invent `http://filename`). Query strings on local
120 * filenames are not stripped: `report.csv?v=2` will not resolve as a file.
121 *
122 * @param string $href Action href.
123 * @param string $dir_name Widget directory name.
124 * @return string Plugin URL, original href, or ''.
125 */
126 function gutenberg_resolve_widget_action_href( $href, $dir_name ) {
127 if ( ! is_string( $href ) || '' === $href ) {
128 return '';
129 }
130
131 // Absolute, scheme-relative, or schemed — including URLs with `..` in the path.
132 if ( preg_match( '#^([a-z][a-z0-9+.-]*:)?//#i', $href ) || str_contains( $href, ':' ) ) {
133 return $href;
134 }
135
136 // Root-relative paths (e.g. /wp-admin/…, /report.csv).
137 if ( str_starts_with( $href, '/' ) ) {
138 return $href;
139 }
140
141 if ( str_contains( $href, '..' ) ) {
142 return '';
143 }
144
145 $path_only = preg_split( '/[?#]/', $href, 2 )[0];
146 if ( str_ends_with( strtolower( $path_only ), '.php' ) ) {
147 // Single-segment admin entry points stay as-is. Deeper relative
148 // paths would come out of `esc_url_raw()` as `http://` URLs, and
149 // PHP files never resolve as local widget assets.
150 return str_contains( $path_only, '/' ) ? '' : $href;
151 }
152
153 if ( ! is_string( $dir_name ) || '' === $dir_name ) {
154 return '';
155 }
156
157 $candidate = 'widgets/' . $dir_name . '/' . $href;
158
159 if ( is_file( gutenberg_dir_path() . $candidate ) ) {
160 return gutenberg_url( $candidate );
161 }
162
163 return '';
164 }
165
166 /**
167 * Sanitizes widget actions to `id` / `label` / `href` (via `esc_url_raw()`),
168 * plus optional `download` / `openInNewTab` / `icon` / `relevance`. Drops
169 * incomplete or unsafe entries; dropped hrefs are reported through
170 * `_doing_it_wrong()`. With `$dir_name`, resolves widget-local file hrefs
171 * first. A malformed `icon` or `relevance` drops the key, never the action.
172 *
173 * This is the registration gate for manifest-sourced widget types. Definitions
174 * registered only on the client do not pass through it; any future CPT/API
175 * source should reuse this helper at that boundary.
176 *
177 * @param array|null $actions Actions from the build manifest.
178 * @param string $dir_name Optional widget directory for local asset hrefs.
179 * @return array|null Sanitized actions, or null.
180 */
181 function gutenberg_sanitize_widget_actions( $actions, $dir_name = '' ) {
182 if ( ! is_array( $actions ) ) {
183 return null;
184 }
185
186 $sanitized = array();
187 foreach ( $actions as $action ) {
188 if (
189 ! is_array( $action ) ||
190 ! isset( $action['id'], $action['label'], $action['href'] ) ||
191 ! is_string( $action['id'] ) ||
192 ! is_string( $action['label'] ) ||
193 ! is_string( $action['href'] ) ||
194 '' === $action['id'] ||
195 '' === $action['label'] ||
196 '' === $action['href']
197 ) {
198 continue;
199 }
200
201 $href = gutenberg_resolve_widget_action_href( $action['href'], $dir_name );
202 $href = esc_url_raw( $href );
203 if ( ! $href ) {
204 _doing_it_wrong(
205 __FUNCTION__,
206 sprintf(
207 /* translators: 1: Widget action id. 2: Declared action href. */
208 __( 'Dropped widget action "%1$s": href "%2$s" is neither an allowed URL nor an existing widget file.', 'gutenberg' ),
209 $action['id'],
210 $action['href']
211 ),
212 '23.7.0'
213 );
214 continue;
215 }
216
217 $entry = array(
218 'id' => $action['id'],
219 'label' => $action['label'],
220 'href' => $href,
221 );
222
223 if ( isset( $action['download'] ) ) {
224 if ( is_bool( $action['download'] ) ) {
225 $entry['download'] = $action['download'];
226 } else {
227 $filename = sanitize_file_name( (string) $action['download'] );
228 if ( $filename ) {
229 $entry['download'] = $filename;
230 }
231 }
232 }
233
234 if ( isset( $action['openInNewTab'] ) ) {
235 $entry['openInNewTab'] = (bool) $action['openInNewTab'];
236 }
237
238 if ( isset( $action['icon'] ) ) {
239 $icon = gutenberg_sanitize_widget_icon( $action['icon'] );
240 if ( $icon ) {
241 $entry['icon'] = $icon;
242 }
243 }
244
245 if ( isset( $action['relevance'] ) && in_array( $action['relevance'], array( 'high', 'medium', 'low' ), true ) ) {
246 $entry['relevance'] = $action['relevance'];
247 }
248
249 $sanitized[] = $entry;
250 }
251
252 return $sanitized ? $sanitized : null;
253 }
254
255 /**
256 * Constrains a widget icon reference to a registered icon name
257 * (`collection/icon-name`). Anything else drops silently, so authoring
258 * forms not accepted yet degrade to no icon rather than warn.
259 *
260 * @param string|null $icon Icon reference from the build manifest.
261 * @return string|null The icon name, or null when the shape does not match.
262 */
263 function gutenberg_sanitize_widget_icon( $icon ) {
264 if ( ! is_string( $icon ) || '' === $icon ) {
265 return null;
266 }
267
268 if ( ! preg_match( '#^[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?/[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?$#', $icon ) ) {
269 return null;
270 }
271
272 return $icon;
273 }
274
275 /**
276 * Hydrates the widget type registry from the build manifest.
277 *
278 * Iterates the widgets discovered by the build pipeline (via
279 * `gutenberg_get_registered_widget_modules()`) and registers each one in
280 * `WP_Widget_Type_Registry`. The manifest is the single source of widget
281 * authorship in this codebase; this loop is a deterministic copy of it
282 * into the in-memory registry, with no filters in between.
283 */
284 function gutenberg_register_widget_types() {
285 if ( ! function_exists( 'gutenberg_get_registered_widget_modules' ) ) {
286 return;
287 }
288
289 $registry = WP_Widget_Type_Registry::get_instance();
290
291 foreach ( gutenberg_get_registered_widget_modules() as $widget ) {
292 if ( empty( $widget['name'] ) || $registry->is_registered( $widget['name'] ) ) {
293 continue;
294 }
295
296 $widget = gutenberg_translate_widget_metadata( $widget );
297
298 $registry->register(
299 $widget['name'],
300 array(
301 'render_module' => $widget['render_module'] ?? null,
302 'widget_module' => $widget['widget_module'] ?? null,
303 'presentation' => $widget['presentation'] ?? null,
304 'category' => $widget['category'] ?? null,
305 'title' => $widget['title'] ?? null,
306 'description' => $widget['description'] ?? null,
307 'help' => gutenberg_sanitize_widget_help( $widget['help'] ?? null ),
308 'icon' => gutenberg_sanitize_widget_icon( $widget['icon'] ?? null ),
309 'actions' => gutenberg_sanitize_widget_actions(
310 $widget['actions'] ?? null,
311 $widget['dir_name'] ?? ''
312 ),
313 'keywords' => $widget['keywords'] ?? null,
314 )
315 );
316 }
317 }
318
319 if ( did_action( 'init' ) ) {
320 gutenberg_register_widget_types();
321 } else {
322 add_action( 'init', 'gutenberg_register_widget_types' );
323 }
324
325 /**
326 * Returns all widget types registered in the widget type registry.
327 *
328 * Convenience accessor around `WP_Widget_Type_Registry::get_all_registered()`
329 * for callers that prefer a function-based API.
330 *
331 * @return WP_Widget_Type[] Associative array of `$name => $widget_type`
332 * pairs.
333 */
334 function gutenberg_get_registered_widget_types() {
335 return WP_Widget_Type_Registry::get_instance()->get_all_registered();
336 }
337
338 /**
339 * Registers the REST controller that exposes the widget type registry.
340 */
341 function gutenberg_register_widget_modules_rest_controller() {
342 $controller = new WP_REST_Widget_Modules_Controller();
343 $controller->register_routes();
344 }
345 add_action( 'rest_api_init', 'gutenberg_register_widget_modules_rest_controller' );
346
347 /**
348 * Adds the registered widget modules to the dashboard page's boot
349 * dependencies.
350 *
351 * The wp-build page templates expose a generic
352 * `{page-id}-wp-admin_boot_dependencies` filter. The dashboard hooks
353 * it to make every registered widget render and metadata module
354 * available in the page's import map for dynamic `import()` calls.
355 *
356 * Both the render module and the metadata module are added as
357 * 'dynamic' dependencies so they are reachable from the import map but
358 * not eagerly executed.
359 *
360 * @param array $boot_dependencies Boot dependencies for the page.
361 * @return array Updated boot dependencies.
362 */
363 function gutenberg_add_widget_modules_to_dashboard_boot_deps( $boot_dependencies ) {
364 foreach ( gutenberg_get_registered_widget_types() as $widget_type ) {
365 if ( $widget_type->render_module ) {
366 $boot_dependencies[] = array(
367 'import' => 'dynamic',
368 'id' => $widget_type->render_module,
369 );
370 }
371 if ( $widget_type->widget_module ) {
372 $boot_dependencies[] = array(
373 'import' => 'dynamic',
374 'id' => $widget_type->widget_module,
375 );
376 }
377 }
378
379 return $boot_dependencies;
380 }
381 add_filter( 'dashboard-wp-admin_boot_dependencies', 'gutenberg_add_widget_modules_to_dashboard_boot_deps' );
382