Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.16% covered (success)
97.16%
376 / 387
83.33% covered (warning)
83.33%
15 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
Config_Schema
97.66% covered (success)
97.66%
376 / 385
83.33% covered (warning)
83.33%
15 / 18
75
0.00% covered (danger)
0.00%
0 / 1
 schema
100.00% covered (success)
100.00%
131 / 131
100.00% covered (success)
100.00%
1 / 1
1
 ip_modes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 default_ip_mode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 resolve
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
8
 defaults_from_schema_node
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 resolve_scalar
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 resolve_group
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 resolve_geo
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
14
 defaults_copy
100.00% covered (success)
100.00%
38 / 38
100.00% covered (success)
100.00%
1 / 1
1
 default_consent_categories
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
2
 normalize_copy
50.00% covered (danger)
50.00%
7 / 14
0.00% covered (danger)
0.00%
0 / 1
8.12
 normalize_consent_categories
100.00% covered (success)
100.00%
40 / 40
100.00% covered (success)
100.00%
1 / 1
21
 normalize_consent_category_key
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 normalize_links
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
6.10
 normalize_gdpr_countries
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 normalize_ccpa_regions
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 default_gdpr_countries
100.00% covered (success)
100.00%
34 / 34
100.00% covered (success)
100.00%
1 / 1
1
 default_ccpa_regions
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * Declarative configuration schema for Cookie Consent.
4 *
5 * @package automattic/jetpack-cookie-consent
6 */
7
8namespace Automattic\Jetpack\CookieConsent;
9
10if ( ! defined( 'ABSPATH' ) ) {
11    exit;
12}
13
14/**
15 * Single source of truth for the package config: shape, types, defaults, and the
16 * enum lists below. The schema descriptor and every bespoke validator (resolve_geo(),
17 * Consent_Log_Controller::get_ip_mode()) read their allowed values from the same
18 * constants, so an enum is declared exactly once.
19 */
20final class Config_Schema {
21
22    /**
23     * Allowed geolocation providers. Sourced by the schema and resolve_geo().
24     *
25     * @var string[]
26     */
27    private const GEO_PROVIDERS = array( 'wpcom', 'custom' );
28
29    /**
30     * Allowed consent-log IP handling modes. Sourced by the schema and, via
31     * ip_modes(), by Consent_Log_Controller::get_ip_mode().
32     *
33     * @var string[]
34     */
35    private const IP_MODES = array( 'drop', 'hash', 'truncate', 'raw' );
36
37    /**
38     * Default consent-log IP handling mode. Sourced by the schema and default_ip_mode().
39     */
40    private const DEFAULT_IP_MODE = 'drop';
41
42    /**
43     * JSON-Schema-shaped descriptor for the full config.
44     *
45     * @return array
46     */
47    public static function schema() {
48        return array(
49            'type'       => 'object',
50            'properties' => array(
51                'enabled'         => array(
52                    'type'    => 'boolean',
53                    'default' => true,
54                ),
55                'schema_version'  => array(
56                    'type'    => 'integer',
57                    'default' => 1,
58                ),
59                'features'        => array(
60                    'type'       => 'object',
61                    'properties' => array(
62                        'banner'             => array(
63                            'type'    => 'boolean',
64                            'default' => true,
65                        ),
66                        'ccpa_page'          => array(
67                            'type'    => 'boolean',
68                            'default' => true,
69                        ),
70                        'footer_links'       => array(
71                            'type'    => 'boolean',
72                            'default' => true,
73                        ),
74                        'consent_log'        => array(
75                            'type'    => 'boolean',
76                            'default' => true,
77                        ),
78                        'tracks'             => array(
79                            'type'    => 'boolean',
80                            'default' => true,
81                        ),
82                        'geo'                => array(
83                            'type'    => 'boolean',
84                            'default' => true,
85                        ),
86                        'page_deletion_lock' => array(
87                            'type'    => 'boolean',
88                            'default' => false,
89                        ),
90                    ),
91                ),
92                'geo'             => array(
93                    'type'       => 'object',
94                    'properties' => array(
95                        'provider'            => array(
96                            'type'    => 'string',
97                            'enum'    => self::GEO_PROVIDERS,
98                            'default' => 'wpcom',
99                        ),
100                        'api_url'             => array(
101                            'type'    => 'string',
102                            'default' => 'https://public-api.wordpress.com/geo/',
103                        ),
104                        'country_code_cookie' => array(
105                            'type'    => 'string',
106                            'default' => 'country_code',
107                        ),
108                        'region_cookie'       => array(
109                            'type'    => 'string',
110                            'default' => 'region',
111                        ),
112                        'cookie_duration'     => array(
113                            'type'    => 'integer',
114                            'default' => 6 * HOUR_IN_SECONDS,
115                        ),
116                        'gdpr_countries'      => array(
117                            'type'    => 'array',
118                            'default' => self::default_gdpr_countries(),
119                        ),
120                        'ccpa_regions'        => array(
121                            'type'    => 'array',
122                            'default' => self::default_ccpa_regions(),
123                        ),
124                        'show_on_error'       => array(
125                            'type'    => 'boolean',
126                            'default' => true,
127                        ),
128                    ),
129                ),
130                'gdpr_honors_gpc' => array(
131                    'type'    => 'boolean',
132                    'default' => true,
133                ),
134                'links'           => array(
135                    'type'       => 'object',
136                    'properties' => array(
137                        'cookie_policy_url' => array(
138                            'type'    => 'string',
139                            'default' => '',
140                        ),
141                    ),
142                ),
143                'event_prefix'    => array(
144                    'type'    => 'string',
145                    'default' => 'jetpack',
146                ),
147                'log'             => array(
148                    'type'       => 'object',
149                    'properties' => array(
150                        'retention_days' => array(
151                            'type'    => 'integer',
152                            'default' => 30,
153                        ),
154                        'policy_version' => array(
155                            'type'    => 'string',
156                            'default' => '1',
157                        ),
158                        'banner_version' => array(
159                            'type'    => 'string',
160                            'default' => '1',
161                        ),
162                        'ip_mode'        => array(
163                            'type'    => 'string',
164                            'enum'    => self::IP_MODES,
165                            'default' => self::DEFAULT_IP_MODE,
166                        ),
167                    ),
168                ),
169                'copy'            => array(
170                    'type'    => 'object',
171                    'default' => self::defaults_copy(),
172                ),
173                'consent'         => array(
174                    'type'    => 'object',
175                    // Left empty: resolve() rebuilds categories from the resolved `copy`, so a
176                    // static default here would use default copy and be discarded anyway.
177                    'default' => array( 'categories' => array() ),
178                ),
179            ),
180        );
181    }
182
183    /**
184     * Allowed consent-log IP handling modes.
185     *
186     * @internal For package classes only; not part of the public API.
187     *
188     * @return string[]
189     */
190    public static function ip_modes() {
191        return self::IP_MODES;
192    }
193
194    /**
195     * Default consent-log IP handling mode.
196     *
197     * @internal For package classes only; not part of the public API.
198     *
199     * @return string
200     */
201    public static function default_ip_mode() {
202        return self::DEFAULT_IP_MODE;
203    }
204
205    /**
206     * Resolve a partial consumer config into a fully-defaulted, validated config.
207     *
208     * @param mixed $config Partial consumer config; anything other than an array falls back to defaults.
209     * @return array
210     */
211    public static function resolve( $config = array() ) {
212        if ( ! is_array( $config ) ) {
213            $config = array();
214        }
215
216        // Build the schema once and index every default and field-schema below into it.
217        // Each schema() build re-materializes the copy, category, and country/region
218        // defaults, so resolving off a single materialization avoids rebuilding them per
219        // field lookup.
220        $schema     = self::schema();
221        $properties = $schema['properties'];
222        $defaults   = self::defaults_from_schema_node( $schema );
223
224        // Scalars, enums, booleans, integers: let the schema coerce them, field by field,
225        // falling back to the default whenever a value is missing or fails validation.
226        $features = self::resolve_group( $config['features'] ?? array(), $defaults['features'], $properties['features']['properties'] );
227        $geo      = self::resolve_geo( $config, $defaults['geo'] );
228        // `log` is merged rather than resolved wholesale because `policy_version`/`banner_version`
229        // keep a deliberately permissive contract: Cookie_Consent::get_log_versions() coerces and
230        // diagnoses their scalar values (including non-string overrides) downstream. The two fields
231        // that DO carry a strict schema â€” `ip_mode` (enum) and `retention_days` (integer) â€” are
232        // validated here so a resolved config is schema-valid for them and the filter re-resolve
233        // re-sanitizes an injected value rather than trusting it verbatim.
234        $log                   = isset( $config['log'] ) && is_array( $config['log'] ) ? array_merge( $defaults['log'], $config['log'] ) : $defaults['log'];
235        $log_properties        = $properties['log']['properties'];
236        $log['ip_mode']        = self::resolve_scalar( $log, 'ip_mode', $defaults['log']['ip_mode'], $log_properties['ip_mode'] );
237        $log['retention_days'] = self::resolve_scalar( $log, 'retention_days', $defaults['log']['retention_days'], $log_properties['retention_days'] );
238
239        $resolved = array(
240            'enabled'         => self::resolve_scalar( $config, 'enabled', $defaults['enabled'], $properties['enabled'] ),
241            'schema_version'  => self::resolve_scalar( $config, 'schema_version', $defaults['schema_version'], $properties['schema_version'] ),
242            'features'        => $features,
243            'geo'             => $geo,
244            'gdpr_honors_gpc' => self::resolve_scalar( $config, 'gdpr_honors_gpc', $defaults['gdpr_honors_gpc'], $properties['gdpr_honors_gpc'] ),
245            'links'           => self::normalize_links( $config, $defaults['links'] ),
246            'event_prefix'    => isset( $config['event_prefix'] ) && is_string( $config['event_prefix'] ) ? $config['event_prefix'] : $defaults['event_prefix'],
247            'log'             => $log,
248            'copy'            => self::normalize_copy( $config['copy'] ?? array(), $defaults['copy'] ),
249        );
250
251        $default_categories = self::default_consent_categories( $resolved['copy'] );
252        $consent            = isset( $config['consent'] ) && is_array( $config['consent'] ) ? $config['consent'] : array();
253        $categories         = $consent['categories'] ?? $default_categories;
254
255        $resolved['consent'] = array( 'categories' => self::normalize_consent_categories( $categories, $default_categories ) );
256
257        return $resolved;
258    }
259
260    /**
261     * Recursively pull `default` values out of a schema node.
262     *
263     * @param array $node Schema node.
264     * @return mixed Nested defaults for an object node, or the node's own default.
265     */
266    private static function defaults_from_schema_node( $node ) {
267        if ( isset( $node['properties'] ) ) {
268            $defaults = array();
269            foreach ( $node['properties'] as $key => $child ) {
270                $defaults[ $key ] = self::defaults_from_schema_node( $child );
271            }
272            return $defaults;
273        }
274
275        return $node['default'] ?? null;
276    }
277
278    /**
279     * Resolve a single scalar field: sanitize it against its schema, or fall back to the default.
280     *
281     * @param array  $config       Config array that may hold the field.
282     * @param string $key          Field key.
283     * @param mixed  $default      Default value to use when the field is missing or invalid.
284     * @param array  $field_schema JSON-Schema descriptor for the field.
285     * @return mixed
286     */
287    private static function resolve_scalar( $config, $key, $default, $field_schema ) {
288        if ( ! is_array( $config ) || ! array_key_exists( $key, $config ) ) {
289            return $default;
290        }
291
292        $value = $config[ $key ];
293
294        if ( is_wp_error( rest_validate_value_from_schema( $value, $field_schema, $key ) ) ) {
295            return $default;
296        }
297
298        return rest_sanitize_value_from_schema( $value, $field_schema, $key );
299    }
300
301    /**
302     * Resolve every property of an object field via resolve_scalar().
303     *
304     * @param mixed $group      Configured group, expected to be an array.
305     * @param array $defaults   Default values for the group, keyed like $properties.
306     * @param array $properties Schema properties for the group.
307     * @return array
308     */
309    private static function resolve_group( $group, $defaults, $properties ) {
310        if ( ! is_array( $group ) ) {
311            return $defaults;
312        }
313
314        $resolved = array();
315        foreach ( $properties as $key => $field_schema ) {
316            $resolved[ $key ] = self::resolve_scalar( $group, $key, $defaults[ $key ], $field_schema );
317        }
318
319        return $resolved;
320    }
321
322    /**
323     * Resolve the `geo` group.
324     *
325     * The provider/api_url pair and a couple of the scalar fields need bespoke
326     * fallback behavior (e.g. an unknown provider resets the api_url too) that a
327     * plain field-by-field schema validation pass can't express, so this is an
328     * escape hatch rather than a resolve_group() call.
329     *
330     * @param array $config   Consumer config that may hold a `geo` group.
331     * @param array $defaults Default geo configuration.
332     * @return array Normalized geo configuration.
333     */
334    private static function resolve_geo( $config, $defaults ) {
335        $nested_geo = isset( $config['geo'] ) && is_array( $config['geo'] ) ? $config['geo'] : array();
336        $geo        = array_merge( $defaults, $nested_geo );
337
338        if ( ! in_array( $geo['provider'], self::GEO_PROVIDERS, true ) ) {
339            $geo['provider'] = $defaults['provider'];
340            $geo['api_url']  = $defaults['api_url'];
341        }
342        if ( ! is_string( $geo['api_url'] ) ) {
343            $geo['api_url'] = '';
344        }
345        // The default provider uses a shared, canonical endpoint, so an empty api_url is
346        // refilled from the default; a custom provider may keep an explicitly blank url.
347        if ( $defaults['provider'] === $geo['provider'] && '' === $geo['api_url'] ) {
348            $geo['api_url'] = $defaults['api_url'];
349        }
350        $geo['country_code_cookie'] = is_string( $geo['country_code_cookie'] ) && '' !== $geo['country_code_cookie'] ? $geo['country_code_cookie'] : $defaults['country_code_cookie'];
351        $geo['region_cookie']       = is_string( $geo['region_cookie'] ) && '' !== $geo['region_cookie'] ? $geo['region_cookie'] : $defaults['region_cookie'];
352        $geo['cookie_duration']     = is_numeric( $geo['cookie_duration'] ) ? (int) $geo['cookie_duration'] : $defaults['cookie_duration'];
353        $geo['gdpr_countries']      = is_array( $geo['gdpr_countries'] ) ? self::normalize_gdpr_countries( $geo['gdpr_countries'] ) : $defaults['gdpr_countries'];
354        $geo['ccpa_regions']        = is_array( $geo['ccpa_regions'] ) ? self::normalize_ccpa_regions( $geo['ccpa_regions'] ) : $defaults['ccpa_regions'];
355        $geo['show_on_error']       = (bool) $geo['show_on_error'];
356
357        return $geo;
358    }
359
360    /**
361     * Get default UI copy.
362     *
363     * Defaults are translated in the package text domain. Consumers can override
364     * any key through the `copy` config group and translate those overrides in
365     * their own text domain before returning them from the config filter.
366     *
367     * @return array Default copy keyed by semantic UI location.
368     */
369    private static function defaults_copy() {
370        return array(
371            'banner_title'                     => __( 'Use of your personal data', 'jetpack-cookie-consent' ),
372            'banner_description'               => __( 'We and our partners process your personal data, such as browsing data, IP addresses, cookie information, and other unique identifiers, based on your consent and/or our legitimate interest to improve our website, marketing activities, and your user experience.', 'jetpack-cookie-consent' ),
373            'banner_accept_button'             => __( 'Accept', 'jetpack-cookie-consent' ),
374            'banner_reject_button'             => __( 'Reject', 'jetpack-cookie-consent' ),
375            'banner_customize_button'          => __( 'Customize', 'jetpack-cookie-consent' ),
376            'modal_title'                      => __( 'Customize preferences', 'jetpack-cookie-consent' ),
377            'modal_close_label'                => __( 'Close modal', 'jetpack-cookie-consent' ),
378            'modal_description'                => __( 'Your privacy is important to us. We and our partners use, store, and process your personal data to improve our website, such as by improving security or conducting analytics, marketing activities to help deliver relevant marketing or content, and your user experience, such as by remembering your account name or language settings where applicable. You can customize your cookie settings below.', 'jetpack-cookie-consent' ),
379            'privacy_policy_link'              => __( 'Privacy Policy', 'jetpack-cookie-consent' ),
380            'modal_links_lead'                 => __( 'Learn more in our', 'jetpack-cookie-consent' ),
381            'modal_links_conjunction'          => __( 'and', 'jetpack-cookie-consent' ),
382            'cookie_policy_link'               => __( 'Cookie Policy', 'jetpack-cookie-consent' ),
383            'category_toggle_label'            => __( 'Toggle category description', 'jetpack-cookie-consent' ),
384            'required_category_label'          => __( 'Required', 'jetpack-cookie-consent' ),
385            'always_active_label'              => __( 'Always active', 'jetpack-cookie-consent' ),
386            'required_category_description'    => __( 'These cookies are essential for our websites and services to perform basic functions and are necessary for us to operate certain features. Examples include your IP address, browser type, requested URLs, response codes, and operating system data.', 'jetpack-cookie-consent' ),
387            'analytics_category_label'         => __( 'Analytics', 'jetpack-cookie-consent' ),
388            'analytics_category_description'   => __( 'These cookies allow us to improve performance by collecting information on how users interact with our websites.', 'jetpack-cookie-consent' ),
389            'advertising_category_label'       => __( 'Advertising', 'jetpack-cookie-consent' ),
390            'advertising_category_description' => __( 'These cookies are set by us and our advertising partners to provide you with relevant content and to understand that content\'s effectiveness.', 'jetpack-cookie-consent' ),
391            'save_preferences_button'          => __( 'Save preferences', 'jetpack-cookie-consent' ),
392            'accept_all_button'                => __( 'Accept all', 'jetpack-cookie-consent' ),
393            'reject_all_button'                => __( 'Reject all', 'jetpack-cookie-consent' ),
394            'manage_preferences_link'          => __( 'Manage Privacy Preferences', 'jetpack-cookie-consent' ),
395            'ccpa_page_title'                  => __( 'Your Privacy Choices', 'jetpack-cookie-consent' ),
396            'ccpa_intro'                       => __( 'We value your privacy and want you to feel in control of your personal information. Like most websites, we use cookies and similar tools to improve your website experience and show you relevant ads. Sometimes we share this information with trusted partners to do so.', 'jetpack-cookie-consent' ),
397            'ccpa_laws_notice'                 => __( 'Some U.S. state laws consider this kind of data sharing a sale or sharing of personal information. Depending on where you live, you may have the right to opt out.', 'jetpack-cookie-consent' ),
398            'ccpa_heading'                     => __( 'How to Opt Out', 'jetpack-cookie-consent' ),
399            'ccpa_browser_opt_out'             => __( 'Browser Opt-Out: Click the Opt Out button below to stop your browser from sharing this data.', 'jetpack-cookie-consent' ),
400            'ccpa_account_opt_out'             => __( 'Account Opt-Out: To apply this choice to your account, check the box and enter your email.', 'jetpack-cookie-consent' ),
401            'ccpa_gpc_opt_out'                 => __( 'Automatic Opt-Out: If you use a browser with Global Privacy Control (GPC) turned on, we will recognize it and respect your choice automatically.', 'jetpack-cookie-consent' ),
402            'ccpa_preferences_notice'          => __( 'Your preferences will only affect how we use your information for personalized ads and similar activities. It will not affect how we use your information for other purposes, like security or site functionality.', 'jetpack-cookie-consent' ),
403            'ccpa_button_instruction'          => __( 'Click the Opt Out button to stop this browser from sharing personal data.', 'jetpack-cookie-consent' ),
404            'ccpa_opt_out_button'              => __( 'Opt Out', 'jetpack-cookie-consent' ),
405            'ccpa_snackbar_success'            => __( 'Your browser has been successfully opted out from sharing personal data.', 'jetpack-cookie-consent' ),
406            'ccpa_snackbar_dismiss_label'      => __( 'Dismiss', 'jetpack-cookie-consent' ),
407        );
408    }
409
410    /**
411     * Get the default consent category registry.
412     *
413     * Category keys are the internal/persistence names. The frontend keeps the
414     * existing `required` and `advertising` aliases for the default `functional`
415     * and `marketing` categories.
416     *
417     * @param array|null $copy Optional normalized copy array.
418     * @return array Default consent categories.
419     */
420    private static function default_consent_categories( $copy = null ) {
421        $copy = is_array( $copy ) ? self::normalize_copy( $copy, self::defaults_copy() ) : self::defaults_copy();
422
423        return array(
424            array(
425                'key'             => 'functional',
426                'label'           => $copy['required_category_label'],
427                'description'     => $copy['required_category_description'],
428                'required'        => true,
429                'default_checked' => true,
430                'wp_consent_map'  => array( 'functional' ),
431            ),
432            array(
433                'key'             => 'analytics',
434                'label'           => $copy['analytics_category_label'],
435                'description'     => $copy['analytics_category_description'],
436                'required'        => false,
437                'default_checked' => true,
438                'wp_consent_map'  => array( 'statistics', 'statistics-anonymous' ),
439            ),
440            array(
441                'key'             => 'marketing',
442                'label'           => $copy['advertising_category_label'],
443                'description'     => $copy['advertising_category_description'],
444                'required'        => false,
445                'default_checked' => false,
446                'wp_consent_map'  => array( 'marketing' ),
447            ),
448        );
449    }
450
451    /**
452     * Normalize configured copy by merging consumer overrides with defaults.
453     *
454     * @param array $copy     Copy values supplied by configuration.
455     * @param array $defaults Default copy values.
456     * @return array Normalized copy.
457     */
458    private static function normalize_copy( $copy, $defaults ) {
459        if ( ! is_array( $copy ) ) {
460            return $defaults;
461        }
462
463        foreach ( $copy as $key => $value ) {
464            if ( ! is_string( $key ) ) {
465                continue;
466            }
467
468            if ( is_scalar( $value ) ) {
469                $defaults[ $key ] = (string) $value;
470                continue;
471            }
472
473            // A non-scalar override can't render, so the default is kept. Surface it
474            // so an integrating developer notices the override was dropped instead of
475            // silently shipping the default copy.
476            _doing_it_wrong(
477                __METHOD__,
478                /* translators: %s is the copy configuration key. */
479                esc_html( sprintf( __( 'Cookie consent copy override for "%s" was ignored because it is not a scalar value.', 'jetpack-cookie-consent' ), $key ) ),
480                ''
481            );
482        }
483
484        return $defaults;
485    }
486
487    /**
488     * Normalize configured consent categories.
489     *
490     * @param array $categories Categories supplied by configuration.
491     * @param array $defaults   Default categories.
492     * @return array Normalized categories.
493     */
494    private static function normalize_consent_categories( $categories, $defaults ) {
495        if ( ! is_array( $categories ) ) {
496            return $defaults;
497        }
498
499        $normalized           = array();
500        $seen_keys            = array();
501        $seen_preference_keys = array();
502
503        // The frontend aliases the default `functional`/`marketing` categories to the
504        // `required`/`advertising` preference keys, so those alias strings are reserved.
505        // A consumer category whose key (or derived preference key) collides with one
506        // would otherwise silently overwrite a built-in category's consent state.
507        $reserved_keys = array( 'required', 'advertising' );
508
509        foreach ( $categories as $category ) {
510            if ( ! is_array( $category ) || empty( $category['key'] ) ) {
511                continue;
512            }
513
514            $key = self::normalize_consent_category_key( $category['key'] );
515            if ( '' === $key || isset( $seen_keys[ $key ] ) ) {
516                continue;
517            }
518
519            $preference_key = Cookie_Consent::get_category_preference_key( $key );
520            if ( in_array( $key, $reserved_keys, true ) || isset( $seen_preference_keys[ $preference_key ] ) ) {
521                _doing_it_wrong(
522                    __METHOD__,
523                    /* translators: %s is the consent category key. */
524                    esc_html( sprintf( __( 'Cookie consent category "%s" was ignored because it collides with a reserved preference key.', 'jetpack-cookie-consent' ), $key ) ),
525                    ''
526                );
527                continue;
528            }
529
530            $wp_consent_map = array();
531            if ( isset( $category['wp_consent_map'] ) && is_array( $category['wp_consent_map'] ) ) {
532                foreach ( $category['wp_consent_map'] as $wp_consent_key ) {
533                    $wp_consent_key = sanitize_key( $wp_consent_key );
534                    if ( '' !== $wp_consent_key && ! in_array( $wp_consent_key, $wp_consent_map, true ) ) {
535                        $wp_consent_map[] = $wp_consent_key;
536                    }
537                }
538            }
539
540            if ( empty( $wp_consent_map ) ) {
541                $wp_consent_map = array( $key );
542            }
543
544            $required = ! empty( $category['required'] );
545
546            $normalized[] = array(
547                'key'             => $key,
548                'label'           => isset( $category['label'] ) && is_scalar( $category['label'] ) ? (string) $category['label'] : $key,
549                'description'     => isset( $category['description'] ) && is_scalar( $category['description'] ) ? (string) $category['description'] : '',
550                'required'        => $required,
551                'default_checked' => $required || ! empty( $category['default_checked'] ),
552                'wp_consent_map'  => $wp_consent_map,
553            );
554
555            $seen_keys[ $key ]                       = true;
556            $seen_preference_keys[ $preference_key ] = true;
557        }
558
559        return empty( $normalized ) ? $defaults : $normalized;
560    }
561
562    /**
563     * Normalize a consent category key for PHP arrays and Interactivity paths.
564     *
565     * @param mixed $key Consent category key.
566     * @return string Normalized key.
567     */
568    private static function normalize_consent_category_key( $key ) {
569        $key = sanitize_key( (string) $key );
570        return preg_replace( '/[^a-z0-9_]/', '_', $key );
571    }
572
573    /**
574     * Normalize configured links by merging consumer overrides with defaults.
575     *
576     * @param array $config   Configuration array supplied through filters.
577     * @param array $defaults Default link values.
578     * @return array Normalized links.
579     */
580    private static function normalize_links( $config, $defaults ) {
581        $links = isset( $config['links'] ) && is_array( $config['links'] ) ? $config['links'] : array();
582
583        foreach ( $links as $key => $value ) {
584            if ( ! is_string( $key ) ) {
585                continue;
586            }
587
588            if ( is_scalar( $value ) ) {
589                $defaults[ $key ] = trim( (string) $value );
590            }
591        }
592
593        return $defaults;
594    }
595
596    /**
597     * Normalize GDPR country codes for case-insensitive matching.
598     *
599     * @param array $countries Country codes; non-scalar entries are dropped.
600     * @return string[] Upper-case country codes.
601     */
602    private static function normalize_gdpr_countries( $countries ) {
603        $countries = array_values( array_filter( $countries, 'is_scalar' ) );
604        return array_map(
605            static function ( $country ) {
606                return strtoupper( (string) $country );
607            },
608            $countries
609        );
610    }
611
612    /**
613     * Normalize CCPA region names for case-insensitive matching.
614     *
615     * @param array $regions Region names; non-scalar entries are dropped.
616     * @return string[] Lower-case region names.
617     */
618    private static function normalize_ccpa_regions( $regions ) {
619        $regions = array_values( array_filter( $regions, 'is_scalar' ) );
620        return array_map(
621            static function ( $region ) {
622                return strtolower( (string) $region );
623            },
624            $regions
625        );
626    }
627
628    /**
629     * Get default GDPR country list.
630     *
631     * @return string[] Country codes where opt-in consent applies.
632     */
633    private static function default_gdpr_countries() {
634        return array(
635            // European Member countries.
636            'AT', // Austria.
637            'BE', // Belgium.
638            'BG', // Bulgaria.
639            'CY', // Cyprus.
640            'CZ', // Czech Republic.
641            'DE', // Germany.
642            'DK', // Denmark.
643            'EE', // Estonia.
644            'ES', // Spain.
645            'FI', // Finland.
646            'FR', // France.
647            'GR', // Greece.
648            'HR', // Croatia.
649            'HU', // Hungary.
650            'IE', // Ireland.
651            'IT', // Italy.
652            'LT', // Lithuania.
653            'LU', // Luxembourg.
654            'LV', // Latvia.
655            'MT', // Malta.
656            'NL', // Netherlands.
657            'PL', // Poland.
658            'PT', // Portugal.
659            'RO', // Romania.
660            'SE', // Sweden.
661            'SI', // Slovenia.
662            'SK', // Slovakia.
663            'GB', // United Kingdom.
664            // Single Market Countries that GDPR applies to.
665            'CH', // Switzerland.
666            'IS', // Iceland.
667            'LI', // Liechtenstein.
668            'NO', // Norway.
669        );
670    }
671
672    /**
673     * Get default CCPA-style region list.
674     *
675     * @return string[] Lower-case region names where opt-out consent applies.
676     */
677    private static function default_ccpa_regions() {
678        return array(
679            /* US regions/states that are treated like California for Do Not Sell requests. */
680            'california',
681            'utah',
682            'virginia',
683            'colorado',
684            'connecticut',
685            'texas',
686            'tennessee',
687            'oregon',
688            'new jersey',
689            'montana',
690            'iowa',
691            'indiana',
692            'delaware',
693        );
694    }
695}