Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
71.17% covered (warning)
71.17%
79 / 111
70.00% covered (warning)
70.00%
7 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Widget_Type_Registry
71.17% covered (warning)
71.17%
79 / 111
70.00% covered (warning)
70.00%
7 / 10
81.28
0.00% covered (danger)
0.00%
0 / 1
 register
58.82% covered (warning)
58.82%
30 / 51
0.00% covered (danger)
0.00%
0 / 1
24.80
 normalize_former_names
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 former_names_are_free
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
12
 unregister
52.38% covered (warning)
52.38%
11 / 21
0.00% covered (danger)
0.00%
0 / 1
3.97
 resolve_name
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_registered
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 get_all_registered
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 is_registered
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 ensure_hydrated
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 get_instance
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2/**
3 * Widget Types API: Widget_Type_Registry class.
4 *
5 * PA-namespaced copy of the dashboard widget-type registry (the core/Gutenberg
6 * version is gated behind an experimental flag). Stays fully isolated from any
7 * core registry so the two never share state.
8 *
9 * @package automattic/jetpack-premium-analytics
10 */
11
12namespace Automattic\Jetpack\PremiumAnalytics;
13
14/**
15 * Stores Widget_Type instances keyed by their namespaced name.
16 *
17 * Hydrates on its first read: the registration action fires once, and every registrant, the
18 * manifest hydration in widget-types.php included, registers its widget types from there. Reads
19 * happen after `init`, while the page boot dependencies are built and from REST, so hooking the
20 * action is the one moment that covers both paths.
21 */
22#[\AllowDynamicProperties]
23final class Widget_Type_Registry {
24
25    /**
26     * Action through which widget types are registered, fired once on the first read.
27     *
28     * @since 0.9.0
29     * @var string
30     */
31    const REGISTER_ACTION = 'jetpack_premium_analytics_register_widget_types';
32
33    /**
34     * Registered widget types, as `$name => $instance` pairs.
35     *
36     * @var Widget_Type[]
37     */
38    private $registered_widget_types = array();
39
40    /**
41     * Former names of the registered widget types, as `$former_name => $current_name` pairs.
42     *
43     * @var string[]
44     */
45    private $former_names = array();
46
47    /**
48     * Whether the registration action has fired.
49     *
50     * @var bool
51     */
52    private $hydrated = false;
53
54    /**
55     * Container for the main instance of the class.
56     *
57     * @var Widget_Type_Registry|null
58     */
59    private static $instance = null;
60
61    /**
62     * Registers a widget type.
63     *
64     * @param string|Widget_Type $name Widget type name including namespace, or
65     *                                 a complete Widget_Type instance. When an
66     *                                 instance is provided the `$args` parameter
67     *                                 is ignored.
68     * @param array              $args Optional. Array of widget type arguments.
69     *                                 Accepts any public property of
70     *                                 Widget_Type. Default empty array.
71     * @return Widget_Type|false The registered widget type on success, or false
72     *                           on failure.
73     */
74    public function register( $name, $args = array() ) {
75        $widget_type = null;
76        if ( $name instanceof Widget_Type ) {
77            $widget_type = $name;
78            $name        = $widget_type->name;
79        }
80
81        if ( ! is_string( $name ) ) {
82            _doing_it_wrong(
83                __METHOD__,
84                esc_html__( 'Widget type names must be strings.', 'jetpack-premium-analytics-pkg' ),
85                '0.1.0'
86            );
87            return false;
88        }
89
90        if ( preg_match( '/[A-Z]+/', $name ) ) {
91            _doing_it_wrong(
92                __METHOD__,
93                esc_html__( 'Widget type names must not contain uppercase characters.', 'jetpack-premium-analytics-pkg' ),
94                '0.1.0'
95            );
96            return false;
97        }
98
99        $name_matcher = '/^[a-z0-9-]+\/[a-z0-9-]+$/';
100        if ( ! preg_match( $name_matcher, $name ) ) {
101            _doing_it_wrong(
102                __METHOD__,
103                esc_html__( 'Widget type names must contain a namespace prefix. Example: my-plugin/my-custom-widget-type', 'jetpack-premium-analytics-pkg' ),
104                '0.1.0'
105            );
106            return false;
107        }
108
109        if ( $this->is_registered( $name ) ) {
110            _doing_it_wrong(
111                __METHOD__,
112                sprintf(
113                    /* translators: %s: Widget type name. */
114                    esc_html__( 'Widget type "%s" is already registered.', 'jetpack-premium-analytics-pkg' ),
115                    esc_html( $name )
116                ),
117                '0.1.0'
118            );
119            return false;
120        }
121
122        if ( isset( $this->former_names[ $name ] ) ) {
123            // One line: tools/replace-next-version-tag.sh only rewrites the token in a single-line call.
124            _doing_it_wrong( __METHOD__, esc_html( sprintf( /* translators: 1: Widget type name. 2: Widget type name. */ __( 'Widget type "%1$s" is a former name of "%2$s".', 'jetpack-premium-analytics-pkg' ), $name, $this->former_names[ $name ] ) ), 'jetpack-premium-analytics-$$next-version$$' );
125            return false;
126        }
127
128        $former_names = self::normalize_former_names( $widget_type ? $widget_type->former_names : ( $args['former_names'] ?? null ) );
129        if ( null !== $former_names && ! $this->former_names_are_free( $name, $former_names ) ) {
130            return false;
131        }
132        // The type keeps the normalized list: it is what the REST record publishes.
133        if ( $widget_type ) {
134            $widget_type->former_names = $former_names;
135        } else {
136            $args['former_names'] = $former_names;
137        }
138
139        if ( ! $widget_type ) {
140            $widget_type = new Widget_Type( $name, $args );
141        }
142
143        $this->registered_widget_types[ $name ] = $widget_type;
144        foreach ( (array) $widget_type->former_names as $former_name ) {
145            $this->former_names[ $former_name ] = $name;
146        }
147
148        return $widget_type;
149    }
150
151    /**
152     * Normalizes declared former names to a list of distinct values, or null when there are none.
153     *
154     * A keyed array, say what `array_unique()` leaves behind, would reach the client as an object
155     * instead of a list. Anything but an array passes through for `former_names_are_free()` to refuse.
156     *
157     * @param mixed $former_names The declared former names.
158     * @return mixed
159     */
160    private static function normalize_former_names( $former_names ) {
161        if ( ! is_array( $former_names ) ) {
162            return $former_names;
163        }
164
165        $former_names = array_values( array_unique( $former_names, SORT_REGULAR ) );
166
167        return $former_names ? $former_names : null;
168    }
169
170    /**
171     * Whether a widget type may claim the given former names.
172     *
173     * Each one must be a namespaced lowercase name that no registered type or other former name
174     * holds; a failure is a `_doing_it_wrong()`.
175     *
176     * @param string $name         The widget type claiming the names.
177     * @param mixed  $former_names The claimed former names.
178     * @return bool
179     */
180    private function former_names_are_free( $name, $former_names ) {
181        $taken = null;
182        if ( is_array( $former_names ) ) {
183            foreach ( $former_names as $former_name ) {
184                if ( ! is_string( $former_name ) || ! preg_match( '/^[a-z0-9-]+\/[a-z0-9-]+$/', $former_name ) || $former_name === $name ) {
185                    $taken = is_string( $former_name ) ? $former_name : gettype( $former_name );
186                    break;
187                }
188                $owner = $this->former_names[ $former_name ] ?? null;
189                if ( $this->is_registered( $former_name ) || ( null !== $owner && $owner !== $name ) ) {
190                    $taken = $former_name;
191                    break;
192                }
193            }
194        } else {
195            $taken = is_scalar( $former_names ) ? (string) $former_names : gettype( $former_names );
196        }
197
198        if ( null === $taken ) {
199            return true;
200        }
201
202        // One line: tools/replace-next-version-tag.sh only rewrites the token in a single-line call.
203        _doing_it_wrong( __METHOD__, esc_html( sprintf( /* translators: 1: Widget type name. 2: Former name. */ __( 'Widget type "%1$s" cannot claim "%2$s" as a former name: it must be a namespaced lowercase name that no registered widget type holds.', 'jetpack-premium-analytics-pkg' ), $name, $taken ) ), 'jetpack-premium-analytics-$$next-version$$' );
204        return false;
205    }
206
207    /**
208     * Unregisters a widget type.
209     *
210     * @param string|Widget_Type $name Widget type name including namespace, or
211     *                                 a complete Widget_Type instance.
212     * @return Widget_Type|false The unregistered widget type on success, or
213     *                           false on failure.
214     */
215    public function unregister( $name ) {
216        if ( $name instanceof Widget_Type ) {
217            $name = $name->name;
218        }
219
220        if ( ! $this->is_registered( $name ) ) {
221            _doing_it_wrong(
222                __METHOD__,
223                sprintf(
224                    /* translators: %s: Widget type name. */
225                    esc_html__( 'Widget type "%s" is not registered.', 'jetpack-premium-analytics-pkg' ),
226                    esc_html( $name )
227                ),
228                '0.1.0'
229            );
230            return false;
231        }
232
233        $unregistered_widget_type = $this->registered_widget_types[ $name ];
234        unset( $this->registered_widget_types[ $name ] );
235        $this->former_names = array_filter(
236            $this->former_names,
237            static function ( $current_name ) use ( $name ) {
238                return $current_name !== $name;
239            }
240        );
241
242        return $unregistered_widget_type;
243    }
244
245    /**
246     * Resolves a possibly former name to the current widget type name.
247     *
248     * Does not hydrate: call it after a read, since former names arrive with their types'
249     * registration. An unknown name comes back unchanged.
250     *
251     * @since $$next-version$$
252     *
253     * @param string $name Widget type name, current or former.
254     * @return string The current name.
255     */
256    public function resolve_name( $name ) {
257        return $this->former_names[ $name ] ?? $name;
258    }
259
260    /**
261     * Retrieves a registered widget type.
262     *
263     * @param string $name Widget type name including namespace.
264     * @return Widget_Type|null The registered widget type, or null if it is not
265     *                          registered.
266     */
267    public function get_registered( $name ) {
268        $this->ensure_hydrated();
269
270        $name = $this->resolve_name( $name );
271        if ( ! $this->is_registered( $name ) ) {
272            return null;
273        }
274
275        return $this->registered_widget_types[ $name ];
276    }
277
278    /**
279     * Retrieves all registered widget types.
280     *
281     * @return Widget_Type[] Associative array of `$name => $widget_type` pairs.
282     */
283    public function get_all_registered() {
284        $this->ensure_hydrated();
285
286        return $this->registered_widget_types;
287    }
288
289    /**
290     * Checks if a widget type is registered. Does not hydrate: register() relies on it, and a
291     * registrant may run before the action fires.
292     *
293     * @param string $name Widget type name including namespace.
294     * @return bool True if the widget type is registered, false otherwise.
295     */
296    public function is_registered( $name ) {
297        return isset( $this->registered_widget_types[ $name ] );
298    }
299
300    /**
301     * Fires the registration action once, on the first read after `init`.
302     *
303     * @return void
304     */
305    private function ensure_hydrated() {
306        if ( $this->hydrated ) {
307            return;
308        }
309
310        // Latching this early would drop every registrant hooked later, so the read skips the action.
311        if ( ! did_action( 'init' ) ) {
312            $message = __( 'Widget types are read after init. A read before it does not hydrate the registry and answers only what was registered directly.', 'jetpack-premium-analytics-pkg' );
313            // One line: tools/replace-next-version-tag.sh only rewrites the token in a single-line call.
314            _doing_it_wrong( __METHOD__, esc_html( $message ), 'jetpack-premium-analytics-0.9.0' );
315            return;
316        }
317
318        // Latched before the action so a registrant that reads the registry cannot re-enter.
319        $this->hydrated = true;
320
321        /**
322         * Fires when the widget type registry hydrates, on its first read after `init`.
323         *
324         * Register widget types here rather than on `init`: the registry is read while the page
325         * boot dependencies are built and from REST, and each path loads it at a different
326         * moment. A registrant that may run twice guards with `is_registered()`.
327         *
328         * @since 0.9.0
329         *
330         * @param Widget_Type_Registry $registry The registry being hydrated.
331         */
332        do_action( self::REGISTER_ACTION, $this );
333    }
334
335    /**
336     * Utility method to retrieve the main instance of the class.
337     *
338     * The instance will be created if it does not exist yet.
339     *
340     * @return Widget_Type_Registry The main instance.
341     */
342    public static function get_instance() {
343        if ( null === self::$instance ) {
344            self::$instance = new self();
345        }
346
347        return self::$instance;
348    }
349}