Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.06% covered (warning)
89.06%
57 / 64
71.43% covered (warning)
71.43%
5 / 7
CRAP
n/a
0 / 0
Automattic\Jetpack\PremiumAnalytics\get_widget_metadata_i18n_schema
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
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\register_widget_types
90.91% covered (success)
90.91%
20 / 22
0.00% covered (danger)
0.00%
0 / 1
5.02
Automattic\Jetpack\PremiumAnalytics\bootstrap_widget_types
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
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 * in-memory Widget_Type_Registry, so the plugin queries the registry instead
7 * of re-parsing the manifest. On the way in, user-facing metadata strings are
8 * translated (per the widget-i18n.json schema) and the `help` note sanitized.
9 *
10 * This is the problem-agnostic "core" layer (a PA-namespaced copy of the
11 * experimental Gutenberg API): it exposes the hooks a consumer uses to scope
12 * widget types, but never decides availability itself.
13 *
14 *   - REGISTRABLE_WIDGET_TYPES_FILTER (registry-time): drop candidates before
15 *     they register, gone everywhere. For hard availability.
16 *   - WIDGET_TYPES_FILTER (runtime): scope the registered set on read. For
17 *     request-dependent or soft state (e.g. shown locked).
18 *
19 * @package automattic/jetpack-premium-analytics
20 */
21
22namespace Automattic\Jetpack\PremiumAnalytics;
23
24require_once __DIR__ . '/class-widget-type.php';
25require_once __DIR__ . '/class-widget-type-registry.php';
26
27/**
28 * Registry-time filter over the manifest candidates, before they are registered.
29 */
30const REGISTRABLE_WIDGET_TYPES_FILTER = 'jetpack_premium_analytics_registrable_widget_types';
31
32/**
33 * Runtime filter over the registered widget types map, read for the client.
34 */
35const WIDGET_TYPES_FILTER = 'jetpack_premium_analytics_widget_types';
36
37/**
38 * Returns the i18n schema describing which widget metadata fields are
39 * translatable and the gettext context to use for each.
40 *
41 * Read once from widget-i18n.json and memoized for the rest of the request.
42 * Decoded as objects, not associative arrays: that is how
43 * `translate_settings_using_i18n_schema()` tells keyed maps apart from lists.
44 *
45 * @return object Map of translatable field name to gettext context.
46 */
47function get_widget_metadata_i18n_schema() {
48    static $i18n_schema = null;
49
50    if ( null === $i18n_schema ) {
51        $schema      = wp_json_file_decode( __DIR__ . '/widget-i18n.json' );
52        $i18n_schema = is_object( $schema ) ? $schema : new \stdClass();
53    }
54
55    return $i18n_schema;
56}
57
58/**
59 * Translates a widget's user-facing metadata strings.
60 *
61 * Runs `title`, `description`, `help`, and `keywords` through the widget
62 * i18n schema, leaving every other key untouched. Unlike the upstream copy,
63 * a widget with no `textdomain` falls back to the package text domain
64 * instead of skipping translation: every bundled widget shares it.
65 *
66 * @param array $widget Widget data from the build manifest.
67 * @return array Widget data with its translatable strings localized.
68 */
69function translate_widget_metadata( $widget ) {
70    $textdomain  = ! empty( $widget['textdomain'] ) ? $widget['textdomain'] : 'jetpack-premium-analytics-pkg';
71    $i18n_schema = get_widget_metadata_i18n_schema();
72
73    foreach ( array( 'title', 'description', 'help', 'keywords' ) as $field ) {
74        if ( isset( $widget[ $field ] ) && isset( $i18n_schema->$field ) ) {
75            $widget[ $field ] = translate_settings_using_i18n_schema( $i18n_schema->$field, $widget[ $field ], $textdomain );
76        }
77    }
78
79    return $widget;
80}
81
82/**
83 * Constrains a widget help note to its allowed shape: `content` keeps
84 * only `em`/`strong` markup, and links are dropped unless they carry a
85 * `label` and an `href` that survives `esc_url_raw()`.
86 *
87 * @param array|null $help Help note from the build manifest.
88 * @return array|null Sanitized help note, or null when there is no content.
89 */
90function sanitize_widget_help( $help ) {
91    if ( ! is_array( $help ) || empty( $help['content'] ) || ! is_string( $help['content'] ) ) {
92        return null;
93    }
94
95    $sanitized = array(
96        'content' => wp_kses(
97            $help['content'],
98            array(
99                'em'     => array(),
100                'strong' => array(),
101            )
102        ),
103    );
104
105    if ( ! empty( $help['links'] ) && is_array( $help['links'] ) ) {
106        $links = array();
107        foreach ( $help['links'] as $link ) {
108            if ( is_array( $link ) && ! empty( $link['label'] ) && ! empty( $link['href'] ) ) {
109                $href = esc_url_raw( $link['href'] );
110
111                if ( $href ) {
112                    $links[] = array(
113                        'label' => $link['label'],
114                        'href'  => $href,
115                    );
116                }
117            }
118        }
119
120        if ( $links ) {
121            $sanitized['links'] = $links;
122        }
123    }
124
125    return $sanitized;
126}
127
128/**
129 * Hydrates the widget type registry from the build manifest.
130 *
131 * Each manifest widget is copied into the registry, gated by
132 * REGISTRABLE_WIDGET_TYPES_FILTER so a consumer can drop a candidate first.
133 *
134 * @return void
135 */
136function register_widget_types() {
137    if ( ! function_exists( 'jpa_get_registered_widget_modules' ) ) {
138        return;
139    }
140
141    $registry = Widget_Type_Registry::get_instance();
142
143    // Generated by wp-build into build/widgets.php, outside Phan's analysis scope.
144    // The function_exists() guard above protects the call at runtime.
145    $jetpack_widget_modules = jpa_get_registered_widget_modules();
146
147    /**
148     * Filters the widget type candidates before they are registered.
149     *
150     * A dropped candidate is never registered, so it is gone from the REST list,
151     * the import map, and any registry reader. Use for hard availability; for
152     * soft state that must stay visible (e.g. shown locked), filter on read via
153     * WIDGET_TYPES_FILTER.
154     *
155     * @param array $jetpack_widget_modules Manifest candidates, each with a `name`.
156     */
157    $jetpack_widget_modules = apply_filters( REGISTRABLE_WIDGET_TYPES_FILTER, $jetpack_widget_modules );
158
159    foreach ( $jetpack_widget_modules as $widget ) {
160        if ( empty( $widget['name'] ) || $registry->is_registered( $widget['name'] ) ) {
161            continue;
162        }
163
164        $widget = translate_widget_metadata( $widget );
165
166        $registry->register(
167            $widget['name'],
168            array(
169                'render_module' => $widget['render_module'] ?? null,
170                'widget_module' => $widget['widget_module'] ?? null,
171                'presentation'  => $widget['presentation'] ?? null,
172                'category'      => $widget['category'] ?? null,
173                'title'         => $widget['title'] ?? null,
174                'description'   => $widget['description'] ?? null,
175                'help'          => sanitize_widget_help( $widget['help'] ?? null ),
176                'keywords'      => $widget['keywords'] ?? null,
177            )
178        );
179    }
180}
181
182/**
183 * Hydrates the registry now if init has run, otherwise on init.
184 *
185 * Call after the availability filters are hooked, so the registry-time
186 * filter applies during hydration.
187 *
188 * @return void
189 */
190function bootstrap_widget_types() {
191    if ( did_action( 'init' ) ) {
192        register_widget_types();
193    } else {
194        add_action( 'init', __NAMESPACE__ . '\\register_widget_types' );
195    }
196}
197
198/**
199 * Returns the raw registry. For the client-facing set use
200 * get_available_widget_types().
201 *
202 * @return Widget_Type[] Map of `$name => $widget_type`.
203 */
204function get_registered_widget_types() {
205    return Widget_Type_Registry::get_instance()->get_all_registered();
206}
207
208/**
209 * Returns the registered widget types scoped through WIDGET_TYPES_FILTER.
210 *
211 * Use this, not get_registered_widget_types(), wherever widget types reach the
212 * client, so the REST list and import map share one policy.
213 *
214 * @return Widget_Type[] Map of `$name => Widget_Type`.
215 */
216function get_available_widget_types() {
217    /**
218     * Filters the widget types available to the dashboard this request.
219     *
220     * Removing an entry drops it from the REST list and the import map. The type
221     * stays registered, so use this (not the registry-time filter) when a
222     * consumer must still see it, e.g. to show it locked.
223     *
224     * @param Widget_Type[] $widget_types Map of `$name => Widget_Type`.
225     */
226    return apply_filters( WIDGET_TYPES_FILTER, get_registered_widget_types() );
227}