} */ class TagRenderer { /** * Clarity's standard stub: queues clarity() calls until the tag loads. * Runs inside the snippet's (c,l,a,r,i,t,y) IIFE. * * @var string */ private const CLARITY_STUB = 'c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};'; /** * Clarity's standard loader: inserts the async clarity.ms tag script. * Runs inside the snippet's (c,l,a,r,i,t,y) IIFE. * * @var string */ private const CLARITY_LOADER = 't=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);'; /** * Browser time zones (a JS regex literal, matched against * Intl.DateTimeFormat().resolvedOptions().timeZone) for which Clarity * starts with consent denied under the eea_denied region default: all * of Europe/*, the Canary Islands, Madeira, the Azores, Iceland, the * Faroe Islands (Faeroe is the spelling V8 reports), Svalbard, Cyprus * (Asia/Nicosia, Asia/Famagusta) and Ceuta and Melilla. It errs towards * denying: European countries outside the EEA, the UK and Switzerland * match too. * * @var string */ private const EEA_TIME_ZONES = '/^(Europe\/|Atlantic\/(Canary|Madeira|Azores|Reykjavik|Faroe|Faeroe)|Arctic\/Longyearbyen|Asia\/(Nicosia|Famagusta)|Africa\/Ceuta)/'; /** * ISO 3166-1 alpha-2 codes for the EEA, the UK and Switzerland, used as * the `region` for the denied Consent Mode default when * consent_region_default is eea_denied. * * @var string[] */ public const EEA_REGIONS = array( 'AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', 'FR', 'DE', 'GR', 'HU', 'IE', 'IT', 'LV', 'LT', 'LU', 'MT', 'NL', 'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE', 'IS', 'LI', 'NO', 'GB', 'CH', ); /** * Settings store. * * @var Settings */ private Settings $settings; /** * The shared exclusions (environment, request type, role, filter). * * @var Gate */ private Gate $gate; /** * Constructor. * * @param Settings $settings Settings store. * @param Gate|null $gate The shared tracking gate; built from the settings when not given. */ public function __construct( Settings $settings, ?Gate $gate = null ) { $this->settings = $settings; $this->gate = $gate ?? new Gate( $settings ); } /** * Hooks the tag output onto wp_head at priority 1. */ public function register(): void { add_action( 'wp_head', array( $this, 'print_tag' ), 1 ); } /** * Prints the same tags render() returns, with core's print functions. * Split out from register() so tests can call render() directly without * triggering output. */ public function print_tag(): void { foreach ( $this->tags() as $tag ) { if ( null === $tag['js'] ) { wp_print_script_tag( $tag['attributes'] ); } else { wp_print_inline_script_tag( $tag['js'], $tag['attributes'] ); } } } /** * Whether any tag (gtag.js or Clarity) should be output for the given * user (the current user when none is given): at least one of them is * configured, and the shared Gate allows the request (environment, * previews, non-page requests, excluded roles, the tagsmith_should_track * filter). * * @param \WP_User|null $user The user to check; defaults to the current user. */ public function should_track( ?\WP_User $user = null ): bool { if ( ! $this->ga_configured() && ! $this->clarity_configured() ) { return false; } return $this->gate->allows( $user ); } /** * Whether Google Analytics is set up: tracking on, with a Measurement ID. */ private function ga_configured(): bool { return (bool) $this->settings->get( 'tracking_enabled' ) && '' !== (string) $this->settings->get( 'measurement_id' ); } /** * Whether Microsoft Clarity is set up: switched on, with a project id. */ private function clarity_configured(): bool { return (bool) $this->settings->get( 'clarity_enabled' ) && '' !== (string) $this->settings->get( 'clarity_project_id' ); } /** * The full tag markup, or '' when should_track() is false: the consent * defaults, then gtag.js (when GA is configured), then Clarity (when * Clarity is configured), then the WP Consent API bridge. */ public function render(): string { $html = ''; foreach ( $this->tags() as $tag ) { $html .= null === $tag['js'] ? wp_get_script_tag( $tag['attributes'] ) : wp_get_inline_script_tag( $tag['js'], $tag['attributes'] ); } return $html; } /** * The tag specs to print, in order, or none when should_track() is false: * the consent defaults, then gtag.js (when GA is configured), then * Clarity (when Clarity is configured), then the WP Consent API bridge. * * @return list */ private function tags(): array { if ( ! $this->should_track() ) { return array(); } $consent_mode = (string) $this->settings->get( 'consent_mode' ); $region_default = (string) $this->settings->get( 'consent_region_default' ); $clarity = $this->clarity_configured(); $tags = array( $this->consent_defaults_tag( $consent_mode, $region_default ) ); if ( $this->ga_configured() ) { $measurement_id = (string) $this->settings->get( 'measurement_id' ); array_push( $tags, ...$this->loader_tags( $measurement_id, $consent_mode ) ); $tags[] = $this->config_tag( $measurement_id ); } if ( $clarity ) { $tags[] = $this->clarity_tag( (string) $this->settings->get( 'clarity_project_id' ), $consent_mode, $region_default ); } // consent_mode 'off' means no consent calls at all (spec ยง4), so the // WP Consent API bridge is not printed either. if ( 'off' !== $consent_mode && function_exists( 'wp_has_consent' ) ) { $tags[] = $this->bridge_tag( $clarity ); } return $tags; } /** * The dataLayer/gtag bootstrap plus the Consent Mode v2 default calls. * * @param string $consent_mode 'off'|'basic'|'advanced'. * @param string $region_default 'eea_denied'|'all_denied'|'all_granted'. * @return TagSpec */ private function consent_defaults_tag( string $consent_mode, string $region_default ): array { $js = 'window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}'; if ( 'off' !== $consent_mode ) { switch ( $region_default ) { case 'eea_denied': $js .= "\ngtag('consent','default'," . Json::for_script( $this->consent_defaults( 'denied', true, true ) ) . ');'; $js .= "\ngtag('consent','default'," . Json::for_script( $this->consent_defaults( 'granted', false, true ) ) . ');'; break; case 'all_denied': $js .= "\ngtag('consent','default'," . Json::for_script( $this->consent_defaults( 'denied', false, false ) ) . ');'; break; case 'all_granted': $js .= "\ngtag('consent','default'," . Json::for_script( $this->consent_defaults( 'granted', false, false ) ) . ');'; break; } } return array( 'js' => $js, 'attributes' => array( 'id' => 'tagsmith-consent' ), ); } /** * Builds one Consent Mode v2 default object. * * @param string $state 'granted'|'denied'. * @param bool $with_region Whether to add the EEA/UK/CH region list. * @param bool $with_wait Whether to add wait_for_update. * @return array */ private function consent_defaults( string $state, bool $with_region, bool $with_wait ): array { $defaults = array( 'analytics_storage' => $state, 'ad_storage' => $state, 'ad_user_data' => $state, 'ad_personalization' => $state, ); if ( $with_region ) { $defaults['region'] = self::EEA_REGIONS; } if ( $with_wait ) { $defaults['wait_for_update'] = 500; } return $defaults; } /** * The gtag.js loader: a normal async script tag in 'advanced' (and * 'off') mode, or an inert text/plain tag plus the tagsmithGrant() * activator in 'basic' mode. * * @param string $measurement_id The GA4 measurement id. * @param string $consent_mode 'off'|'basic'|'advanced'. * @return list */ private function loader_tags( string $measurement_id, string $consent_mode ): array { $src = esc_url( 'https://www.googletagmanager.com/gtag/js?id=' . $measurement_id ); if ( 'basic' === $consent_mode ) { $tag = array( 'js' => null, 'attributes' => array( 'type' => 'text/plain', 'data-tagsmith-src' => $src, 'id' => 'tagsmith-gtag', ), ); $grant_js = 'window.tagsmithGrant=function(){' . 'if(window.tagsmithGranted){return;}' . 'window.tagsmithGranted=true;' . "var t=document.getElementById('tagsmith-gtag');" . 'if(!t){return;}' . "var s=document.createElement('script');" . 's.async=true;' . "s.src=t.getAttribute('data-tagsmith-src');" . 't.parentNode.insertBefore(s,t);' . 't.parentNode.removeChild(t);' . '};'; return array( $tag, array( 'js' => $grant_js, 'attributes' => array( 'id' => 'tagsmith-grant' ), ), ); } return array( array( 'js' => null, 'attributes' => array( 'async' => true, 'src' => $src, 'id' => 'tagsmith-gtag', ), ), ); } /** * The gtag('js', ...) / gtag('config', ...) call. * * @param string $measurement_id The GA4 measurement id. * @return TagSpec */ private function config_tag( string $measurement_id ): array { /** * Filters the fields Tagsmith adds to the gtag('config', ...) call, for * example send_page_view or a cookie_domain. Printed as JSON. * * @since 2.0.0 * * @param array $config The config fields. Default is empty. */ $config = (array) apply_filters( 'tagsmith_gtag_config', array() ); $config_json = empty( $config ) ? '{}' : Json::for_script( $config ); $id_json = Json::for_script( $measurement_id ); $js = "gtag('js', new Date());gtag('config', {$id_json}, {$config_json});"; return array( 'js' => $js, 'attributes' => array( 'id' => 'tagsmith-config' ), ); } /** * Microsoft Clarity: the standard stub and loader with the * JSON-encoded project id, then the WordPress context tags. * * Consent follows the same settings as gtag.js: * - off: Clarity loads, with no consent calls; * - advanced: Clarity loads, and the all_denied/all_granted default is * sent with consentv2; eea_denied sends consentv2 denied for European * browser time zones (see clarity_eea_default()); * - basic: only the stub (calls are queued); tagsmithGrant(), wrapped * around the gtag one when GA is configured, inserts the clarity.ms * script once and grants analytics storage. It is the call for a * visitor who accepts statistics cookies (banners without the WP * Consent API may call it directly), so it never grants ad storage on * its own: ad storage follows the WP Consent API's marketing consent * when it is present, else the ad_storage state the Google Consent * Mode forwarder exposes on the dataLayer (read when the grant runs), * and is denied when neither says granted. The grant also records * analytics as granted in that state when it exists (never ad), so a * banner's later ad-only update keeps analytics granted. The bridge * then forwards the exact state. * Outside off mode, the Google Consent Mode forwarder follows (see * clarity_gcm_forwarder()), then the context tags. * * @param string $project_id The Clarity project id. * @param string $consent_mode 'off'|'basic'|'advanced'. * @param string $region_default 'eea_denied'|'all_denied'|'all_granted'. * @return TagSpec */ private function clarity_tag( string $project_id, string $consent_mode, string $region_default ): array { $args = '(window,document,"clarity","script",' . Json::for_script( $project_id ) . ');'; if ( 'basic' === $consent_mode ) { $js = '(function(c,l,a,r,i,t,y){' . self::CLARITY_STUB . 'var g=c.tagsmithGrant;' . 'c.tagsmithGrant=function(){' . 'if(g){g();}' . 'if(t){return;}' . self::CLARITY_LOADER . 'var k=c.dataLayer&&c.dataLayer.tagsmithClarityGcm;' . "if(k){k.an='granted';}" . "c[a]('consentv2',{ad_Storage:(typeof wp_has_consent==='function'?wp_has_consent('marketing'):k&&k.ad==='granted')?'granted':'denied',analytics_Storage:'granted'});" . '};' . '})' . $args; } else { $js = '(function(c,l,a,r,i,t,y){' . self::CLARITY_STUB . self::CLARITY_LOADER . '})' . $args; if ( 'advanced' === $consent_mode && in_array( $region_default, array( 'all_denied', 'all_granted' ), true ) ) { $state = 'all_granted' === $region_default ? 'granted' : 'denied'; $js .= "\nclarity('consentv2',{ad_Storage:'{$state}',analytics_Storage:'{$state}'});"; } elseif ( 'advanced' === $consent_mode && 'eea_denied' === $region_default ) { $js .= "\n" . self::clarity_eea_default(); } } // After the default, so a banner's Consent Mode update (even one // pushed before this script) has the last word. if ( 'off' !== $consent_mode ) { $js .= "\n" . self::clarity_gcm_forwarder(); } foreach ( $this->clarity_tags() as $key => $value ) { $js .= "\nclarity('set'," . Json::for_script( $key ) . ',' . Json::for_script( $value ) . ');'; } return array( 'js' => $js, 'attributes' => array( 'id' => 'tagsmith-clarity' ), ); } /** * Clarity's default under advanced mode's eea_denied region default. * * Gtag gets a denied default scoped to the EEA, the UK and Switzerland * with `region`, plus a global granted default. Clarity reads Consent * Mode but may not honour `region`, so it could treat EEA visitors as * granted. To be privacy-safe whatever Clarity does with `region`, this * sends consentv2 denied when the browser's time zone is European (see * EEA_TIME_ZONES), or cannot be read. It is printed right after the * snippet, before the context tags and any consent update; the WP * Consent API bridge then passes on the visitor's actual choice. */ private static function clarity_eea_default(): string { return '(function(){var z;' . 'try{z=Intl.DateTimeFormat().resolvedOptions().timeZone;}catch(e){}' . 'if(!z||' . self::EEA_TIME_ZONES . '.test(z)){' . "clarity('consentv2',{ad_Storage:'denied',analytics_Storage:'denied'});" . '}})();'; } /** * Forwards Google Consent Mode updates to Clarity, so Clarity follows * any consent banner that calls gtag('consent','update', ...), not only * WP Consent API ones (for which the bridge also forwards). Printed * with Clarity unless consent mode is off, after the default. * * It replays the dataLayer entries already there (a banner may have * pushed a returning visitor's choice before this script), then wraps * dataLayer.push. For each gtag consent 'update' command (an array-like * consent, update, object) with analytics_storage or ad_storage, it * merges them into its state (a missing key keeps its value; both start * unknown) and calls consentv2, granting only what is known to be * granted. 'default' commands are ignored: our own defaults handle * them. * * The wrapper is a thin pass-through, so GTM and gtag.js, which wrap * push again later and call through, keep working: it calls the * previous push with the same this and arguments and returns its * result, and the forwarding runs in try/catch. * * The instance that wraps push exposes its live state as * dataLayer.tagsmithClarityGcm ({an, ad}, each 'granted', 'denied' * or unknown), which basic mode's tagsmithGrant() reads for * ad_Storage when there is no WP Consent API, and records its own * statistics consent in (an only). It also serves as the * flag that keeps a second copy of the markup from wrapping twice; that * copy's replay still runs, so it puts the banner's choice back after * its own default. */ private static function clarity_gcm_forwarder(): string { return '(function(w){' . 'var d=w.dataLayer=w.dataLayer||[],s={},i,p;' . 'function f(e){try{' . "var u=e&&e[0]==='consent'&&e[1]==='update'&&e[2],a,b;" . "if(!u||typeof u!=='object'){return;}" . 'a=u.analytics_storage;b=u.ad_storage;' . 'if(a===void 0&&b===void 0){return;}' . 'if(a!==void 0){s.an=a;}' . 'if(b!==void 0){s.ad=b;}' . "clarity('consentv2',{ad_Storage:s.ad==='granted'?'granted':'denied',analytics_Storage:s.an==='granted'?'granted':'denied'});" . '}catch(x){}}' . 'for(i=0;i */ private function clarity_tags(): array { if ( ! $this->settings->get( 'clarity_wp_tags' ) ) { return array(); } $tags = array( 'wp_page_type' => $this->page_type() ); $post = is_singular() ? get_queried_object() : null; if ( $post instanceof \WP_Post ) { $tags['wp_post_type'] = $post->post_type; if ( is_object_in_taxonomy( $post->post_type, 'category' ) ) { $categories = get_the_category( $post->ID ); if ( ! empty( $categories ) ) { $tags['wp_category'] = $categories[0]->slug; } } // The author's numeric user ID, not their user_nicename, which is // often derived from the login name. $author_id = (int) $post->post_author; if ( $author_id > 0 ) { $tags['wp_author'] = (string) $author_id; } } $tags['wp_visitor'] = is_user_logged_in() ? 'logged_in' : 'guest'; /** * Filters the WordPress context tags Tagsmith sends to Microsoft * Clarity with clarity('set', key, value). * * @since 2.0.0 * * @param array $tags Tag name => value. */ return self::clean_clarity_tags( apply_filters( 'tagsmith_clarity_tags', $tags ) ); } /** * Keeps the filtered tags that can be sent: non-empty string keys with * non-empty string (or number, cast to string) values. * * @param mixed $tags The filtered tags. * @return array */ private static function clean_clarity_tags( mixed $tags ): array { $clean = array(); foreach ( is_array( $tags ) ? $tags : array() as $key => $value ) { if ( ! is_string( $key ) || '' === $key || ! ( is_string( $value ) || is_int( $value ) || is_float( $value ) ) ) { continue; } $value = (string) $value; if ( '' !== $value ) { $clean[ $key ] = $value; } } return $clean; } /** * The wp_page_type context tag: front, blog, page, post (any other * singular view), archive, search, 404 or other. */ private function page_type(): string { if ( is_front_page() ) { return 'front'; } if ( is_home() ) { return 'blog'; } if ( is_page() ) { return 'page'; } if ( is_singular() ) { return 'post'; } // Before is_archive(): a search limited to a category is both. if ( is_search() ) { return 'search'; } if ( is_archive() ) { return 'archive'; } if ( is_404() ) { return '404'; } return 'other'; } /** * The WP Consent API bridge: maps 'statistics'/'marketing' consent * categories to Consent Mode v2 and, once statistics consent is given, * activates the inert basic-mode loader(s). With Clarity it also * forwards the state to Clarity's consentv2, after tagsmithGrant() * (and after the eea_denied time zone default, printed with the * Clarity tag) so the visitor's actual choice is the last word. * * @param bool $clarity Whether the Clarity tag is printed. * @return TagSpec */ private function bridge_tag( bool $clarity ): array { $js = '(function(){' . 'function upd(){' . "var s=wp_has_consent('statistics'),m=wp_has_consent('marketing');" . "gtag('consent','update',{analytics_storage:s?'granted':'denied',ad_storage:m?'granted':'denied',ad_user_data:m?'granted':'denied',ad_personalization:m?'granted':'denied'});" . 'if(s&&window.tagsmithGrant){tagsmithGrant();}' . ( $clarity ? "window.clarity&&clarity('consentv2',{ad_Storage:m?'granted':'denied',analytics_Storage:s?'granted':'denied'});" : '' ) . '}' . "document.addEventListener('DOMContentLoaded',upd);" . "document.addEventListener('wp_listen_for_consent_change',upd);" . '})();'; return array( 'js' => $js, 'attributes' => array( 'id' => 'tagsmith-consent-bridge' ), ); } }