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