Add TCF v2.2 bridge mode for external CMP integration (v1.3.0)

New cmp_mode setting toggles between the built-in banner (default,
unchanged) and a bridge mode that defers banner UX to an external
IAB TCF v2.2 certified CMP — e.g. Google's free Privacy & messaging
(Funding Choices) or Tarteaucitron.js — and listens to __tcfapi to
keep custom-script gating, attribution preservation, the att_cc_consent
cookie cache and the WP Consent API bridge in sync. Lets sites that
need a Google-certified CMP for EEA/UK AdSense/AdMob serving keep
using this plugin for everything except the consent UI itself.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-30 14:10:58 +01:00
parent 7059c45e20
commit eb35fdabb6
7 changed files with 238 additions and 7 deletions

View File

@@ -12,6 +12,13 @@ A WordPress plugin implementing Google Consent Mode v2 with session attribution
2. **Tracking script (priority 2)** - Loads gtag.js or GTM. In Advanced mode, loads immediately. In Basic mode, deferred until consent.
3. **Footer** - Banner HTML + enqueued `banner.js` and `banner.css`.
### CMP Mode
The `cmp_mode` setting toggles between two operating modes:
- **`self` (default)** - this plugin renders the banner, owns the consent decision, and writes the `att_cc_consent` cookie. Original behaviour.
- **`tcf_bridge`** - an external IAB TCF v2.2 certified CMP (e.g. Google's free Privacy & messaging / Funding Choices banner, Tarteaucitron.js) renders the banner and emits the official TC string + `gtag('consent', ...)` calls. The plugin suppresses its own banner/modal/`consent default`, and instead loads `public/js/tcf-bridge.js`, which listens to `__tcfapi('addEventListener', 2, …)`, maps TCF purposes back to the three categories, and feeds `AttConsent.update()` so custom-script gating, attribution preservation and the WP Consent API bridge continue to work. Used when the site needs to satisfy Google's CMP requirement for EEA/UK/CH AdSense/AdMob traffic.
### Key Files
- `includes/class-frontend.php` - Orchestrates the output order. The `output_consent_defaults()` method generates the critical inline script.

View File

@@ -16,6 +16,27 @@ if ( ! defined( 'ABSPATH' ) ) {
<input type="hidden" name="att_cc_tab" value="general">
<table class="form-table">
<tr>
<th scope="row">
<label for="cmp_mode"><?php esc_html_e( 'CMP Mode', 'att-consent' ); ?></label>
</th>
<td>
<select name="cmp_mode" id="cmp_mode">
<option value="self" <?php selected( $settings['cmp_mode'], 'self' ); ?>>
<?php esc_html_e( 'Built-in banner (default)', 'att-consent' ); ?>
</option>
<option value="tcf_bridge" <?php selected( $settings['cmp_mode'], 'tcf_bridge' ); ?>>
<?php esc_html_e( 'External TCF v2.2 CMP (e.g. Google Funding Choices, Tarteaucitron)', 'att-consent' ); ?>
</option>
</select>
<p class="description">
<?php
echo wp_kses_post( __( '<strong>Built-in banner:</strong> this plugin renders the consent banner and owns the consent decision.<br><strong>External TCF CMP:</strong> a separate, IAB TCF v2.2 certified CMP (such as Google\'s free Privacy &amp; messaging banner from AdSense, or Tarteaucitron.js) shows the banner and emits the official TC string. This plugin then listens to <code>__tcfapi</code>, maps TCF purposes to its three categories, and continues to run custom-script gating, attribution preservation and the WP Consent API bridge. Use this if you serve Google ads in EEA/UK/CH and need a certified CMP.', 'att-consent' ) );
?>
</p>
</td>
</tr>
<tr>
<th scope="row">
<label for="tracking_mode"><?php esc_html_e( 'Tracking Mode', 'att-consent' ); ?></label>

View File

@@ -3,7 +3,7 @@
* Plugin Name: ATT Consent
* Plugin URI: https://github.com/att-consent/att-consent
* Description: Google Consent Mode v2 cookie consent with session attribution preservation, custom script management, and full gtag.js/GTM support.
* Version: 1.2.0
* Version: 1.3.0
* Requires at least: 6.0
* Requires PHP: 7.4
* Author: ATT Consent
@@ -18,7 +18,7 @@ if ( ! defined( 'ABSPATH' ) ) {
exit;
}
define( 'ATT_CC_VERSION', '1.2.0' );
define( 'ATT_CC_VERSION', '1.3.0' );
define( 'ATT_CC_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
define( 'ATT_CC_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
define( 'ATT_CC_PLUGIN_BASENAME', plugin_basename( __FILE__ ) );

View File

@@ -165,6 +165,7 @@ class ATT_Consent_Admin {
$settings['ga4_measurement_id'] = sanitize_text_field( $_POST['ga4_measurement_id'] ?? '' );
$settings['gtm_container_id'] = sanitize_text_field( $_POST['gtm_container_id'] ?? '' );
$settings['consent_mode'] = in_array( $_POST['consent_mode'] ?? '', array( 'advanced', 'basic' ), true ) ? $_POST['consent_mode'] : 'advanced';
$settings['cmp_mode'] = in_array( $_POST['cmp_mode'] ?? '', array( 'self', 'tcf_bridge' ), true ) ? $_POST['cmp_mode'] : 'self';
$settings['banner_position'] = in_array( $_POST['banner_position'] ?? '', array( 'bottom', 'top', 'center' ), true ) ? $_POST['banner_position'] : 'bottom';
$settings['consent_expiry'] = min( 730, max( 1, absint( $_POST['consent_expiry'] ?? 365 ) ) );
$settings['floating_widget'] = in_array(

View File

@@ -29,6 +29,7 @@ class ATT_Consent {
'ga4_measurement_id' => '',
'gtm_container_id' => '',
'consent_mode' => 'advanced',
'cmp_mode' => 'self',
'banner_position' => 'bottom',
'consent_expiry' => 365,
'floating_widget' => 'bottom-right',

View File

@@ -50,6 +50,7 @@ class ATT_Consent_Frontend {
$config = array(
'consent_mode' => $s['consent_mode'],
'cmp_mode' => $s['cmp_mode'],
'consent_expiry' => (int) $s['consent_expiry'],
'wait_for_update' => (int) $s['wait_for_update'],
'url_passthrough' => (bool) $s['url_passthrough'],
@@ -66,11 +67,13 @@ class ATT_Consent_Frontend {
}
// In basic mode, pass the tracking snippet for deferred injection.
if ( 'basic' === $s['consent_mode'] ) {
// Basic mode only applies when this plugin owns consent (self mode).
if ( 'basic' === $s['consent_mode'] && 'self' === $s['cmp_mode'] ) {
$config['tracking_snippet'] = $this->get_tracking_snippet();
}
$config_json = wp_json_encode( $config );
$is_tcf = ( 'tcf_bridge' === $s['cmp_mode'] );
?>
<script>
@@ -87,6 +90,24 @@ window.gtag=g;
g('js',new Date());
if(c.ads_data_redaction){g('set','ads_data_redaction',true);}
if(c.url_passthrough){g('set','url_passthrough',true);}
<?php if ( $is_tcf ) : ?>
// TCF bridge mode: an external CMP will emit gtag('consent','update', ...)
// once it has read the user's TC string. We still emit a default-denied
// here so gtag.js holds events until the CMP responds — without this,
// gtag falls back to its implicit "granted" default and would fire a
// fully-tracked hit before the CMP has had a chance to apply consent.
g('consent','default',{
ad_storage:'denied',
analytics_storage:'denied',
ad_user_data:'denied',
ad_personalization:'denied',
functionality_storage:'denied',
personalization_storage:'denied',
security_storage:'granted',
wait_for_update:c.wait_for_update||500
});
window.attCCHasConsent=false;
<?php else : ?>
var ck=document.cookie.match(/(?:^|; )att_cc_consent=([^;]*)/);
var st=null;
if(ck){try{st=JSON.parse(decodeURIComponent(ck[1]));}catch(e){}}
@@ -115,6 +136,7 @@ wait_for_update:c.wait_for_update||500
});
window.attCCHasConsent=false;
}
<?php endif; ?>
if(!sessionStorage.getItem('att_cc_attr')){
var p=new URLSearchParams(window.location.search);
var a={
@@ -134,6 +156,22 @@ if(a.r||a.us||a.gc||a.gl){
sessionStorage.setItem('att_cc_attr',JSON.stringify(a));
}
}
<?php if ( $is_tcf ) : ?>
// Pre-stage attribution params on dataLayer (no-op if nothing captured).
try{
var sa=sessionStorage.getItem('att_cc_attr');
if(sa){
var aa=JSON.parse(sa);
var sp={};
if(aa.us){sp.campaign_source=aa.us;}
if(aa.um){sp.campaign_medium=aa.um;}
if(aa.uc){sp.campaign_name=aa.uc;}
if(aa.ut){sp.campaign_term=aa.ut;}
if(aa.uo){sp.campaign_content=aa.uo;}
for(var k in sp){g('set',sp);break;}
}
}catch(e){}
<?php endif; ?>
})();
</script>
<?php
@@ -200,6 +238,9 @@ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
* Enqueue frontend assets.
*/
public function enqueue_assets() {
$is_tcf = ( 'tcf_bridge' === $this->settings['cmp_mode'] );
// Banner CSS is needed in both modes — the floating widget uses it.
wp_enqueue_style(
'att-consent-banner',
ATT_CC_PLUGIN_URL . 'public/css/banner.css',
@@ -207,6 +248,7 @@ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
ATT_CC_VERSION
);
// consent-manager.js runs in both modes (script gating, cookie I/O, WP Consent API).
wp_enqueue_script(
'att-consent-manager',
ATT_CC_PLUGIN_URL . 'public/js/consent-manager.js',
@@ -215,6 +257,18 @@ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
true
);
if ( $is_tcf ) {
// In TCF bridge mode the banner.js UI is replaced by the TCF listener.
wp_enqueue_script(
'att-consent-tcf-bridge',
ATT_CC_PLUGIN_URL . 'public/js/tcf-bridge.js',
array( 'att-consent-manager' ),
ATT_CC_VERSION,
true
);
return;
}
wp_enqueue_script(
'att-consent-banner',
ATT_CC_PLUGIN_URL . 'public/js/banner.js',
@@ -226,9 +280,13 @@ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
/**
* Output the banner and preferences modal HTML.
* In TCF bridge mode the banner and modal are suppressed (an external CMP
* renders its own UI); only the optional floating widget is emitted, and
* its click handler is wired to the external CMP via tcf-bridge.js.
*/
public function output_banner_html() {
$s = $this->settings;
$s = $this->settings;
$is_tcf = ( 'tcf_bridge' === $s['cmp_mode'] );
do_action( 'att_consent_before_banner' );
@@ -246,7 +304,11 @@ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
esc_attr( $s['btn_preferences_text'] )
);
$banner_html = '<div id="att-cc-banner" class="' . esc_attr( $position_class ) . '" role="dialog" aria-labelledby="att-cc-banner-heading" aria-hidden="false" tabindex="-1" style="' . $style_vars . '">
$banner_html = '';
$modal_html = '';
if ( ! $is_tcf ) {
$banner_html = '<div id="att-cc-banner" class="' . esc_attr( $position_class ) . '" role="dialog" aria-labelledby="att-cc-banner-heading" aria-hidden="false" tabindex="-1" style="' . $style_vars . '">
<div class="att-cc-banner__inner">
<div class="att-cc-banner__content">
<h2 id="att-cc-banner-heading" class="att-cc-banner__heading">' . esc_html( $s['banner_heading'] ) . '</h2>
@@ -260,7 +322,7 @@ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
</div>
</div>';
$modal_html = '<div id="att-cc-modal" role="dialog" aria-labelledby="att-cc-modal-heading" aria-hidden="true" aria-modal="true" style="' . $style_vars . '">
$modal_html = '<div id="att-cc-modal" role="dialog" aria-labelledby="att-cc-modal-heading" aria-hidden="true" aria-modal="true" style="' . $style_vars . '">
<div class="att-cc-modal__overlay"></div>
<div class="att-cc-modal__dialog">
<h2 id="att-cc-modal-heading" class="att-cc-modal__heading">' . esc_html__( 'Manage Cookie Preferences', 'att-consent' ) . '</h2>
@@ -312,12 +374,15 @@ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
</div>
</div>
</div>';
}
$widget_html = '';
if ( 'none' !== $s['floating_widget'] ) {
$widget_position = 'right' === $s['floating_widget'] ? 'att-cc-widget--right' : 'att-cc-widget--bottom-right';
$widget_label = ! empty( $s['floating_widget_label'] ) ? $s['floating_widget_label'] : 'Cookie Settings';
$widget_html = '<button type="button" id="att-cc-widget" class="att-cc-widget ' . esc_attr( $widget_position ) . '" aria-label="' . esc_attr( $widget_label ) . '" style="display:none;' . $style_vars . '"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2zm0 18a8 8 0 1 1 8-8 8 8 0 0 1-8 8zm-1-13a1.5 1.5 0 1 1-1.5-1.5A1.5 1.5 0 0 1 11 7zm4 2a1 1 0 1 1-1-1 1 1 0 0 1 1 1zm-7 3a1 1 0 1 1-1-1 1 1 0 0 1 1 1zm3 4a1.5 1.5 0 1 1-1.5-1.5A1.5 1.5 0 0 1 11 16zm5-1a1 1 0 1 1-1-1 1 1 0 0 1 1 1z"/></svg> ' . esc_html( $widget_label ) . '</button>';
// In TCF bridge mode the widget is shown immediately (no banner to wait for).
$widget_display = $is_tcf ? '' : 'display:none;';
$widget_html = '<button type="button" id="att-cc-widget" class="att-cc-widget ' . esc_attr( $widget_position ) . '" aria-label="' . esc_attr( $widget_label ) . '" style="' . $widget_display . $style_vars . '"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2zm0 18a8 8 0 1 1 8-8 8 8 0 0 1-8 8zm-1-13a1.5 1.5 0 1 1-1.5-1.5A1.5 1.5 0 0 1 11 7zm4 2a1 1 0 1 1-1-1 1 1 0 0 1 1 1zm-7 3a1 1 0 1 1-1-1 1 1 0 0 1 1 1zm3 4a1.5 1.5 0 1 1-1.5-1.5A1.5 1.5 0 0 1 11 16zm5-1a1 1 0 1 1-1-1 1 1 0 0 1 1 1z"/></svg> ' . esc_html( $widget_label ) . '</button>';
}
$html = apply_filters( 'att_consent_banner_html', $banner_html . $modal_html . $widget_html, $s );

136
public/js/tcf-bridge.js Normal file
View File

@@ -0,0 +1,136 @@
/**
* ATT Consent — TCF v2.2 Bridge
*
* Used when cmp_mode = 'tcf_bridge'. A separate, IAB TCF v2.2 certified CMP
* (e.g. Google's "Privacy & messaging" / Funding Choices, Tarteaucitron.js)
* is expected to render the banner and emit gtag('consent', ...) calls.
*
* This script:
* 1. Waits for window.__tcfapi to appear.
* 2. Subscribes to the TCF event stream.
* 3. Maps TCF v2.2 purposes back to this plugin's three categories
* (functional / analytics / marketing).
* 4. Calls AttConsent.update() so custom scripts, the att_cc_consent
* cookie cache and the WP Consent API bridge all stay in sync.
* 5. Wires the floating widget click to the CMP's "re-open" entry point.
*
* If __tcfapi never appears (CMP not installed, or visitor is outside the
* TCF jurisdiction and the CMP loaded no stub) the plugin's default-denied
* state simply persists — no fail-open here.
*
* TCF purpose → category mapping:
* 1, 5, 6 → functional (device storage, personalised content)
* 7, 8, 9, 10 → analytics (ad/content measurement, market research, product dev)
* 2, 3, 4 → marketing (basic ads, ad profiling, personalised ads)
*/
(function () {
'use strict';
if (typeof window.AttConsent === 'undefined') {
return;
}
var MAX_WAIT_MS = 10000;
var POLL_INTERVAL = 100;
var elapsed = 0;
var lastSignature = '';
function mapTcDataToCategories(tcData) {
// Outside the TCF scope (typically non-EEA/UK/CH): the CMP isn't
// asserting any restriction, so treat as full consent. The site
// operator remains responsible for any other applicable law (GPP/CCPA).
if (tcData && tcData.gdprApplies === false) {
return { functional: true, analytics: true, marketing: true };
}
var purposes = (tcData && tcData.purpose && tcData.purpose.consents) || {};
return {
functional: !!(purposes[1] || purposes[5] || purposes[6]),
analytics: !!(purposes[7] || purposes[8] || purposes[9] || purposes[10]),
marketing: !!(purposes[2] || purposes[3] || purposes[4])
};
}
function handleTcData(tcData, success) {
if (!success || !tcData) {
return;
}
// Only act on terminal states.
if (tcData.eventStatus !== 'tcloaded' && tcData.eventStatus !== 'useractioncomplete') {
return;
}
var cats = mapTcDataToCategories(tcData);
// Suppress repeat updates with identical state — TCF can fire
// multiple times per page (e.g. tcloaded then useractioncomplete).
var sig = cats.functional + '|' + cats.analytics + '|' + cats.marketing;
if (sig === lastSignature) {
return;
}
lastSignature = sig;
try {
window.AttConsent.update(cats);
} catch (e) {
// Swallow — don't break the page if consent-manager errored.
}
}
function attach() {
try {
window.__tcfapi('addEventListener', 2, handleTcData);
} catch (e) {
// Bad CMP stub. Nothing else to do.
}
}
function poll() {
if (typeof window.__tcfapi === 'function') {
attach();
return;
}
elapsed += POLL_INTERVAL;
if (elapsed >= MAX_WAIT_MS) {
return;
}
setTimeout(poll, POLL_INTERVAL);
}
// --- Floating widget: re-open the external CMP UI ---
var widget = document.getElementById('att-cc-widget');
if (widget) {
widget.addEventListener('click', function (e) {
e.preventDefault();
// 1. Standard IAB way (works for any compliant TCF v2.2 CMP).
if (typeof window.__tcfapi === 'function') {
try {
window.__tcfapi('displayConsentUi', 2, function () {});
return;
} catch (err) {}
}
// 2. Google Funding Choices specific re-prompt.
if (window.googlefc && window.googlefc.callbackQueue) {
window.googlefc.callbackQueue.push({
CONSENT_DATA_READY: function () {
if (typeof window.googlefc.showRevocationMessage === 'function') {
window.googlefc.showRevocationMessage();
}
}
});
return;
}
// 3. Last-ditch: scroll to a #privacy / #cookies anchor if the
// site has one in its footer. Fail silently otherwise.
var fallback = document.querySelector('a[href*="#cookie"], a[href*="#privacy"]');
if (fallback) {
fallback.click();
}
});
}
poll();
})();