Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.16% covered (success)
97.16%
377 / 388
83.33% covered (warning)
83.33%
15 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
Config_Schema
97.67% covered (success)
97.67%
377 / 386
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%
39 / 39
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            'gpc_notice'                       => __( 'Your browser is sending a Global Privacy Control (GPC) signal, which turns optional cookies off. Saving with a category turned on overrides that signal on this site.', 'jetpack-cookie-consent' ),
380            'privacy_policy_link'              => __( 'Privacy Policy', 'jetpack-cookie-consent' ),
381            'modal_links_lead'                 => __( 'Learn more in our', 'jetpack-cookie-consent' ),
382            'modal_links_conjunction'          => __( 'and', 'jetpack-cookie-consent' ),
383            'cookie_policy_link'               => __( 'Cookie Policy', 'jetpack-cookie-consent' ),
384            'category_toggle_label'            => __( 'Toggle category description', 'jetpack-cookie-consent' ),
385            'required_category_label'          => __( 'Required', 'jetpack-cookie-consent' ),
386            'always_active_label'              => __( 'Always active', 'jetpack-cookie-consent' ),
387            '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' ),
388            'analytics_category_label'         => __( 'Analytics', 'jetpack-cookie-consent' ),
389            'analytics_category_description'   => __( 'These cookies allow us to improve performance by collecting information on how users interact with our websites.', 'jetpack-cookie-consent' ),
390            'advertising_category_label'       => __( 'Advertising', 'jetpack-cookie-consent' ),
391            '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' ),
392            'save_preferences_button'          => __( 'Save preferences', 'jetpack-cookie-consent' ),
393            'accept_all_button'                => __( 'Accept all', 'jetpack-cookie-consent' ),
394            'reject_all_button'                => __( 'Reject all', 'jetpack-cookie-consent' ),
395            'manage_preferences_link'          => __( 'Manage Privacy Preferences', 'jetpack-cookie-consent' ),
396            'ccpa_page_title'                  => __( 'Your Privacy Choices', 'jetpack-cookie-consent' ),
397            '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' ),
398            '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' ),
399            'ccpa_heading'                     => __( 'How to Opt Out', 'jetpack-cookie-consent' ),
400            'ccpa_browser_opt_out'             => __( 'Browser Opt-Out: Click the Opt Out button below to stop your browser from sharing this data.', 'jetpack-cookie-consent' ),
401            'ccpa_account_opt_out'             => __( 'Account Opt-Out: To apply this choice to your account, check the box and enter your email.', 'jetpack-cookie-consent' ),
402            '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' ),
403            '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' ),
404            'ccpa_button_instruction'          => __( 'Click the Opt Out button to stop this browser from sharing personal data.', 'jetpack-cookie-consent' ),
405            'ccpa_opt_out_button'              => __( 'Opt Out', 'jetpack-cookie-consent' ),
406            'ccpa_snackbar_success'            => __( 'Your browser has been successfully opted out from sharing personal data.', 'jetpack-cookie-consent' ),
407            'ccpa_snackbar_dismiss_label'      => __( 'Dismiss', 'jetpack-cookie-consent' ),
408        );
409    }
410
411    /**
412     * Get the default consent category registry.
413     *
414     * Category keys are the internal/persistence names. The frontend keeps the
415     * existing `required` and `advertising` aliases for the default `functional`
416     * and `marketing` categories.
417     *
418     * @param array|null $copy Optional normalized copy array.
419     * @return array Default consent categories.
420     */
421    private static function default_consent_categories( $copy = null ) {
422        $copy = is_array( $copy ) ? self::normalize_copy( $copy, self::defaults_copy() ) : self::defaults_copy();
423
424        return array(
425            array(
426                'key'             => 'functional',
427                'label'           => $copy['required_category_label'],
428                'description'     => $copy['required_category_description'],
429                'required'        => true,
430                'default_checked' => true,
431                'wp_consent_map'  => array( 'functional' ),
432            ),
433            array(
434                'key'             => 'analytics',
435                'label'           => $copy['analytics_category_label'],
436                'description'     => $copy['analytics_category_description'],
437                'required'        => false,
438                'default_checked' => true,
439                'wp_consent_map'  => array( 'statistics', 'statistics-anonymous' ),
440            ),
441            array(
442                'key'             => 'marketing',
443                'label'           => $copy['advertising_category_label'],
444                'description'     => $copy['advertising_category_description'],
445                'required'        => false,
446                'default_checked' => false,
447                'wp_consent_map'  => array( 'marketing' ),
448            ),
449        );
450    }
451
452    /**
453     * Normalize configured copy by merging consumer overrides with defaults.
454     *
455     * @param array $copy     Copy values supplied by configuration.
456     * @param array $defaults Default copy values.
457     * @return array Normalized copy.
458     */
459    private static function normalize_copy( $copy, $defaults ) {
460        if ( ! is_array( $copy ) ) {
461            return $defaults;
462        }
463
464        foreach ( $copy as $key => $value ) {
465            if ( ! is_string( $key ) ) {
466                continue;
467            }
468
469            if ( is_scalar( $value ) ) {
470                $defaults[ $key ] = (string) $value;
471                continue;
472            }
473
474            // A non-scalar override can't render, so the default is kept. Surface it
475            // so an integrating developer notices the override was dropped instead of
476            // silently shipping the default copy.
477            _doing_it_wrong(
478                __METHOD__,
479                /* translators: %s is the copy configuration key. */
480                esc_html( sprintf( __( 'Cookie consent copy override for "%s" was ignored because it is not a scalar value.', 'jetpack-cookie-consent' ), $key ) ),
481                ''
482            );
483        }
484
485        return $defaults;
486    }
487
488    /**
489     * Normalize configured consent categories.
490     *
491     * @param array $categories Categories supplied by configuration.
492     * @param array $defaults   Default categories.
493     * @return array Normalized categories.
494     */
495    private static function normalize_consent_categories( $categories, $defaults ) {
496        if ( ! is_array( $categories ) ) {
497            return $defaults;
498        }
499
500        $normalized           = array();
501        $seen_keys            = array();
502        $seen_preference_keys = array();
503
504        // The frontend aliases the default `functional`/`marketing` categories to the
505        // `required`/`advertising` preference keys, so those alias strings are reserved.
506        // A consumer category whose key (or derived preference key) collides with one
507        // would otherwise silently overwrite a built-in category's consent state.
508        $reserved_keys = array( 'required', 'advertising' );
509
510        foreach ( $categories as $category ) {
511            if ( ! is_array( $category ) || empty( $category['key'] ) ) {
512                continue;
513            }
514
515            $key = self::normalize_consent_category_key( $category['key'] );
516            if ( '' === $key || isset( $seen_keys[ $key ] ) ) {
517                continue;
518            }
519
520            $preference_key = Cookie_Consent::get_category_preference_key( $key );
521            if ( in_array( $key, $reserved_keys, true ) || isset( $seen_preference_keys[ $preference_key ] ) ) {
522                _doing_it_wrong(
523                    __METHOD__,
524                    /* translators: %s is the consent category key. */
525                    esc_html( sprintf( __( 'Cookie consent category "%s" was ignored because it collides with a reserved preference key.', 'jetpack-cookie-consent' ), $key ) ),
526                    ''
527                );
528                continue;
529            }
530
531            $wp_consent_map = array();
532            if ( isset( $category['wp_consent_map'] ) && is_array( $category['wp_consent_map'] ) ) {
533                foreach ( $category['wp_consent_map'] as $wp_consent_key ) {
534                    $wp_consent_key = sanitize_key( $wp_consent_key );
535                    if ( '' !== $wp_consent_key && ! in_array( $wp_consent_key, $wp_consent_map, true ) ) {
536                        $wp_consent_map[] = $wp_consent_key;
537                    }
538                }
539            }
540
541            if ( empty( $wp_consent_map ) ) {
542                $wp_consent_map = array( $key );
543            }
544
545            $required = ! empty( $category['required'] );
546
547            $normalized[] = array(
548                'key'             => $key,
549                'label'           => isset( $category['label'] ) && is_scalar( $category['label'] ) ? (string) $category['label'] : $key,
550                'description'     => isset( $category['description'] ) && is_scalar( $category['description'] ) ? (string) $category['description'] : '',
551                'required'        => $required,
552                'default_checked' => $required || ! empty( $category['default_checked'] ),
553                'wp_consent_map'  => $wp_consent_map,
554            );
555
556            $seen_keys[ $key ]                       = true;
557            $seen_preference_keys[ $preference_key ] = true;
558        }
559
560        return empty( $normalized ) ? $defaults : $normalized;
561    }
562
563    /**
564     * Normalize a consent category key for PHP arrays and Interactivity paths.
565     *
566     * @param mixed $key Consent category key.
567     * @return string Normalized key.
568     */
569    private static function normalize_consent_category_key( $key ) {
570        $key = sanitize_key( (string) $key );
571        return preg_replace( '/[^a-z0-9_]/', '_', $key );
572    }
573
574    /**
575     * Normalize configured links by merging consumer overrides with defaults.
576     *
577     * @param array $config   Configuration array supplied through filters.
578     * @param array $defaults Default link values.
579     * @return array Normalized links.
580     */
581    private static function normalize_links( $config, $defaults ) {
582        $links = isset( $config['links'] ) && is_array( $config['links'] ) ? $config['links'] : array();
583
584        foreach ( $links as $key => $value ) {
585            if ( ! is_string( $key ) ) {
586                continue;
587            }
588
589            if ( is_scalar( $value ) ) {
590                $defaults[ $key ] = trim( (string) $value );
591            }
592        }
593
594        return $defaults;
595    }
596
597    /**
598     * Normalize GDPR country codes for case-insensitive matching.
599     *
600     * @param array $countries Country codes; non-scalar entries are dropped.
601     * @return string[] Upper-case country codes.
602     */
603    private static function normalize_gdpr_countries( $countries ) {
604        $countries = array_values( array_filter( $countries, 'is_scalar' ) );
605        return array_map(
606            static function ( $country ) {
607                return strtoupper( (string) $country );
608            },
609            $countries
610        );
611    }
612
613    /**
614     * Normalize CCPA region names for case-insensitive matching.
615     *
616     * @param array $regions Region names; non-scalar entries are dropped.
617     * @return string[] Lower-case region names.
618     */
619    private static function normalize_ccpa_regions( $regions ) {
620        $regions = array_values( array_filter( $regions, 'is_scalar' ) );
621        return array_map(
622            static function ( $region ) {
623                return strtolower( (string) $region );
624            },
625            $regions
626        );
627    }
628
629    /**
630     * Get default GDPR country list.
631     *
632     * @return string[] Country codes where opt-in consent applies.
633     */
634    private static function default_gdpr_countries() {
635        return array(
636            // European Member countries.
637            'AT', // Austria.
638            'BE', // Belgium.
639            'BG', // Bulgaria.
640            'CY', // Cyprus.
641            'CZ', // Czech Republic.
642            'DE', // Germany.
643            'DK', // Denmark.
644            'EE', // Estonia.
645            'ES', // Spain.
646            'FI', // Finland.
647            'FR', // France.
648            'GR', // Greece.
649            'HR', // Croatia.
650            'HU', // Hungary.
651            'IE', // Ireland.
652            'IT', // Italy.
653            'LT', // Lithuania.
654            'LU', // Luxembourg.
655            'LV', // Latvia.
656            'MT', // Malta.
657            'NL', // Netherlands.
658            'PL', // Poland.
659            'PT', // Portugal.
660            'RO', // Romania.
661            'SE', // Sweden.
662            'SI', // Slovenia.
663            'SK', // Slovakia.
664            'GB', // United Kingdom.
665            // Single Market Countries that GDPR applies to.
666            'CH', // Switzerland.
667            'IS', // Iceland.
668            'LI', // Liechtenstein.
669            'NO', // Norway.
670        );
671    }
672
673    /**
674     * Get default CCPA-style region list.
675     *
676     * @return string[] Lower-case region names where opt-out consent applies.
677     */
678    private static function default_ccpa_regions() {
679        return array(
680            /* US regions/states that are treated like California for Do Not Sell requests. */
681            'california',
682            'utah',
683            'virginia',
684            'colorado',
685            'connecticut',
686            'texas',
687            'tennessee',
688            'oregon',
689            'new jersey',
690            'montana',
691            'iowa',
692            'indiana',
693            'delaware',
694        );
695    }
696}