Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.43% covered (success)
96.43%
135 / 140
81.82% covered (warning)
81.82%
9 / 11
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\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_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%
31 / 31
100.00% covered (success)
100.00%
1 / 1
11
Automattic\Jetpack\PremiumAnalytics\register_widget_types
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
2.01
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`, and `actions` fields
10 * 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.9.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`, and `keywords` through
75 * the widget i18n schema, leaving every other key untouched. Unlike the
76 * upstream copy, a widget with no `textdomain` falls back to the package
77 * text domain instead of skipping translation: every bundled widget
78 * 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', '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 * Constrains a widget icon reference to a registered icon name
267 * (`collection/icon-name`); anything else drops silently to no icon.
268 *
269 * @param mixed $icon Icon reference from the build manifest.
270 * @return string|null The icon name, or null when the shape does not match.
271 */
272function sanitize_widget_icon( $icon ) {
273    if ( ! is_string( $icon ) || '' === $icon ) {
274        return null;
275    }
276
277    if ( ! preg_match( '#^[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?/[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?$#', $icon ) ) {
278        return null;
279    }
280
281    return $icon;
282}
283
284/**
285 * Registers the widget types a build manifest describes.
286 *
287 * What a plugin with a `widgets/` folder calls from the registration action, with the manifest
288 * its own build generates. Each candidate that survives REGISTRABLE_WIDGET_TYPES_FILTER is
289 * translated, sanitized and registered; one already registered, or without a name, is skipped.
290 *
291 * @since 0.9.0
292 *
293 * @param array                     $widgets  Manifest entries, as the generated `<prefix>_get_registered_widget_modules()` returns them.
294 * @param array                     $args     {
295 *     Optional. Registration options.
296 *
297 *     @type string $textdomain    Text domain for the metadata strings of a candidate that declares none.
298 *     @type string $i18n_manifest URL of the build's i18n manifest, for a candidate that declares none.
299 *     @type array  $former_names  Map of widget type name to the names it registered under before, for a candidate that declares none.
300 * }
301 * @param Widget_Type_Registry|null $registry Optional. The registry to write into. Defaults to the main instance.
302 * @return void
303 */
304function register_widget_types_from_manifest( array $widgets, array $args = array(), $registry = null ) {
305    if ( ! $registry instanceof Widget_Type_Registry ) {
306        $registry = Widget_Type_Registry::get_instance();
307    }
308
309    /**
310     * Filters the widget type candidates before they are registered.
311     *
312     * A dropped candidate is never registered, so it is gone from the REST list,
313     * the import map, and any registry reader. Use for hard availability; for
314     * soft state that must stay visible (e.g. shown locked), filter on read via
315     * WIDGET_TYPES_FILTER.
316     *
317     * @param array $widgets Manifest candidates, each with a `name`.
318     */
319    $widgets = apply_filters( REGISTRABLE_WIDGET_TYPES_FILTER, $widgets );
320
321    foreach ( $widgets as $widget ) {
322        if ( empty( $widget['name'] ) || $registry->is_registered( $widget['name'] ) ) {
323            continue;
324        }
325
326        if ( empty( $widget['textdomain'] ) && ! empty( $args['textdomain'] ) ) {
327            $widget['textdomain'] = $args['textdomain'];
328        }
329        if ( empty( $widget['i18n_manifest'] ) && ! empty( $args['i18n_manifest'] ) ) {
330            $widget['i18n_manifest'] = $args['i18n_manifest'];
331        }
332        if ( empty( $widget['former_names'] ) && ! empty( $args['former_names'][ $widget['name'] ] ) ) {
333            $widget['former_names'] = $args['former_names'][ $widget['name'] ];
334        }
335
336        $widget = translate_widget_metadata( $widget );
337
338        $registry->register(
339            $widget['name'],
340            array(
341                'render_module' => $widget['render_module'] ?? null,
342                'widget_module' => $widget['widget_module'] ?? null,
343                'presentation'  => $widget['presentation'] ?? null,
344                'category'      => $widget['category'] ?? null,
345                'title'         => $widget['title'] ?? null,
346                'description'   => $widget['description'] ?? null,
347                'help'          => sanitize_widget_help( $widget['help'] ?? null ),
348                'icon'          => sanitize_widget_icon( $widget['icon'] ?? null ),
349                'actions'       => sanitize_widget_actions( $widget['actions'] ?? null ),
350                'keywords'      => $widget['keywords'] ?? null,
351                'textdomain'    => $widget['textdomain'] ?? null,
352                'i18n_manifest' => $widget['i18n_manifest'] ?? null,
353                'former_names'  => $widget['former_names'] ?? null,
354            )
355        );
356    }
357}
358
359/**
360 * Registers the package's own widget types from its build manifest.
361 *
362 * The package's registrant on the registration action, safe to call directly: a widget type
363 * already registered is skipped.
364 *
365 * @param Widget_Type_Registry|null $registry Optional. The registry being hydrated. Defaults to the main instance.
366 * @return void
367 */
368function register_widget_types( $registry = null ) {
369    if ( ! function_exists( 'jpa_get_registered_widget_modules' ) ) {
370        return;
371    }
372
373    // Generated by wp-build into build/widgets.php, outside Phan's analysis scope.
374    // The function_exists() guard above protects the call at runtime.
375    // No manifest URL: the page's boot init module loads the package's own catalogs.
376    register_widget_types_from_manifest(
377        jpa_get_registered_widget_modules(),
378        array( 'textdomain' => 'jetpack-premium-analytics-pkg' ),
379        $registry
380    );
381}
382
383add_action( Widget_Type_Registry::REGISTER_ACTION, __NAMESPACE__ . '\\register_widget_types' );
384
385/**
386 * Registers a single widget type.
387 *
388 * Call it from a callback on Widget_Type_Registry::REGISTER_ACTION, where the
389 * API is loaded and the registry is hydrating. The metadata strings arrive
390 * translated and in shape, as a section's label does; a build manifest goes
391 * through register_widget_types_from_manifest() instead.
392 *
393 * @since 0.9.0
394 *
395 * @param string $name Widget type name including namespace, `<namespace>/<name>`.
396 * @param array  $args Optional. Widget type arguments, any public property of Widget_Type.
397 * @return Widget_Type|false The registered widget type on success, or false on failure.
398 */
399function register_widget_type( $name, $args = array() ) {
400    return Widget_Type_Registry::get_instance()->register( $name, $args );
401}
402
403/**
404 * Returns the raw registry. For the client-facing set use
405 * get_available_widget_types().
406 *
407 * @return Widget_Type[] Map of `$name => $widget_type`.
408 */
409function get_registered_widget_types() {
410    return Widget_Type_Registry::get_instance()->get_all_registered();
411}
412
413/**
414 * Returns the registered widget types scoped through WIDGET_TYPES_FILTER.
415 *
416 * Use this, not get_registered_widget_types(), wherever widget types reach the
417 * client, so the REST list and import map share one policy.
418 *
419 * @return Widget_Type[] Map of `$name => Widget_Type`.
420 */
421function get_available_widget_types() {
422    /**
423     * Filters the widget types available to the dashboard this request.
424     *
425     * Removing an entry drops it from the REST list and the import map. The type
426     * stays registered, so use this (not the registry-time filter) when a
427     * consumer must still see it, e.g. to show it locked.
428     *
429     * @param Widget_Type[] $widget_types Map of `$name => Widget_Type`.
430     */
431    return apply_filters( WIDGET_TYPES_FILTER, get_registered_widget_types() );
432}