Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.65% covered (success)
95.65%
198 / 207
66.67% covered (warning)
66.67%
8 / 12
CRAP
n/a
0 / 0
Automattic\Jetpack\PremiumAnalytics\get_widget_metadata_i18n_schema
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
3.58
Automattic\Jetpack\PremiumAnalytics\translate_widget_metadata
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
Automattic\Jetpack\PremiumAnalytics\sanitize_widget_help
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
12
Automattic\Jetpack\PremiumAnalytics\resolve_widget_action_href
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
9.05
Automattic\Jetpack\PremiumAnalytics\sanitize_widget_actions
100.00% covered (success)
100.00%
44 / 44
100.00% covered (success)
100.00%
1 / 1
23
Automattic\Jetpack\PremiumAnalytics\sanitize_widget_attributes
96.67% covered (success)
96.67%
58 / 60
0.00% covered (danger)
0.00%
0 / 1
46
Automattic\Jetpack\PremiumAnalytics\sanitize_widget_icon
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
Automattic\Jetpack\PremiumAnalytics\register_widget_types_from_manifest
100.00% covered (success)
100.00%
35 / 35
100.00% covered (success)
100.00%
1 / 1
13
Automattic\Jetpack\PremiumAnalytics\register_widget_types
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
2.00
Automattic\Jetpack\PremiumAnalytics\register_widget_type
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
Automattic\Jetpack\PremiumAnalytics\get_registered_widget_types
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
Automattic\Jetpack\PremiumAnalytics\get_available_widget_types
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * Widget Types: registry hydration plus the availability filter hooks.
4 *
5 * Copies the wp-build manifest (`jpa_get_registered_widget_modules()`) into the
6 * Widget_Type_Registry from the registration action the registry fires on its
7 * first read, so the plugin queries the registry instead of re-parsing the
8 * manifest. On the way in, user-facing metadata strings are translated (per the
9 * widget-i18n.json schema) and the `help`, `icon`, `actions`, and
10 * `attributes` fields sanitized. Another plugin registers its widget types from the same action,
11 * through register_widget_type().
12 *
13 * This is the problem-agnostic "core" layer (a PA-namespaced copy of the
14 * experimental Gutenberg API): it exposes the hooks a consumer uses to scope
15 * widget types, but never decides availability itself.
16 *
17 *   - REGISTRABLE_WIDGET_TYPES_FILTER (registry-time): drop candidates before
18 *     they register, gone everywhere. For hard availability.
19 *   - WIDGET_TYPES_FILTER (runtime): scope the registered set on read. For
20 *     request-dependent or soft state (e.g. shown locked).
21 *
22 * @package automattic/jetpack-premium-analytics
23 */
24
25namespace Automattic\Jetpack\PremiumAnalytics;
26
27require_once __DIR__ . '/class-widget-type.php';
28require_once __DIR__ . '/class-widget-type-registry.php';
29
30/**
31 * Registry-time filter over the manifest candidates, before they are registered.
32 */
33const REGISTRABLE_WIDGET_TYPES_FILTER = 'jetpack_premium_analytics_registrable_widget_types';
34
35/**
36 * Runtime filter over the registered widget types map, read for the client.
37 */
38const WIDGET_TYPES_FILTER = 'jetpack_premium_analytics_widget_types';
39
40/**
41 * Version of the widget contract a plugin builds against: the shared module ids, the
42 * Widget_Type fields the client reads, and the toolkit exports. The major changes when a widget
43 * built against the previous contract stops working, the minor when a consumer can rely on
44 * something new; a consumer compares it before registering.
45 *
46 * @since 0.9.0
47 */
48const WIDGET_API_VERSION = '1.15.0';
49
50/**
51 * Returns the i18n schema describing which widget metadata fields are
52 * translatable and the gettext context to use for each.
53 *
54 * Read once from widget-i18n.json and memoized for the rest of the request.
55 * Decoded as objects, not associative arrays: that is how
56 * `translate_settings_using_i18n_schema()` tells keyed maps apart from lists.
57 *
58 * @return object Map of translatable field name to gettext context.
59 */
60function get_widget_metadata_i18n_schema() {
61    static $i18n_schema = null;
62
63    if ( null === $i18n_schema ) {
64        $schema      = wp_json_file_decode( __DIR__ . '/widget-i18n.json' );
65        $i18n_schema = is_object( $schema ) ? $schema : new \stdClass();
66    }
67
68    return $i18n_schema;
69}
70
71/**
72 * Translates a widget's user-facing metadata strings.
73 *
74 * Runs `title`, `description`, `help`, `actions`, `attributes`, and
75 * `keywords` through the widget i18n schema, leaving every other key
76 * untouched. Unlike the upstream copy, a widget with no `textdomain` falls
77 * back to the package text domain instead of skipping translation: every
78 * bundled widget shares it.
79 *
80 * @param array $widget Widget data from the build manifest.
81 * @return array Widget data with its translatable strings localized.
82 */
83function translate_widget_metadata( $widget ) {
84    $textdomain  = ! empty( $widget['textdomain'] ) ? $widget['textdomain'] : 'jetpack-premium-analytics-pkg';
85    $i18n_schema = get_widget_metadata_i18n_schema();
86
87    foreach ( array( 'title', 'description', 'help', 'actions', 'attributes', 'keywords' ) as $field ) {
88        if ( isset( $widget[ $field ] ) && isset( $i18n_schema->$field ) ) {
89            $widget[ $field ] = translate_settings_using_i18n_schema( $i18n_schema->$field, $widget[ $field ], $textdomain );
90        }
91    }
92
93    return $widget;
94}
95
96/**
97 * Constrains a widget help note to its allowed shape: `content` keeps
98 * only `em`/`strong` markup, and links are dropped unless they carry a
99 * `label` and an `href` that survives `esc_url_raw()`.
100 *
101 * @param array|null $help Help note from the build manifest.
102 * @return array|null Sanitized help note, or null when there is no content.
103 */
104function sanitize_widget_help( $help ) {
105    if ( ! is_array( $help ) || empty( $help['content'] ) || ! is_string( $help['content'] ) ) {
106        return null;
107    }
108
109    $sanitized = array(
110        'content' => wp_kses(
111            $help['content'],
112            array(
113                'em'     => array(),
114                'strong' => array(),
115            )
116        ),
117    );
118
119    if ( ! empty( $help['links'] ) && is_array( $help['links'] ) ) {
120        $links = array();
121        foreach ( $help['links'] as $link ) {
122            if ( is_array( $link ) && ! empty( $link['label'] ) && ! empty( $link['href'] ) ) {
123                $href = esc_url_raw( $link['href'] );
124
125                if ( $href ) {
126                    $links[] = array(
127                        'label' => $link['label'],
128                        'href'  => $href,
129                    );
130                }
131            }
132        }
133
134        if ( $links ) {
135            $sanitized['links'] = $links;
136        }
137    }
138
139    return $sanitized;
140}
141
142/**
143 * Resolves an action href to the form `esc_url_raw()` can judge.
144 *
145 * Absolute, scheme-relative, root-relative, and single-segment admin `.php`
146 * hrefs pass through unchanged. Returns '' for path traversal and for any
147 * other relative href, so `esc_url_raw()` cannot invent `http://filename`.
148 * Unlike upstream, widget-local files are never resolved: `widgets/` does
149 * not ship with the package, only its build output does.
150 *
151 * @param string $href Action href.
152 * @return string The href to escape, or ''.
153 */
154function resolve_widget_action_href( $href ) {
155    if ( ! is_string( $href ) || '' === $href ) {
156        return '';
157    }
158
159    // Absolute, scheme-relative, or schemed, including URLs with `..` in the path.
160    if ( preg_match( '#^([a-z][a-z0-9+.-]*:)?//#i', $href ) || str_contains( $href, ':' ) ) {
161        return $href;
162    }
163
164    // Root-relative paths (e.g. /wp-admin/..., /report.csv).
165    if ( str_starts_with( $href, '/' ) ) {
166        return $href;
167    }
168
169    if ( str_contains( $href, '..' ) ) {
170        return '';
171    }
172
173    $path_only = preg_split( '/[?#]/', $href, 2 )[0];
174    if ( str_ends_with( strtolower( $path_only ), '.php' ) ) {
175        // Single-segment admin entry points stay as-is. Deeper relative
176        // paths would come out of `esc_url_raw()` as `http://` URLs.
177        return str_contains( $path_only, '/' ) ? '' : $href;
178    }
179
180    return '';
181}
182
183/**
184 * Sanitizes widget actions to `id` / `label` / `href` (via `esc_url_raw()`),
185 * plus optional `download` / `openInNewTab` / `icon` / `relevance`. Drops
186 * incomplete or unsafe entries and reports dropped hrefs through
187 * `_doing_it_wrong()`; a malformed `icon` or `relevance` drops the key, never
188 * the action, and a `download` name that sanitizes to nothing becomes `true`.
189 *
190 * @param mixed $actions Actions from the build manifest.
191 * @return array|null Sanitized actions, or null when none survive.
192 */
193function sanitize_widget_actions( $actions ) {
194    if ( ! is_array( $actions ) ) {
195        return null;
196    }
197
198    $sanitized = array();
199    foreach ( $actions as $action ) {
200        if (
201            ! is_array( $action ) ||
202            ! isset( $action['id'] ) ||
203            ! isset( $action['label'] ) ||
204            ! isset( $action['href'] ) ||
205            ! is_string( $action['id'] ) ||
206            ! is_string( $action['label'] ) ||
207            ! is_string( $action['href'] ) ||
208            '' === $action['id'] ||
209            '' === $action['label'] ||
210            '' === $action['href']
211        ) {
212            continue;
213        }
214
215        $href = esc_url_raw( resolve_widget_action_href( $action['href'] ) );
216        if ( ! $href ) {
217            $message = sprintf(
218                /* translators: 1: Widget action id. 2: Declared action href. */
219                __( 'Dropped widget action "%1$s": href "%2$s" is not an allowed URL.', 'jetpack-premium-analytics-pkg' ),
220                $action['id'],
221                $action['href']
222            );
223            // One line: tools/replace-next-version-tag.sh only rewrites the token in a single-line call.
224            _doing_it_wrong( __FUNCTION__, esc_html( $message ), 'jetpack-premium-analytics-0.8.0' );
225            continue;
226        }
227
228        $entry = array(
229            'id'    => $action['id'],
230            'label' => $action['label'],
231            'href'  => $href,
232        );
233
234        if ( isset( $action['download'] ) ) {
235            if ( is_bool( $action['download'] ) ) {
236                $entry['download'] = $action['download'];
237            } else {
238                // A name that sanitizes to nothing keeps the download under the original name; only `false` navigates.
239                $filename          = sanitize_file_name( (string) $action['download'] );
240                $entry['download'] = '' !== $filename ? $filename : true;
241            }
242        }
243
244        if ( isset( $action['openInNewTab'] ) ) {
245            $entry['openInNewTab'] = (bool) $action['openInNewTab'];
246        }
247
248        if ( isset( $action['icon'] ) ) {
249            $icon = sanitize_widget_icon( $action['icon'] );
250            if ( $icon ) {
251                $entry['icon'] = $icon;
252            }
253        }
254
255        if ( isset( $action['relevance'] ) && in_array( $action['relevance'], array( 'high', 'medium', 'low' ), true ) ) {
256            $entry['relevance'] = $action['relevance'];
257        }
258
259        $sanitized[] = $entry;
260    }
261
262    return $sanitized ? $sanitized : null;
263}
264
265/**
266 * Sanitizes a widget attribute schema. A malformed or empty key drops, never
267 * the entry; an entry without a unique string `id` drops.
268 *
269 * @param mixed $attributes Attribute schema from the build manifest.
270 * @return array|null Sanitized schema, or null when no entry survives.
271 */
272function sanitize_widget_attributes( $attributes ) {
273    if ( ! is_array( $attributes ) ) {
274        return null;
275    }
276
277    $string_keys  = array( 'type', 'label', 'header', 'description', 'placeholder' );
278    $boolean_keys = array( 'readOnly', 'isDisabled', 'enableSorting', 'enableHiding', 'enableGlobalSearch' );
279
280    $sanitized = array();
281    $seen      = array();
282    foreach ( $attributes as $attribute ) {
283        if (
284            ! is_array( $attribute ) ||
285            ! isset( $attribute['id'] ) ||
286            ! is_string( $attribute['id'] ) ||
287            '' === $attribute['id'] ||
288            isset( $seen[ $attribute['id'] ] )
289        ) {
290            continue;
291        }
292        $seen[ $attribute['id'] ] = true;
293
294        $entry = array( 'id' => $attribute['id'] );
295
296        foreach ( $string_keys as $key ) {
297            if ( isset( $attribute[ $key ] ) && is_string( $attribute[ $key ] ) && '' !== $attribute[ $key ] ) {
298                $entry[ $key ] = $attribute[ $key ];
299            }
300        }
301
302        foreach ( $boolean_keys as $key ) {
303            if ( isset( $attribute[ $key ] ) && is_bool( $attribute[ $key ] ) ) {
304                $entry[ $key ] = $attribute[ $key ];
305            }
306        }
307
308        if ( isset( $attribute['elements'] ) && is_array( $attribute['elements'] ) ) {
309            $elements = array();
310            foreach ( $attribute['elements'] as $element ) {
311                if (
312                    ! is_array( $element ) ||
313                    ! array_key_exists( 'value', $element ) ||
314                    ! ( null === $element['value'] || is_scalar( $element['value'] ) ) ||
315                    ! isset( $element['label'] ) ||
316                    ! is_string( $element['label'] )
317                ) {
318                    continue;
319                }
320
321                $option = array(
322                    'value' => $element['value'],
323                    'label' => $element['label'],
324                );
325                if ( isset( $element['description'] ) && is_string( $element['description'] ) ) {
326                    $option['description'] = $element['description'];
327                }
328                if ( isset( $element['icon'] ) ) {
329                    $icon = sanitize_widget_icon( $element['icon'] );
330                    if ( $icon ) {
331                        $option['icon'] = $icon;
332                    }
333                }
334                $elements[] = $option;
335            }
336
337            if ( $elements ) {
338                $entry['elements'] = $elements;
339            }
340        }
341
342        if (
343            isset( $attribute['filterBy'] ) &&
344            ( false === $attribute['filterBy'] || ( is_array( $attribute['filterBy'] ) && array() !== $attribute['filterBy'] ) )
345        ) {
346            $entry['filterBy'] = $attribute['filterBy'];
347        }
348
349        if ( ! empty( $attribute['format'] ) && is_array( $attribute['format'] ) ) {
350            $entry['format'] = $attribute['format'];
351        }
352
353        if ( isset( $attribute['isValid'] ) && is_array( $attribute['isValid'] ) ) {
354            $rules = $attribute['isValid'];
355            unset( $rules['custom'] );
356            if ( $rules ) {
357                $entry['isValid'] = $rules;
358            }
359        }
360
361        if (
362            isset( $attribute['Edit'] ) &&
363            ( ( is_string( $attribute['Edit'] ) && '' !== $attribute['Edit'] ) || ( is_array( $attribute['Edit'] ) && array() !== $attribute['Edit'] ) )
364        ) {
365            $entry['Edit'] = $attribute['Edit'];
366        }
367
368        if ( isset( $attribute['relevance'] ) && in_array( $attribute['relevance'], array( 'high', 'medium', 'low' ), true ) ) {
369            $entry['relevance'] = $attribute['relevance'];
370        }
371
372        $sanitized[] = $entry;
373    }
374
375    return $sanitized ? $sanitized : null;
376}
377
378/**
379 * Constrains a widget icon reference to a registered icon name
380 * (`collection/icon-name`); anything else drops silently to no icon.
381 *
382 * @param mixed $icon Icon reference from the build manifest.
383 * @return string|null The icon name, or null when the shape does not match.
384 */
385function 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 * Registers the widget types a build manifest describes.
399 *
400 * What a plugin with a `widgets/` folder calls from the registration action, with the manifest
401 * its own build generates. Each candidate that survives REGISTRABLE_WIDGET_TYPES_FILTER is
402 * translated, sanitized and registered; one already registered, or without a name, is skipped.
403 *
404 * @since 0.9.0
405 *
406 * @param array                     $widgets  Manifest entries, as the generated `<prefix>_get_registered_widget_modules()` returns them.
407 * @param array                     $args     {
408 *     Optional. Registration options.
409 *
410 *     @type string $textdomain    Text domain for the metadata strings of a candidate that declares none.
411 *     @type string $i18n_manifest URL of the build's i18n manifest, for a candidate that declares none.
412 *     @type array  $former_names  Map of widget type name to the names it registered under before, for a candidate that declares none.
413 *     @type array  $plan_features Map of widget type name to its plan feature, for a candidate that declares none.
414 * }
415 * @param Widget_Type_Registry|null $registry Optional. The registry to write into. Defaults to the main instance.
416 * @return void
417 */
418function register_widget_types_from_manifest( array $widgets, array $args = array(), $registry = null ) {
419    if ( ! $registry instanceof Widget_Type_Registry ) {
420        $registry = Widget_Type_Registry::get_instance();
421    }
422
423    /**
424     * Filters the widget type candidates before they are registered.
425     *
426     * A dropped candidate is never registered, so it is gone from the REST list,
427     * the import map, and any registry reader. Use for hard availability; for
428     * soft state that must stay visible (e.g. shown locked), filter on read via
429     * WIDGET_TYPES_FILTER.
430     *
431     * @param array $widgets Manifest candidates, each with a `name`.
432     */
433    $widgets = apply_filters( REGISTRABLE_WIDGET_TYPES_FILTER, $widgets );
434
435    foreach ( $widgets as $widget ) {
436        if ( empty( $widget['name'] ) || $registry->is_registered( $widget['name'] ) ) {
437            continue;
438        }
439
440        if ( empty( $widget['textdomain'] ) && ! empty( $args['textdomain'] ) ) {
441            $widget['textdomain'] = $args['textdomain'];
442        }
443        if ( empty( $widget['i18n_manifest'] ) && ! empty( $args['i18n_manifest'] ) ) {
444            $widget['i18n_manifest'] = $args['i18n_manifest'];
445        }
446        if ( empty( $widget['former_names'] ) && ! empty( $args['former_names'][ $widget['name'] ] ) ) {
447            $widget['former_names'] = $args['former_names'][ $widget['name'] ];
448        }
449        if ( empty( $widget['plan_feature'] ) && ! empty( $args['plan_features'][ $widget['name'] ] ) ) {
450            $widget['plan_feature'] = $args['plan_features'][ $widget['name'] ];
451        }
452
453        $widget = translate_widget_metadata( $widget );
454
455        $registry->register(
456            $widget['name'],
457            array(
458                'render_module' => $widget['render_module'] ?? null,
459                'widget_module' => $widget['widget_module'] ?? null,
460                'presentation'  => $widget['presentation'] ?? null,
461                'category'      => $widget['category'] ?? null,
462                'title'         => $widget['title'] ?? null,
463                'description'   => $widget['description'] ?? null,
464                'help'          => sanitize_widget_help( $widget['help'] ?? null ),
465                'icon'          => sanitize_widget_icon( $widget['icon'] ?? null ),
466                'actions'       => sanitize_widget_actions( $widget['actions'] ?? null ),
467                'attributes'    => sanitize_widget_attributes( $widget['attributes'] ?? null ),
468                'keywords'      => $widget['keywords'] ?? null,
469                'textdomain'    => $widget['textdomain'] ?? null,
470                'i18n_manifest' => $widget['i18n_manifest'] ?? null,
471                'former_names'  => $widget['former_names'] ?? null,
472                'plan_feature'  => $widget['plan_feature'] ?? null,
473            )
474        );
475    }
476}
477
478/**
479 * The plan feature each of the package's own widget types shows, for the plan gates to lock.
480 * Locations needs only countries: regions and cities lock inside the widget.
481 */
482const WIDGET_TYPE_PLAN_FEATURES = array(
483    'jpa/stats-top-posts'                => 'top-posts',
484    'jpa/referrers'                      => 'referrers',
485    'jpa/locations'                      => 'countries',
486    'jpa/clicks'                         => 'clicks',
487    'jpa/file-downloads'                 => 'file-downloads',
488    'jpa/authors'                        => 'authors',
489    'jpa/author-top-posts'               => 'authors',
490    'jpa/author-performance'             => 'authors',
491    'jpa/search-terms'                   => 'search-terms',
492    'jpa/video-detail-views-performance' => 'videos',
493    'jpa/video-detail-embeds'            => 'videos',
494    'jpa/utm-insights'                   => 'utm',
495    'jpa/devices'                        => 'devices',
496    'jpa/top-platforms'                  => 'devices',
497    'jpa/annual-highlights'              => 'insights',
498    'jpa/all-time-stats'                 => 'insights',
499    'jpa/most-popular-time'              => 'insights',
500    'jpa/most-popular-day'               => 'insights',
501    'jpa/popular-post'                   => 'insights',
502    'jpa/latest-post'                    => 'insights',
503    'jpa/posting-activity'               => 'insights',
504    'jpa/views-over-years'               => 'insights',
505    'jpa/most-commented-posts'           => 'insights',
506    'jpa/most-commented-authors'         => 'insights',
507    'jpa/shares'                         => 'insights',
508    'jpa/tags'                           => 'tags',
509    'jpa/post-all-time-traffic'          => 'recent-7-days',
510);
511
512/**
513 * Registers the package's own widget types from its build manifest.
514 *
515 * The package's registrant on the registration action, safe to call directly: a widget type
516 * already registered is skipped.
517 *
518 * @param Widget_Type_Registry|null $registry Optional. The registry being hydrated. Defaults to the main instance.
519 * @return void
520 */
521function register_widget_types( $registry = null ) {
522    if ( ! function_exists( 'jpa_get_registered_widget_modules' ) ) {
523        return;
524    }
525
526    // Generated by wp-build into build/widgets.php, outside Phan's analysis scope.
527    // The function_exists() guard above protects the call at runtime.
528    // No manifest URL: the page's boot init module loads the package's own catalogs.
529    register_widget_types_from_manifest(
530        jpa_get_registered_widget_modules(),
531        array(
532            'textdomain'    => 'jetpack-premium-analytics-pkg',
533            'plan_features' => WIDGET_TYPE_PLAN_FEATURES,
534        ),
535        $registry
536    );
537}
538
539add_action( Widget_Type_Registry::REGISTER_ACTION, __NAMESPACE__ . '\\register_widget_types' );
540
541/**
542 * Registers a single widget type.
543 *
544 * Call it from a callback on Widget_Type_Registry::REGISTER_ACTION, where the
545 * API is loaded and the registry is hydrating. The metadata strings arrive
546 * translated and in shape, as a section's label does; a build manifest goes
547 * through register_widget_types_from_manifest() instead.
548 *
549 * @since 0.9.0
550 *
551 * @param string $name Widget type name including namespace, `<namespace>/<name>`.
552 * @param array  $args Optional. Widget type arguments, any public property of Widget_Type.
553 * @return Widget_Type|false The registered widget type on success, or false on failure.
554 */
555function register_widget_type( $name, $args = array() ) {
556    return Widget_Type_Registry::get_instance()->register( $name, $args );
557}
558
559/**
560 * Returns the raw registry. For the client-facing set use
561 * get_available_widget_types().
562 *
563 * @return Widget_Type[] Map of `$name => $widget_type`.
564 */
565function get_registered_widget_types() {
566    return Widget_Type_Registry::get_instance()->get_all_registered();
567}
568
569/**
570 * Returns the registered widget types scoped through WIDGET_TYPES_FILTER.
571 *
572 * Use this, not get_registered_widget_types(), wherever widget types reach the
573 * client, so the REST list and import map share one policy.
574 *
575 * @return Widget_Type[] Map of `$name => Widget_Type`.
576 */
577function get_available_widget_types() {
578    /**
579     * Filters the widget types available to the dashboard this request.
580     *
581     * Removing an entry drops it from the REST list and the import map. The type
582     * stays registered, so use this (not the registry-time filter) when a
583     * consumer must still see it, e.g. to show it locked.
584     *
585     * @param Widget_Type[] $widget_types Map of `$name => Widget_Type`.
586     */
587    return apply_filters( WIDGET_TYPES_FILTER, get_registered_widget_types() );
588}