Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
49.88% covered (danger)
49.88%
212 / 425
25.00% covered (danger)
25.00%
9 / 36
CRAP
0.00% covered (danger)
0.00%
0 / 1
Cookie_Consent
50.12% covered (warning)
50.12%
212 / 423
25.00% covered (danger)
25.00%
9 / 36
2466.53
0.00% covered (danger)
0.00%
0 / 1
 init
96.88% covered (success)
96.88%
31 / 32
0.00% covered (danger)
0.00%
0 / 1
12
 deactivate
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
2
 uninstall
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 delete_ccpa_page
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 maybe_create_ccpa_page
90.62% covered (success)
90.62%
29 / 32
0.00% covered (danger)
0.00%
0 / 1
9.07
 register_ccpa_page_setting
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 ignore_geo_cookies_in_page_cache
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 ccpa_page_is_published
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 ccpa_footer_link_enabled
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 privacy_policy_is_published
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 get_ccpa_page_content
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
2.01
 exclude_ccpa_from_get_pages
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 register_footer_navigation_links
72.22% covered (warning)
72.22%
13 / 18
0.00% covered (danger)
0.00%
0 / 1
12.14
 set_footer_navigation_link_attributes
0.00% covered (danger)
0.00%
0 / 40
0.00% covered (danger)
0.00%
0 / 1
72
 add_ccpa_interactivity_directives
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
5.01
 maybe_suppress_privacy_policy_link
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 add_gdpr_manage_preferences_directives
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
30
 mark_footer_links_injected
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 should_show_fallback
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
56
 get_privacy_policy_url
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 get_ccpa_page_link
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 maybe_render_footer_links_fallback
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
20
 add_ccpa_button_directive
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 add_ccpa_group_directives
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
30
 get_log_versions
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 normalize_log_version
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 get_copy
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 get_consent_categories
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 get_current_consent_categories
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 get_category_preference_key
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 get_category_context
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 get_frontend_consent_categories
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 get_config
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 resolve_config
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 enqueue_assets
96.67% covered (success)
96.67%
58 / 60
0.00% covered (danger)
0.00%
0 / 1
5
 render_banner
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2/**
3 * Cookie Consent
4 *
5 * GDPR-compliant cookie consent controls implementation using the WordPress Interactivity API.
6 *
7 * @package automattic/jetpack-cookie-consent
8 */
9
10namespace Automattic\Jetpack\CookieConsent;
11
12use WP_HTML_Tag_Processor;
13
14if ( ! defined( 'ABSPATH' ) ) {
15    exit;
16}
17
18/**
19 * Cookie Consent class
20 */
21class Cookie_Consent {
22
23    /**
24     * Package version.
25     *
26     * @var string
27     */
28    const PACKAGE_VERSION = '0.1.0-alpha';
29
30    /**
31     * CCPA page ID option name.
32     *
33     * @var string
34     */
35    private const CCPA_PAGE_ID_OPTION = 'jetpack_cookie_consent_ccpa_page_id';
36
37    /**
38     * CCPA page created-once option name.
39     *
40     * @var string
41     */
42    private const CCPA_PAGE_CREATED_OPTION = 'jetpack_cookie_consent_ccpa_page_created';
43
44    /**
45     * CCPA opt-out page slug.
46     *
47     * @var string
48     */
49    private const CCPA_PAGE_SLUG = 'your-privacy-choices';
50
51    /**
52     * Post meta marker for CCPA pages created by this package.
53     *
54     * @var string
55     */
56    private const CCPA_PAGE_CREATED_META = '_jetpack_cookie_consent_created_ccpa_page';
57
58    /**
59     * Whether the class has been initialized.
60     *
61     * @var bool
62     */
63    private static $initialized = false;
64
65    /**
66     * Resolved configuration, stashed on first access.
67     *
68     * @var array|null
69     */
70    private static $config = null;
71
72    /**
73     * Whether the required footer legal links were injected into a footer
74     * navigation block (Block Hooks) for the current rendered response.
75     *
76     * Set true by the navigation-link render filter when one of our hooked
77     * links renders; consulted by the wp_footer fallback to avoid duplicating
78     * the links on block themes that already received them.
79     *
80     * @var bool
81     */
82    private static $footer_links_injected = false;
83
84    /**
85     * Initialize the package. Idempotent; safe to call multiple times.
86     *
87     * Public entry point a consumer calls to activate cookie consent. Resolves
88     * and stashes the configuration, bails entirely when `enabled` is false, and
89     * otherwise registers each hook slice only when its `features.*` toggle is on.
90     *
91     * The first call latches, including when `enabled` is false: a disabled init is a
92     * sticky no-op, so a later caller cannot re-enable the feature within the same
93     * request. Only deactivate() clears the latch.
94     *
95     * @param array $config Partial consumer config, resolved via Config_Schema.
96     */
97    public static function init( array $config = array() ) {
98        if ( self::$initialized ) {
99            return;
100        }
101        self::$initialized = true;
102        $resolved          = self::resolve_config( $config );
103        self::$config      = $resolved;
104        if ( empty( $resolved['enabled'] ) ) {
105            return;
106        }
107
108        $features = $resolved['features'];
109
110        // Gate each shared frontend resource on every feature that needs it, not on the
111        // banner alone — otherwise disabling the banner would break the CCPA opt-out button
112        // and the footer "Manage Privacy Preferences" link, which reuse the same module and
113        // modal. The consent-log route stays gated on `consent_log`; the frontend skips its
114        // POST when that's off (see logger.ts).
115        $needs_module = $features['banner'] || $features['ccpa_page'] || $features['footer_links'];
116        $needs_modal  = $features['banner'] || $features['footer_links'];
117
118        if ( $needs_module ) {
119            add_action( 'wp_enqueue_scripts', array( __CLASS__, 'enqueue_assets' ) );
120        }
121
122        if ( $needs_modal ) {
123            // Also rendered for footer-links-only sites so the manage-preferences link can
124            // reopen the modal; it only auto-shows when the banner feature is on (view.ts).
125            add_action( 'wp_footer', array( __CLASS__, 'render_banner' ), 999 );
126        }
127
128        if ( $features['footer_links'] ) {
129            // Classic-theme (and footer-nav-less block theme) fallback for the required
130            // legal links. Runs late so any Block Hooks injection has already happened
131            // and flipped self::$footer_links_injected.
132            add_action( 'wp_footer', array( __CLASS__, 'maybe_render_footer_links_fallback' ), 999 );
133
134            // Hook Privacy Policy and CCPA links into navigation blocks using Block Hooks API.
135            add_filter( 'hooked_block_types', array( __CLASS__, 'register_footer_navigation_links' ), 10, 4 );
136            add_filter( 'hooked_block_core/navigation-link', array( __CLASS__, 'set_footer_navigation_link_attributes' ), 10, 4 );
137            add_filter( 'render_block_core/navigation-link', array( __CLASS__, 'maybe_suppress_privacy_policy_link' ), 10, 2 );
138            add_filter( 'render_block_core/navigation-link', array( __CLASS__, 'add_gdpr_manage_preferences_directives' ), 10, 2 );
139            // Flag that the required links were injected into a footer nav (covers all
140            // three, including the Privacy Policy link which has no directive filter),
141            // so the wp_footer fallback does not duplicate them.
142            add_filter( 'render_block_core/navigation-link', array( __CLASS__, 'mark_footer_links_injected' ), 10, 2 );
143        }
144
145        if ( $features['ccpa_page'] ) {
146            // Create the CCPA opt-out page once (guarded), now that the Atomic
147            // garden_site_provisioning hook is no longer available in this context.
148            add_action( 'init', array( __CLASS__, 'maybe_create_ccpa_page' ) );
149            add_filter( 'render_block_core/navigation-link', array( __CLASS__, 'add_ccpa_interactivity_directives' ), 10, 2 );
150
151            // Add Interactivity API directive to CCPA opt-out button.
152            add_filter( 'render_block_core/button', array( __CLASS__, 'add_ccpa_button_directive' ), 10, 2 );
153
154            // Add Interactivity API directives to CCPA group block and inject snackbar.
155            add_filter( 'render_block_core/group', array( __CLASS__, 'add_ccpa_group_directives' ), 10, 2 );
156
157            // Exclude CCPA page from page-list block using get_pages filter.
158            add_filter( 'get_pages', array( __CLASS__, 'exclude_ccpa_from_get_pages' ), 10, 2 );
159
160            // Register the CCPA page id setting for REST access.
161            add_action( 'rest_api_init', array( __CLASS__, 'register_ccpa_page_setting' ) );
162        }
163
164        if ( $features['geo'] ) {
165            // Keep the geolocation cookies out of Jetpack Boost's page-cache key.
166            add_filter( 'jetpack_boost_ignore_cookies', array( __CLASS__, 'ignore_geo_cookies_in_page_cache' ) );
167        }
168
169        if ( $features['consent_log'] ) {
170            // Consent log REST controller: table, cron cleanup, routes.
171            Consent_Log_Controller::init( $resolved['log'] );
172        }
173    }
174
175    /**
176     * Deactivate Cookie Consent runtime hooks and scheduled events.
177     *
178     * Consumers should call this from their plugin deactivation hook. This keeps
179     * stored consent logs, CCPA page content, and options intact so a later
180     * reactivation can resume without data loss.
181     *
182     * @since 0.1.0
183     */
184    public static function deactivate() {
185        // These mirror the full set of hooks init() can register across every
186        // features.* toggle, and matter only when a consumer deactivates within
187        // the same request (e.g. a runtime toggle). Removing a hook that a gated
188        // init() never added is a harmless no-op, so this list stays unconditional
189        // rather than re-deriving which toggles were on. The standard
190        // register_deactivation_hook path runs in a separate admin request where
191        // these hooks never fire, so the durable cleanup is the cron unschedule and
192        // setting unregister below. Keep this list in sync with init() whenever a
193        // hook is added there.
194        remove_action( 'wp_enqueue_scripts', array( __CLASS__, 'enqueue_assets' ) );
195        remove_action( 'wp_footer', array( __CLASS__, 'render_banner' ), 999 );
196        remove_action( 'wp_footer', array( __CLASS__, 'maybe_render_footer_links_fallback' ), 999 );
197        remove_action( 'init', array( __CLASS__, 'maybe_create_ccpa_page' ) );
198
199        remove_filter( 'hooked_block_types', array( __CLASS__, 'register_footer_navigation_links' ), 10 );
200        remove_filter( 'hooked_block_core/navigation-link', array( __CLASS__, 'set_footer_navigation_link_attributes' ), 10 );
201        remove_filter( 'render_block_core/navigation-link', array( __CLASS__, 'add_ccpa_interactivity_directives' ), 10 );
202        remove_filter( 'render_block_core/navigation-link', array( __CLASS__, 'maybe_suppress_privacy_policy_link' ), 10 );
203        remove_filter( 'render_block_core/navigation-link', array( __CLASS__, 'add_gdpr_manage_preferences_directives' ), 10 );
204        remove_filter( 'render_block_core/navigation-link', array( __CLASS__, 'mark_footer_links_injected' ), 10 );
205        remove_filter( 'render_block_core/button', array( __CLASS__, 'add_ccpa_button_directive' ), 10 );
206        remove_filter( 'render_block_core/group', array( __CLASS__, 'add_ccpa_group_directives' ), 10 );
207        remove_filter( 'get_pages', array( __CLASS__, 'exclude_ccpa_from_get_pages' ), 10 );
208        remove_action( 'rest_api_init', array( __CLASS__, 'register_ccpa_page_setting' ) );
209        remove_filter( 'jetpack_boost_ignore_cookies', array( __CLASS__, 'ignore_geo_cookies_in_page_cache' ), 10 );
210
211        if ( function_exists( 'unregister_setting' ) ) {
212            unregister_setting( 'general', self::CCPA_PAGE_ID_OPTION );
213        }
214
215        Consent_Log_Controller::deactivate();
216        self::$initialized = false;
217    }
218
219    /**
220     * Delete Cookie Consent artifacts owned by this package.
221     *
222     * Consumers should call this from their plugin uninstall hook. Consent logs
223     * are retained by default because they may be compliance records; pass true
224     * only when the consuming plugin has decided uninstall should delete them.
225     *
226     * @since 0.1.0
227     *
228     * @param bool $delete_consent_logs Whether to drop the consent-log table.
229     */
230    public static function uninstall( $delete_consent_logs = false ) {
231        self::deactivate();
232        self::delete_ccpa_page();
233        Consent_Log_Controller::uninstall( $delete_consent_logs );
234    }
235
236    /**
237     * Delete the configured CCPA page and clear the CCPA options.
238     */
239    private static function delete_ccpa_page() {
240        $page_id = (int) get_option( self::CCPA_PAGE_ID_OPTION );
241
242        if ( $page_id ) {
243            $page = get_post( $page_id );
244            if (
245                $page
246                && 'page' === $page->post_type
247                && get_post_meta( $page_id, self::CCPA_PAGE_CREATED_META, true )
248            ) {
249                wp_delete_post( $page_id, true );
250            }
251        }
252
253        delete_option( self::CCPA_PAGE_ID_OPTION );
254        delete_option( self::CCPA_PAGE_CREATED_OPTION );
255    }
256
257    /**
258     * Create the CCPA opt-out page at most once per site.
259     *
260     * Once the created-once flag is set, the page is never recreated — even if
261     * the site owner later deletes it.
262     */
263    public static function maybe_create_ccpa_page() {
264        // Create the page at most once per site. Once we have created or adopted
265        // a page, never recreate it — even if the owner later deletes it.
266        if ( get_option( self::CCPA_PAGE_CREATED_OPTION ) ) {
267            return;
268        }
269
270        // A page already exists for the stored ID: record that and stop.
271        $page_id = get_option( self::CCPA_PAGE_ID_OPTION );
272        if ( $page_id && get_post( $page_id ) ) {
273            update_option( self::CCPA_PAGE_CREATED_OPTION, 1 );
274            return;
275        }
276
277        // Stale ID (option set but post gone): clear it before trying the slug.
278        if ( $page_id && ! get_post( $page_id ) ) {
279            delete_option( self::CCPA_PAGE_ID_OPTION );
280        }
281
282        // Fallback: adopt an existing page by slug (backwards compatibility).
283        $page_slug = self::CCPA_PAGE_SLUG;
284        $page      = get_page_by_path( $page_slug );
285        if ( $page ) {
286            update_option( self::CCPA_PAGE_ID_OPTION, $page->ID );
287            update_option( self::CCPA_PAGE_CREATED_OPTION, 1 );
288            return;
289        }
290
291        // Build the page content with blocks.
292        $content = self::get_ccpa_page_content();
293        $config  = self::get_config();
294        $copy    = $config['copy'];
295
296        // Create the page in a single call so the created-once flag is only set
297        // once the full page (with content) has actually persisted. A two-step
298        // insert-then-update would latch the flag before the content write, and a
299        // failed update would leave a permanently published, empty page.
300        $page_id = wp_insert_post(
301            array(
302                'post_title'     => $copy['ccpa_page_title'],
303                'post_name'      => $page_slug,
304                'post_status'    => 'publish',
305                'post_type'      => 'page',
306                'post_content'   => $content,
307                'comment_status' => 'closed',
308                'ping_status'    => 'closed',
309            )
310        );
311
312        // Store the page ID and mark as created.
313        if ( $page_id && ! is_wp_error( $page_id ) ) {
314            update_post_meta( $page_id, self::CCPA_PAGE_CREATED_META, 1 );
315            update_option( self::CCPA_PAGE_ID_OPTION, $page_id );
316            update_option( self::CCPA_PAGE_CREATED_OPTION, 1 );
317        }
318    }
319
320    /**
321     * Register the CCPA page id setting for REST API access.
322     */
323    public static function register_ccpa_page_setting() {
324        register_setting(
325            'general',
326            self::CCPA_PAGE_ID_OPTION,
327            array(
328                'show_in_rest' => true,
329                'type'         => 'integer',
330                'description'  => __( 'CCPA opt-out page ID', 'jetpack-cookie-consent' ),
331            )
332        );
333    }
334
335    /**
336     * Exclude the geolocation cookies from Jetpack Boost's page-cache key.
337     *
338     * Geo is resolved client-side, so the server response is identical for every
339     * visitor. Boost keys its cache on the full cookie set, so leaving the
340     * `country_code`/`region` cookies in would spawn a separate cache file per
341     * region and make returning visitors miss the cache. They never affect the
342     * server-rendered HTML, so it is safe to ignore them.
343     *
344     * @param array $cookies Regex patterns Boost removes from the cache key.
345     * @return array Patterns with the geolocation cookies appended.
346     */
347    public static function ignore_geo_cookies_in_page_cache( $cookies ) {
348        $config       = self::get_config();
349        $country_code = $config['geo']['country_code_cookie'];
350        $region       = $config['geo']['region_cookie'];
351        $cookies[]    = preg_quote( $country_code, '/' );
352        $cookies[]    = preg_quote( $region, '/' );
353
354        return $cookies;
355    }
356
357    /**
358     * Whether the auto-created CCPA page exists and is published.
359     *
360     * A trashed page still resolves via get_post(), so callers must check the
361     * status too — otherwise links would point at a trashed page and 404.
362     *
363     * @return bool
364     */
365    private static function ccpa_page_is_published() {
366        $ccpa_page_id = get_option( self::CCPA_PAGE_ID_OPTION );
367        if ( ! $ccpa_page_id ) {
368            return false;
369        }
370        $page = get_post( $ccpa_page_id );
371        return $page && 'publish' === $page->post_status;
372    }
373
374    /**
375     * Whether the CCPA "Your Privacy Choices" footer link should be surfaced.
376     *
377     * The opt-out page must be published AND the `ccpa_page` feature on. The link's
378     * region-gating interactivity directives (add_ccpa_interactivity_directives), its
379     * page-list exclusion (exclude_ccpa_from_get_pages), and the trashed-page 404 guard
380     * are all registered only inside init()'s ccpa_page branch, so injecting the link
381     * while that feature is off would render an unguarded, always-visible dead link.
382     *
383     * @return bool
384     */
385    private static function ccpa_footer_link_enabled() {
386        return self::ccpa_page_is_published() && ! empty( self::get_config()['features']['ccpa_page'] );
387    }
388
389    /**
390     * Whether the site's Privacy Policy page exists and is published.
391     *
392     * Mirrors ccpa_page_is_published(): a trashed page still resolves via
393     * get_post(), so the status must be checked too.
394     *
395     * @return bool
396     */
397    private static function privacy_policy_is_published() {
398        $privacy_policy_page_id = (int) get_option( 'wp_page_for_privacy_policy' );
399        if ( ! $privacy_policy_page_id ) {
400            return false;
401        }
402        $page = get_post( $privacy_policy_page_id );
403        return $page && 'publish' === $page->post_status;
404    }
405
406    /**
407     * Get CCPA page content in block format
408     *
409     * @return string Page content with WordPress blocks
410     */
411    private static function get_ccpa_page_content() {
412        $template_path = plugin_dir_path( __FILE__ ) . 'ccpa-content.php';
413
414        if ( ! file_exists( $template_path ) ) {
415            return '';
416        }
417
418        ob_start();
419        // Get config for template (consumed by ccpa-content.php via include scope). $copy is
420        // pre-resolved here so the template can use it directly instead of re-resolving via
421        // get_copy() — the config is already stashed and fully normalized at this point.
422        $config = self::get_config(); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
423        $copy   = $config['copy']; // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
424        include $template_path;
425        return ob_get_clean();
426    }
427
428    /**
429     * Exclude CCPA page from get_pages() results
430     *
431     * @param \WP_Post[] $pages Array of page objects.
432     * @return \WP_Post[] Filtered array of page objects.
433     */
434    public static function exclude_ccpa_from_get_pages( $pages ) {
435        if ( is_admin() ) {
436            // In admin, include the CCPA page in the results.
437            return $pages;
438        }
439
440        $ccpa_page_id = get_option( self::CCPA_PAGE_ID_OPTION );
441
442        if ( ! $ccpa_page_id || empty( $pages ) ) {
443            return $pages;
444        }
445
446        // Filter out the CCPA page from the results.
447        return array_filter(
448            $pages,
449            function ( $page ) use ( $ccpa_page_id ) {
450                return (int) $page->ID !== (int) $ccpa_page_id;
451            }
452        );
453    }
454
455    /**
456     * Register Privacy Policy and CCPA navigation links as hooked blocks
457     *
458     * @param array                    $hooked_block_types Array of hooked block types.
459     * @param string                   $relative_position  The relative position.
460     * @param string                   $anchor_block_type  The anchor block type.
461     * @param \WP_Block_Template|array $context            The block template, template part, or pattern.
462     * @return array Modified array of hooked block types.
463     */
464    public static function register_footer_navigation_links( $hooked_block_types, $relative_position, $anchor_block_type, $context ) {
465        // Only hook to navigation blocks.
466        if ( 'core/navigation' !== $anchor_block_type ) {
467            return $hooked_block_types;
468        }
469
470        // Only hook as last child.
471        if ( 'last_child' !== $relative_position ) {
472            return $hooked_block_types;
473        }
474
475        // Only hook in footer template parts.
476        if ( ! is_array( $context ) || ! isset( $context['slug'] ) || ! str_contains( $context['slug'], '/footer' ) ) {
477            return $hooked_block_types;
478        }
479
480        // Check which pages exist and register appropriate blocks.
481        $privacy_policy_exists = false;
482        $ccpa_page_exists      = false;
483
484        // Check Privacy Policy page (must be published — a trashed page still resolves).
485        if ( self::privacy_policy_is_published() ) {
486            $privacy_policy_exists = true;
487        }
488
489        // Check CCPA page (must be published — a trashed page still resolves — and the
490        // ccpa_page feature on, which owns the link's directives and page-list exclusion).
491        if ( self::ccpa_footer_link_enabled() ) {
492            $ccpa_page_exists = true;
493        }
494
495        // Register one block for each page that exists.
496        if ( $privacy_policy_exists ) {
497            $hooked_block_types[] = 'core/navigation-link';
498        }
499        if ( $ccpa_page_exists ) {
500            $hooked_block_types[] = 'core/navigation-link';
501        }
502
503        // Register GDPR "Manage Privacy Preferences" link (visibility handled client-side).
504        $hooked_block_types[] = 'core/navigation-link';
505
506        return $hooked_block_types;
507    }
508
509    /**
510     * Set attributes for hooked footer navigation links (Privacy Policy and CCPA)
511     *
512     * @param array|null $hooked_block The hooked block, or null to suppress.
513     * @return array|null Modified hooked block.
514     */
515    public static function set_footer_navigation_link_attributes( $hooked_block ) {
516        // Track which blocks we've processed.
517        static $privacy_policy_processed     = false;
518        static $ccpa_processed               = false;
519        static $manage_preferences_processed = false;
520
521        // Has the hooked block been suppressed by a previous filter?
522        if ( null === $hooked_block ) {
523            return $hooked_block;
524        }
525
526        // If another filter already set metadata, skip this block.
527        if ( isset( $hooked_block['attrs']['metadata']['name'] ) ) {
528            return $hooked_block;
529        }
530
531        // Check which pages exist (same logic as registration).
532        $privacy_policy_page_id = (int) get_option( 'wp_page_for_privacy_policy' );
533        $privacy_policy_exists  = self::privacy_policy_is_published();
534
535        $ccpa_page_exists = self::ccpa_footer_link_enabled();
536        $ccpa_page_id     = get_option( self::CCPA_PAGE_ID_OPTION );
537
538        // Process Privacy Policy first (if it exists and hasn't been processed).
539        if ( $privacy_policy_exists && ! $privacy_policy_processed ) {
540            $hooked_block['attrs']['url']      = get_permalink( $privacy_policy_page_id );
541            $hooked_block['attrs']['label']    = get_the_title( $privacy_policy_page_id );
542            $hooked_block['attrs']['kind']     = 'custom';
543            $hooked_block['attrs']['metadata'] = array(
544                'name' => 'jetpack-cookie-consent-privacy-policy-link',
545            );
546            $privacy_policy_processed          = true;
547            return $hooked_block;
548        }
549
550        // Process CCPA next (if it exists and hasn't been processed).
551        if ( $ccpa_page_exists && ! $ccpa_processed ) {
552            $hooked_block['attrs']['url']      = get_permalink( $ccpa_page_id );
553            $hooked_block['attrs']['label']    = get_the_title( $ccpa_page_id );
554            $hooked_block['attrs']['kind']     = 'custom';
555            $hooked_block['attrs']['metadata'] = array(
556                'name' => 'jetpack-cookie-consent-ccpa-privacy-link',
557            );
558            $ccpa_processed                    = true;
559            return $hooked_block;
560        }
561
562        // Process GDPR "Manage Privacy Preferences" link last.
563        if ( ! $manage_preferences_processed ) {
564            $config                            = self::get_config();
565            $hooked_block['attrs']['url']      = '#manage-preferences';
566            $hooked_block['attrs']['label']    = $config['copy']['manage_preferences_link'];
567            $hooked_block['attrs']['kind']     = 'custom';
568            $hooked_block['attrs']['metadata'] = array(
569                'name' => 'jetpack-cookie-consent-gdpr-manage-preferences-link',
570            );
571            $manage_preferences_processed      = true;
572            return $hooked_block;
573        }
574
575        // If we get here, all have been processed or don't exist.
576        return $hooked_block;
577    }
578
579    /**
580     * Add Interactivity API directives to the CCPA navigation link for conditional rendering
581     *
582     * @param string $block_content The block content.
583     * @param array  $block         The full block, including name and attributes.
584     * @return string Modified block content.
585     */
586    public static function add_ccpa_interactivity_directives( $block_content, $block ) {
587        // Check if this is our CCPA link by looking for our metadata marker.
588        if ( ! isset( $block['attrs']['metadata']['name'] ) || 'jetpack-cookie-consent-ccpa-privacy-link' !== $block['attrs']['metadata']['name'] ) {
589            return $block_content;
590        }
591
592        // If the CCPA page is gone or unpublished (e.g. the owner trashed or
593        // deleted it), suppress the link instead of rendering a dead 404. This
594        // covers persisted hooked links that bypass the injection-time gate.
595        if ( ! self::ccpa_page_is_published() ) {
596            return '';
597        }
598
599        // Use WP_HTML_Tag_Processor to safely add Interactivity API directives.
600        $tags = new WP_HTML_Tag_Processor( $block_content );
601
602        // Find the first <li> tag (the navigation item wrapper).
603        if ( $tags->next_tag( 'li' ) ) {
604            // Add Interactivity API directives to the <li>.
605            $tags->set_attribute( 'data-wp-interactive', 'jetpack/cookie-consent' );
606            $tags->set_attribute( 'data-wp-context', '{"isCcpaRegion": false}' );
607            $tags->set_attribute( 'data-wp-class--jetpack-cookie-consent-ccpa-privacy-link-hidden', '!state.isCcpaRegion' );
608            $tags->set_attribute( 'data-wp-init', 'callbacks.init' );
609
610            return $tags->get_updated_html();
611        }
612
613        return $block_content;
614    }
615
616    /**
617     * Suppress the hooked Privacy Policy link when its page is gone or unpublished.
618     *
619     * Making the Privacy Policy page deletable (the deletion lock was removed)
620     * means a persisted, Site-Editor-materialized footer link can outlive the
621     * page and 404. The injection-time gate only runs for non-persisted hooks, so
622     * this render-time guard mirrors the CCPA one to avoid a dead link.
623     *
624     * @param string $block_content The block content.
625     * @param array  $block         The full block, including name and attributes.
626     * @return string Modified block content.
627     */
628    public static function maybe_suppress_privacy_policy_link( $block_content, $block ) {
629        // Only act on our hooked Privacy Policy link.
630        if ( ! isset( $block['attrs']['metadata']['name'] ) || 'jetpack-cookie-consent-privacy-policy-link' !== $block['attrs']['metadata']['name'] ) {
631            return $block_content;
632        }
633
634        if ( ! self::privacy_policy_is_published() ) {
635            return '';
636        }
637
638        return $block_content;
639    }
640
641    /**
642     * Add Interactivity API directives to the GDPR "Manage Privacy Preferences" link
643     *
644     * @param string $block_content The block content.
645     * @param array  $block         The full block, including name and attributes.
646     * @return string Modified block content.
647     */
648    public static function add_gdpr_manage_preferences_directives( $block_content, $block ) {
649        // Check if this is our manage preferences link by looking for our metadata marker.
650        if ( ! isset( $block['attrs']['metadata']['name'] ) || 'jetpack-cookie-consent-gdpr-manage-preferences-link' !== $block['attrs']['metadata']['name'] ) {
651            return $block_content;
652        }
653
654        // Use WP_HTML_Tag_Processor to safely add Interactivity API directives.
655        $tags = new WP_HTML_Tag_Processor( $block_content );
656
657        // Find the first <li> tag (the navigation item wrapper).
658        if ( $tags->next_tag( 'li' ) ) {
659            // Add Interactivity API directives to the <li>.
660            $tags->set_attribute( 'data-wp-interactive', 'jetpack/cookie-consent' );
661            $tags->set_attribute( 'data-wp-context', '{"isGdprManageLink": false}' );
662            $tags->set_attribute( 'data-wp-class--jetpack-cookie-consent-gdpr-manage-link-hidden', '!context.isGdprManageLink' );
663            $tags->set_attribute( 'data-wp-init', 'callbacks.init' );
664            $tags->add_class( 'jetpack-cookie-consent-gdpr-manage-link-hidden' );
665        }
666
667        // Find the <a> tag and add click handler.
668        if ( $tags->next_tag( 'a' ) ) {
669            $tags->set_attribute( 'data-wp-on--click', 'actions.openManagePreferences' );
670        }
671
672        return $tags->get_updated_html();
673    }
674
675    /**
676     * Mark that one of our required footer legal links was injected into a
677     * footer navigation block, so the wp_footer fallback does not duplicate it.
678     *
679     * Keyed on the metadata.name set by set_footer_navigation_link_attributes().
680     * Covers the Privacy Policy link too, which has no directive filter of its own.
681     *
682     * @param string $block_content The block content.
683     * @param array  $block         The full block, including name and attributes.
684     * @return string Unmodified block content.
685     */
686    public static function mark_footer_links_injected( $block_content, $block ) {
687        if ( ! isset( $block['attrs']['metadata']['name'] ) ) {
688            return $block_content;
689        }
690
691        $names = array(
692            'jetpack-cookie-consent-privacy-policy-link',
693            'jetpack-cookie-consent-ccpa-privacy-link',
694            'jetpack-cookie-consent-gdpr-manage-preferences-link',
695        );
696
697        if ( in_array( $block['attrs']['metadata']['name'], $names, true ) ) {
698            self::$footer_links_injected = true;
699        }
700
701        return $block_content;
702    }
703
704    /**
705     * Decide whether the floating footer-links fallback should render.
706     *
707     * Minimal gate modeled on Jetpack_Subscribe_Floating_Button::should_user_see_floating_button():
708     * frontend only, never on 404, and never in customizer/theme/post previews.
709     *
710     * @return bool True when the fallback control should be rendered.
711     */
712    private static function should_show_fallback() {
713        if ( is_admin() ) {
714            return false;
715        }
716
717        if ( is_404() ) {
718            return false;
719        }
720
721        // Don't show in customizer/theme/post previews.
722        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
723        if ( isset( $_GET['preview'] ) || isset( $_GET['theme_preview'] ) || isset( $_GET['customize_preview'] ) || isset( $_GET['hide_banners'] ) ) {
724            return false;
725        }
726
727        return true;
728    }
729
730    /**
731     * Get the published Privacy Policy page URL, or '' when none exists.
732     *
733     * @return string Privacy Policy permalink or empty string.
734     */
735    private static function get_privacy_policy_url() {
736        $privacy_policy_page_id = (int) get_option( 'wp_page_for_privacy_policy' );
737        if ( ! $privacy_policy_page_id ) {
738            return '';
739        }
740
741        $page = get_post( $privacy_policy_page_id );
742        if ( ! $page || 'publish' !== $page->post_status ) {
743            return '';
744        }
745
746        return (string) get_permalink( $privacy_policy_page_id );
747    }
748
749    /**
750     * Get the CCPA opt-out page URL and title, or empty strings when none exists.
751     *
752     * Mirrors the Block Hooks footer nav-link, which uses the CCPA page's own
753     * title (e.g. "Your Privacy Choices") as the link label.
754     *
755     * @return array{url: string, label: string} CCPA page URL and label.
756     */
757    private static function get_ccpa_page_link() {
758        if ( ! self::ccpa_page_is_published() ) {
759            return array(
760                'url'   => '',
761                'label' => '',
762            );
763        }
764
765        $ccpa_page_id = get_option( self::CCPA_PAGE_ID_OPTION );
766
767        return array(
768            'url'   => (string) get_permalink( $ccpa_page_id ),
769            'label' => (string) get_the_title( $ccpa_page_id ),
770        );
771    }
772
773    /**
774     * Render the classic-theme fallback for the required legal links.
775     *
776     * Bails when the Block Hooks footer-nav injection already ran for this
777     * response (self::$footer_links_injected) or when the visibility gate is
778     * false. Otherwise builds a fixed-corner floating control, processes its
779     * Interactivity directives, and echoes it — same pattern as render_banner().
780     */
781    public static function maybe_render_footer_links_fallback() {
782        if ( self::$footer_links_injected ) {
783            return;
784        }
785
786        if ( ! self::should_show_fallback() ) {
787            return;
788        }
789
790        $template_path = plugin_dir_path( __FILE__ ) . 'footer-links-fallback.php';
791        if ( ! file_exists( $template_path ) ) {
792            return;
793        }
794
795        // Resolve which links to show (same checks the Block Hooks path uses).
796        // These are consumed by footer-links-fallback.php via the include scope.
797        $privacy_policy_url = self::get_privacy_policy_url(); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
798        $ccpa_link          = self::get_ccpa_page_link();
799        $ccpa_url           = $ccpa_link['url']; // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
800        $ccpa_label         = $ccpa_link['label']; // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
801
802        ob_start();
803        include $template_path;
804        $html = ob_get_clean();
805
806        // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
807        echo wp_interactivity_process_directives( $html );
808    }
809
810    /**
811     * Add Interactivity API directive to CCPA opt-out button
812     *
813     * @param string $block_content The block content.
814     * @param array  $block         The full block, including name and attributes.
815     * @return string Modified block content.
816     */
817    public static function add_ccpa_button_directive( $block_content, $block ) {
818        // Check if this button has the CCPA opt-out class.
819        if ( ! isset( $block['attrs']['className'] ) || false === strpos( $block['attrs']['className'], 'jetpack-cookie-consent-ccpa-opt-out-button' ) ) {
820            return $block_content;
821        }
822
823        // Use WP_HTML_Tag_Processor to safely add the directive.
824        $tags = new WP_HTML_Tag_Processor( $block_content );
825
826        // Find the button element with the wp-block-button__link class.
827        if ( $tags->next_tag( array( 'class_name' => 'wp-block-button__link' ) ) ) {
828            $tags->set_attribute( 'data-wp-on--click', 'actions.optOut' );
829            return $tags->get_updated_html();
830        }
831
832        return $block_content;
833    }
834
835    /**
836     * Add Interactivity API directives to CCPA group block and inject snackbar
837     *
838     * @param string $block_content The block content.
839     * @param array  $block         The full block, including name and attributes.
840     * @return string Modified block content.
841     */
842    public static function add_ccpa_group_directives( $block_content, $block ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
843        // Check if this group block has the CCPA opt-out section class.
844        if ( ! isset( $block['attrs']['className'] ) || false === strpos( $block['attrs']['className'], 'jetpack-cookie-consent-ccpa-opt-out-section' ) ) {
845            return $block_content;
846        }
847
848        // Use WP_HTML_Tag_Processor to add Interactivity API directives.
849        $tags = new WP_HTML_Tag_Processor( $block_content );
850
851        // Find the group div.
852        if ( $tags->next_tag( array( 'class_name' => 'wp-block-group' ) ) ) {
853            $tags->set_attribute( 'data-wp-interactive', 'jetpack/cookie-consent' );
854            $tags->set_attribute( 'data-wp-context', '{"showSnackbar": false}' );
855            $block_content = $tags->get_updated_html();
856        }
857
858        $config = self::get_config();
859        $copy   = $config['copy'];
860
861        // Build the snackbar HTML.
862        $snackbar_html = sprintf(
863            '<div class="jetpack-cookie-consent-ccpa-snackbar" data-wp-bind--hidden="!state.showSnackbar">
864                <div class="jetpack-cookie-consent-ccpa-snackbar__content">
865                    <span>%s</span>
866                    <button type="button" class="jetpack-cookie-consent-ccpa-snackbar__dismiss" data-wp-on--click="actions.dismissSnackbar" aria-label="%s">×</button>
867                </div>
868            </div>',
869            esc_html( $copy['ccpa_snackbar_success'] ),
870            esc_attr( $copy['ccpa_snackbar_dismiss_label'] )
871        );
872
873        // Insert the snackbar inside the group block (before the closing </div>).
874        $closing_tag_pos = strrpos( $block_content, '</div>' );
875        if ( false !== $closing_tag_pos ) {
876            $block_content = substr_replace( $block_content, $snackbar_html, $closing_tag_pos, 0 );
877        }
878
879        return $block_content;
880    }
881
882    /**
883     * Get configured log versions.
884     *
885     * @return array Log version configuration.
886     */
887    public static function get_log_versions() {
888        $config     = self::get_config();
889        $log_config = isset( $config['log'] ) && is_array( $config['log'] ) ? $config['log'] : array();
890
891        return array(
892            'policy_version' => self::normalize_log_version( $log_config['policy_version'] ?? '1', 'policy_version' ),
893            'banner_version' => self::normalize_log_version( $log_config['banner_version'] ?? '1', 'banner_version' ),
894        );
895    }
896
897    /**
898     * Normalize a configured log version value for callers.
899     *
900     * @param mixed  $version Version value from config.
901     * @param string $key     Config key being normalized, used for diagnostics.
902     * @return string Non-empty log version.
903     */
904    private static function normalize_log_version( $version, $key ) {
905        if ( is_scalar( $version ) ) {
906            $normalized = sanitize_text_field( (string) $version );
907            if ( '' !== $normalized ) {
908                return $normalized;
909            }
910        }
911
912        // A supplied value that isn't a usable version string would silently become the
913        // default '1' on a proof-of-consent record, masking a misconfiguration. Surface it
914        // so an integrating developer notices the configured value was dropped.
915        _doing_it_wrong(
916            __METHOD__,
917            /* translators: %s is the log version configuration key. */
918            esc_html( sprintf( __( 'Cookie consent log version for "%s" was ignored because it is not a non-empty scalar value.', 'jetpack-cookie-consent' ), $key ) ),
919            ''
920        );
921
922        return '1';
923    }
924
925    /**
926     * Resolve UI copy for a template, backfilling any missing keys with defaults.
927     *
928     * Templates normally receive a fully normalized `copy` group from get_config(),
929     * but they can also be included directly (tests, Storybook). Routing through this
930     * helper guarantees every key is present so a partial or absent `copy` array can't
931     * cause undefined-index access. An empty (the default) $config reads the already
932     * resolved and stashed get_config() instead of re-resolving from scratch.
933     *
934     * @since 0.1.0
935     *
936     * @param array $config Configuration array, which may lack a normalized `copy` group.
937     * @return array Copy with every key present.
938     */
939    public static function get_copy( $config = array() ) {
940        if ( empty( $config ) ) {
941            return self::get_config()['copy'];
942        }
943
944        return Config_Schema::resolve( $config )['copy'];
945    }
946
947    /**
948     * Get normalized consent categories from a configuration array.
949     *
950     * An empty (the default) $config reads the already resolved and stashed
951     * get_config() instead of re-resolving from scratch.
952     *
953     * @since 0.1.0
954     *
955     * @param array $config Configuration array.
956     * @return array Normalized consent categories.
957     */
958    public static function get_consent_categories( $config = array() ) {
959        if ( empty( $config ) ) {
960            return self::get_config()['consent']['categories'];
961        }
962
963        return Config_Schema::resolve( $config )['consent']['categories'];
964    }
965
966    /**
967     * Get the active configured consent category registry.
968     *
969     * @since 0.1.0
970     *
971     * @return array Normalized configured consent categories.
972     */
973    public static function get_current_consent_categories() {
974        $config = self::get_config();
975        return $config['consent']['categories'];
976    }
977
978    /**
979     * Get the frontend preference key for a registry category key.
980     *
981     * @since 0.1.0
982     *
983     * @param string $key Registry category key.
984     * @return string Frontend preference key.
985     */
986    public static function get_category_preference_key( $key ) {
987        if ( 'functional' === $key ) {
988            return 'required';
989        }
990
991        if ( 'marketing' === $key ) {
992            return 'advertising';
993        }
994
995        return $key;
996    }
997
998    /**
999     * Get the Interactivity API category context object.
1000     *
1001     * @since 0.1.0
1002     *
1003     * @param array $categories Normalized category registry.
1004     * @return array Category choices keyed by frontend preference key.
1005     */
1006    public static function get_category_context( $categories ) {
1007        $context = array();
1008
1009        foreach ( $categories as $category ) {
1010            $preference_key             = self::get_category_preference_key( $category['key'] );
1011            $context[ $preference_key ] = $category['required'] || $category['default_checked'];
1012        }
1013
1014        return $context;
1015    }
1016
1017    /**
1018     * Convert normalized categories to the frontend config shape.
1019     *
1020     * @since 0.1.0
1021     *
1022     * @param array $categories Normalized category registry.
1023     * @return array Frontend category registry.
1024     */
1025    public static function get_frontend_consent_categories( $categories ) {
1026        $frontend_categories = array();
1027
1028        foreach ( $categories as $category ) {
1029            $frontend_categories[] = array(
1030                'key'            => $category['key'],
1031                'preferenceKey'  => self::get_category_preference_key( $category['key'] ),
1032                'required'       => (bool) $category['required'],
1033                'defaultChecked' => (bool) $category['default_checked'],
1034                'wpConsentMap'   => array_values( $category['wp_consent_map'] ),
1035            );
1036        }
1037
1038        return $frontend_categories;
1039    }
1040
1041    /**
1042     * Get package configuration, resolving and stashing it on first access.
1043     *
1044     * @internal This accessor is for package classes only and is not part of the public API.
1045     *
1046     * @return array Configuration array.
1047     */
1048    public static function get_config() {
1049        if ( null === self::$config ) {
1050            self::$config = self::resolve_config();
1051        }
1052
1053        return self::$config;
1054    }
1055
1056    /**
1057     * Resolve a partial config against the schema, let consumers filter the
1058     * result, then re-resolve so any filtered value is re-validated.
1059     *
1060     * The consuming plugin injects its config through init(); this filter is the
1061     * override point for other code on the site that does not own that init()
1062     * call. Because the filtered array is resolved a second time, unknown or
1063     * malformed values a filter returns are sanitized back to schema defaults
1064     * rather than trusted verbatim. resolve() is idempotent, so re-resolving an
1065     * unfiltered config is a no-op.
1066     *
1067     * @param array $config Partial consumer config.
1068     * @return array Fully resolved, filtered, and re-validated config.
1069     */
1070    private static function resolve_config( array $config = array() ) {
1071        $resolved = Config_Schema::resolve( $config );
1072
1073        /**
1074         * Filters the resolved Cookie Consent configuration.
1075         *
1076         * @since 0.1.0
1077         *
1078         * @param array $resolved The fully resolved configuration array.
1079         */
1080        $filtered = apply_filters( 'jetpack_cookie_consent_config', $resolved );
1081
1082        return Config_Schema::resolve( is_array( $filtered ) ? $filtered : $resolved );
1083    }
1084
1085    /**
1086     * Enqueue scripts and styles
1087     */
1088    public static function enqueue_assets() {
1089        // Don't load in admin.
1090        if ( is_admin() ) {
1091            return;
1092        }
1093
1094        $config   = self::get_config();
1095        $features = $config['features'];
1096
1097        // init() registers this only when a consent UI feature needs the runtime, so no bail
1098        // here. w.js (Tracks) is loaded by the module on the frontend, gated on consent
1099        // (tracks-utils.ts); PHP must never enqueue it here.
1100
1101        // Register and enqueue the Interactivity API script module built by webpack.
1102        // Only the build version is read from the asset file; module dependencies are
1103        // declared explicitly. Script modules may only depend on other script modules,
1104        // so the asset file's `dependencies` (which can include classic scripts such as
1105        // `wp-polyfill`) must not be forwarded to wp_register_script_module().
1106        $asset_file = __DIR__ . '/../build/modules/cookie-consent/index.asset.php';
1107        $asset      = file_exists( $asset_file ) ? require $asset_file : array();
1108        $version    = $asset['version'] ?? self::PACKAGE_VERSION;
1109        $module_url = plugins_url( 'build/modules/cookie-consent/index.js', __DIR__ );
1110        $module_id  = '@automattic/jetpack-cookie-consent';
1111
1112        wp_register_script_module(
1113            $module_id,
1114            $module_url,
1115            array( '@wordpress/interactivity' ),
1116            $version
1117        );
1118        wp_enqueue_script_module( $module_id );
1119
1120        // Enqueue the module's compiled styles.
1121        $style_url = plugins_url( 'build/modules/cookie-consent/index.css', __DIR__ );
1122        wp_enqueue_style(
1123            'jetpack-cookie-consent',
1124            $style_url,
1125            array(),
1126            $version
1127        );
1128
1129        // Resolve the configured Tracks event prefix once so it can be shared below.
1130        $frontend_categories = self::get_frontend_consent_categories( $config['consent']['categories'] );
1131
1132        $config_data = array(
1133            'apiUrl'      => rest_url( 'jetpack/v4/cookie-consent/consent-log' ),
1134            'eventPrefix' => $config['event_prefix'],
1135            'features'    => $config['features'],
1136        );
1137
1138        // Only expose a REST nonce to logged-in visitors, so the consent logger can
1139        // authenticate and record the real user_id. Anonymous visitors deliberately get
1140        // none: their pages are full-page-cached, a cached nonce would go stale and make
1141        // core reject the request (rest_cookie_invalid_nonce). Without a nonce core treats
1142        // the request as anonymous and stores user_id = 0, which is correct for them.
1143        if ( is_user_logged_in() ) {
1144            $config_data['nonce'] = wp_create_nonce( 'wp_rest' );
1145        }
1146        $config_data['categories'] = $frontend_categories;
1147
1148        // Pass REST API URL and Tracks event prefix to the module via global config.
1149        wp_print_inline_script_tag(
1150            sprintf(
1151                'window.jetpackCookieConsentConfig = %s;',
1152                wp_json_encode(
1153                    $config_data,
1154                    JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT
1155                )
1156            ),
1157            array( 'id' => 'jetpack-cookie-consent-config' )
1158        );
1159
1160        // Check for preview query parameter.
1161        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
1162        $force_preview = isset( $_GET['preview_cookie_consent'] ) && '1' === $_GET['preview_cookie_consent'];
1163
1164        $frontend_config = array(
1165            'cookiePolicyUrl' => $config['links']['cookie_policy_url'],
1166            'gdprHonorsGpc'   => $config['gdpr_honors_gpc'] ?? true,
1167            'forcePreview'    => $force_preview,
1168            'geoEnabled'      => (bool) $features['geo'],
1169            // Always emit the full geo sub-array, even when the geo feature is off:
1170            // the banner JS dereferences config.geo unconditionally, so omitting it
1171            // (rather than gating on geoEnabled) would break the module.
1172            'geo'             => array(
1173                'provider'          => $config['geo']['provider'],
1174                'apiUrl'            => $config['geo']['api_url'],
1175                'countryCodeCookie' => $config['geo']['country_code_cookie'],
1176                'regionCookie'      => $config['geo']['region_cookie'],
1177                'cookieDuration'    => $config['geo']['cookie_duration'],
1178                'gdprCountries'     => $config['geo']['gdpr_countries'],
1179                'ccpaRegions'       => $config['geo']['ccpa_regions'],
1180                'showOnError'       => $config['geo']['show_on_error'],
1181            ),
1182        );
1183
1184        // Pass configuration to frontend using wp_interactivity_config.
1185        wp_interactivity_config( 'jetpack/cookie-consent', $frontend_config );
1186    }
1187
1188    /**
1189     * Render the cookie consent banner
1190     */
1191    public static function render_banner() {
1192        // Don't render in admin.
1193        if ( is_admin() ) {
1194            return;
1195        }
1196
1197        // Always render the banner/modal HTML so the "Manage Privacy Preferences" footer link
1198        // can reopen the modal after consent has been set. The banner visibility is controlled
1199        // client-side via Interactivity API directives (showBanner starts as false).
1200        $template_path = plugin_dir_path( __FILE__ ) . 'cookie-banner-content.php';
1201        if ( file_exists( $template_path ) ) {
1202            // Get config for template (consumed by cookie-banner-content.php via include scope).
1203            // $copy/$categories are pre-resolved here so the template can use them directly
1204            // instead of re-resolving via get_copy()/get_consent_categories() — the config is
1205            // already stashed and fully normalized at this point.
1206            $config     = self::get_config(); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
1207            $copy       = $config['copy']; // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
1208            $categories = $config['consent']['categories']; // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
1209            ob_start();
1210            include $template_path;
1211            $html = ob_get_clean();
1212            // Process directives and output.
1213            // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
1214            echo wp_interactivity_process_directives( $html );
1215        }
1216    }
1217}