Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
72.17% covered (warning)
72.17%
83 / 115
70.00% covered (warning)
70.00%
7 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Widget_Type_Registry
72.17% covered (warning)
72.17%
83 / 115
70.00% covered (warning)
70.00%
7 / 10
85.71
0.00% covered (danger)
0.00%
0 / 1
 register
61.82% covered (warning)
61.82%
34 / 55
0.00% covered (danger)
0.00%
0 / 1
30.25
 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-0.11.0' );
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        // The plan gates use it as an array key, where anything but a string throws.
140        $plan_feature = $widget_type ? $widget_type->plan_feature : ( $args['plan_feature'] ?? null );
141        if ( null !== $plan_feature && ! is_string( $plan_feature ) ) {
142            // One line: tools/replace-next-version-tag.sh only rewrites the token in a single-line call.
143            _doing_it_wrong( __METHOD__, esc_html( sprintf( /* translators: %s: Widget type name. */ __( 'Widget type "%s" must declare its plan feature as a string.', 'jetpack-premium-analytics-pkg' ), $name ) ), 'jetpack-premium-analytics-$$next-version$$' );
144            return false;
145        }
146
147        if ( ! $widget_type ) {
148            $widget_type = new Widget_Type( $name, $args );
149        }
150
151        $this->registered_widget_types[ $name ] = $widget_type;
152        foreach ( (array) $widget_type->former_names as $former_name ) {
153            $this->former_names[ $former_name ] = $name;
154        }
155
156        return $widget_type;
157    }
158
159    /**
160     * Normalizes declared former names to a list of distinct values, or null when there are none.
161     *
162     * A keyed array, say what `array_unique()` leaves behind, would reach the client as an object
163     * instead of a list. Anything but an array passes through for `former_names_are_free()` to refuse.
164     *
165     * @param mixed $former_names The declared former names.
166     * @return mixed
167     */
168    private static function normalize_former_names( $former_names ) {
169        if ( ! is_array( $former_names ) ) {
170            return $former_names;
171        }
172
173        $former_names = array_values( array_unique( $former_names, SORT_REGULAR ) );
174
175        return $former_names ? $former_names : null;
176    }
177
178    /**
179     * Whether a widget type may claim the given former names.
180     *
181     * Each one must be a namespaced lowercase name that no registered type or other former name
182     * holds; a failure is a `_doing_it_wrong()`.
183     *
184     * @param string $name         The widget type claiming the names.
185     * @param mixed  $former_names The claimed former names.
186     * @return bool
187     */
188    private function former_names_are_free( $name, $former_names ) {
189        $taken = null;
190        if ( is_array( $former_names ) ) {
191            foreach ( $former_names as $former_name ) {
192                if ( ! is_string( $former_name ) || ! preg_match( '/^[a-z0-9-]+\/[a-z0-9-]+$/', $former_name ) || $former_name === $name ) {
193                    $taken = is_string( $former_name ) ? $former_name : gettype( $former_name );
194                    break;
195                }
196                $owner = $this->former_names[ $former_name ] ?? null;
197                if ( $this->is_registered( $former_name ) || ( null !== $owner && $owner !== $name ) ) {
198                    $taken = $former_name;
199                    break;
200                }
201            }
202        } else {
203            $taken = is_scalar( $former_names ) ? (string) $former_names : gettype( $former_names );
204        }
205
206        if ( null === $taken ) {
207            return true;
208        }
209
210        // One line: tools/replace-next-version-tag.sh only rewrites the token in a single-line call.
211        _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-0.11.0' );
212        return false;
213    }
214
215    /**
216     * Unregisters a widget type.
217     *
218     * @param string|Widget_Type $name Widget type name including namespace, or
219     *                                 a complete Widget_Type instance.
220     * @return Widget_Type|false The unregistered widget type on success, or
221     *                           false on failure.
222     */
223    public function unregister( $name ) {
224        if ( $name instanceof Widget_Type ) {
225            $name = $name->name;
226        }
227
228        if ( ! $this->is_registered( $name ) ) {
229            _doing_it_wrong(
230                __METHOD__,
231                sprintf(
232                    /* translators: %s: Widget type name. */
233                    esc_html__( 'Widget type "%s" is not registered.', 'jetpack-premium-analytics-pkg' ),
234                    esc_html( $name )
235                ),
236                '0.1.0'
237            );
238            return false;
239        }
240
241        $unregistered_widget_type = $this->registered_widget_types[ $name ];
242        unset( $this->registered_widget_types[ $name ] );
243        $this->former_names = array_filter(
244            $this->former_names,
245            static function ( $current_name ) use ( $name ) {
246                return $current_name !== $name;
247            }
248        );
249
250        return $unregistered_widget_type;
251    }
252
253    /**
254     * Resolves a possibly former name to the current widget type name.
255     *
256     * Does not hydrate: call it after a read, since former names arrive with their types'
257     * registration. An unknown name comes back unchanged.
258     *
259     * @since 0.11.0
260     *
261     * @param string $name Widget type name, current or former.
262     * @return string The current name.
263     */
264    public function resolve_name( $name ) {
265        return $this->former_names[ $name ] ?? $name;
266    }
267
268    /**
269     * Retrieves a registered widget type.
270     *
271     * @param string $name Widget type name including namespace.
272     * @return Widget_Type|null The registered widget type, or null if it is not
273     *                          registered.
274     */
275    public function get_registered( $name ) {
276        $this->ensure_hydrated();
277
278        $name = $this->resolve_name( $name );
279        if ( ! $this->is_registered( $name ) ) {
280            return null;
281        }
282
283        return $this->registered_widget_types[ $name ];
284    }
285
286    /**
287     * Retrieves all registered widget types.
288     *
289     * @return Widget_Type[] Associative array of `$name => $widget_type` pairs.
290     */
291    public function get_all_registered() {
292        $this->ensure_hydrated();
293
294        return $this->registered_widget_types;
295    }
296
297    /**
298     * Checks if a widget type is registered. Does not hydrate: register() relies on it, and a
299     * registrant may run before the action fires.
300     *
301     * @param string $name Widget type name including namespace.
302     * @return bool True if the widget type is registered, false otherwise.
303     */
304    public function is_registered( $name ) {
305        return isset( $this->registered_widget_types[ $name ] );
306    }
307
308    /**
309     * Fires the registration action once, on the first read after `init`.
310     *
311     * @return void
312     */
313    private function ensure_hydrated() {
314        if ( $this->hydrated ) {
315            return;
316        }
317
318        // Latching this early would drop every registrant hooked later, so the read skips the action.
319        if ( ! did_action( 'init' ) ) {
320            $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' );
321            // One line: tools/replace-next-version-tag.sh only rewrites the token in a single-line call.
322            _doing_it_wrong( __METHOD__, esc_html( $message ), 'jetpack-premium-analytics-0.9.0' );
323            return;
324        }
325
326        // Latched before the action so a registrant that reads the registry cannot re-enter.
327        $this->hydrated = true;
328
329        /**
330         * Fires when the widget type registry hydrates, on its first read after `init`.
331         *
332         * Register widget types here rather than on `init`: the registry is read while the page
333         * boot dependencies are built and from REST, and each path loads it at a different
334         * moment. A registrant that may run twice guards with `is_registered()`.
335         *
336         * @since 0.9.0
337         *
338         * @param Widget_Type_Registry $registry The registry being hydrated.
339         */
340        do_action( self::REGISTER_ACTION, $this );
341    }
342
343    /**
344     * Utility method to retrieve the main instance of the class.
345     *
346     * The instance will be created if it does not exist yet.
347     *
348     * @return Widget_Type_Registry The main instance.
349     */
350    public static function get_instance() {
351        if ( null === self::$instance ) {
352            self::$instance = new self();
353        }
354
355        return self::$instance;
356    }
357}