Refactor script injection to type="text/plain" inert pattern
Custom scripts are now rendered as inert <script type="text/plain" data-att-cc-category="..."> tags in the page HTML. On consent, consent-manager.js scans the DOM and activates matching elements. This replaces the JSON-in-config approach and allows third-party plugins (e.g. HFCM) to output consent-gated scripts using the same data attribute convention. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -30,7 +30,9 @@ Single serialized option: `att_consent_settings`. Access via `ATT_Consent::get_s
|
|||||||
{prefix}att_cc_scripts (id, name, snippet, category, placement, status, priority, created_at, updated_at)
|
{prefix}att_cc_scripts (id, name, snippet, category, placement, status, priority, created_at, updated_at)
|
||||||
```
|
```
|
||||||
|
|
||||||
Scripts are passed to frontend JS via the inline config object, NOT rendered in PHP (they'd execute before consent). Injected into DOM by `consent-manager.js` after consent.
|
Scripts are rendered in PHP as inert `<script type="text/plain" data-att-cc-category="{category}">` tags (head scripts at priority 99, footer scripts at priority 99). Non-script HTML (pixels, iframes) is wrapped in `<template data-att-cc-category="..." data-att-cc-type="html">`. On consent, `consent-manager.js` scans the DOM for matching elements and activates them by creating fresh executable copies.
|
||||||
|
|
||||||
|
Third-party plugins (e.g. HFCM) can output consent-gated scripts without any coupling to this plugin — they just need to use `type="text/plain"` and the `data-att-cc-category` attribute with a valid category (`functional`, `analytics`, `marketing`).
|
||||||
|
|
||||||
### Consent Cookie
|
### Consent Cookie
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ class ATT_Consent_Frontend {
|
|||||||
// Priority 2: gtag.js or GTM (Advanced mode only).
|
// Priority 2: gtag.js or GTM (Advanced mode only).
|
||||||
add_action( 'wp_head', array( $this, 'output_tracking_script' ), 2 );
|
add_action( 'wp_head', array( $this, 'output_tracking_script' ), 2 );
|
||||||
|
|
||||||
|
// Custom scripts as inert type="text/plain" tags.
|
||||||
|
add_action( 'wp_head', array( $this, 'output_custom_scripts_head' ), 99 );
|
||||||
|
add_action( 'wp_footer', array( $this, 'output_custom_scripts_footer' ), 99 );
|
||||||
|
|
||||||
// Footer: banner HTML.
|
// Footer: banner HTML.
|
||||||
add_action( 'wp_footer', array( $this, 'output_banner_html' ), 5 );
|
add_action( 'wp_footer', array( $this, 'output_banner_html' ), 5 );
|
||||||
|
|
||||||
@@ -58,9 +62,6 @@ class ATT_Consent_Frontend {
|
|||||||
$config['tracking_snippet'] = $this->get_tracking_snippet();
|
$config['tracking_snippet'] = $this->get_tracking_snippet();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pass custom scripts for conditional injection.
|
|
||||||
$config['scripts'] = ATT_Consent_Scripts_Manager::get_scripts_for_frontend();
|
|
||||||
|
|
||||||
$config_json = wp_json_encode( $config );
|
$config_json = wp_json_encode( $config );
|
||||||
|
|
||||||
?>
|
?>
|
||||||
@@ -170,6 +171,20 @@ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Output custom scripts in <head> as inert type="text/plain" tags.
|
||||||
|
*/
|
||||||
|
public function output_custom_scripts_head() {
|
||||||
|
echo ATT_Consent_Scripts_Manager::render_scripts_as_html( 'head' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- contains script/template tags.
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Output custom scripts in footer as inert type="text/plain" tags.
|
||||||
|
*/
|
||||||
|
public function output_custom_scripts_footer() {
|
||||||
|
echo ATT_Consent_Scripts_Manager::render_scripts_as_html( 'footer' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- contains script/template tags.
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enqueue frontend assets.
|
* Enqueue frontend assets.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -165,4 +165,60 @@ class ATT_Consent_Scripts_Manager {
|
|||||||
|
|
||||||
return $grouped;
|
return $grouped;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render scripts as inert HTML for a given placement.
|
||||||
|
*
|
||||||
|
* Script tags are output with type="text/plain" so they don't execute
|
||||||
|
* until activated by consent-manager.js. Non-script HTML (pixels, etc.)
|
||||||
|
* is wrapped in <template> tags.
|
||||||
|
*
|
||||||
|
* @param string $placement 'head' or 'footer'.
|
||||||
|
* @return string Combined HTML string.
|
||||||
|
*/
|
||||||
|
public static function render_scripts_as_html( $placement ) {
|
||||||
|
$scripts = self::get_scripts( null, $placement );
|
||||||
|
|
||||||
|
if ( empty( $scripts ) ) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$output = '';
|
||||||
|
|
||||||
|
foreach ( $scripts as $script ) {
|
||||||
|
$snippet = trim( $script['snippet'] );
|
||||||
|
$category = esc_attr( $script['category'] );
|
||||||
|
|
||||||
|
if ( '' === $snippet ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if snippet contains <script> tags.
|
||||||
|
if ( preg_match( '/<script[\s>]/i', $snippet ) ) {
|
||||||
|
// Rewrite each <script> tag to type="text/plain" with category attribute.
|
||||||
|
$snippet = preg_replace_callback(
|
||||||
|
'/<script(\s[^>]*)?>|<script>/i',
|
||||||
|
function ( $matches ) use ( $category ) {
|
||||||
|
$attrs = isset( $matches[1] ) ? $matches[1] : '';
|
||||||
|
|
||||||
|
// Strip any existing type attribute.
|
||||||
|
$attrs = preg_replace( '/\s*type\s*=\s*(["\'])[^"\']*\1/i', '', $attrs );
|
||||||
|
|
||||||
|
return '<script type="text/plain" data-att-cc-category="' . $category . '"' . $attrs . '>';
|
||||||
|
},
|
||||||
|
$snippet
|
||||||
|
);
|
||||||
|
|
||||||
|
$output .= $snippet . "\n";
|
||||||
|
} elseif ( preg_match( '/^\s*</', $snippet ) ) {
|
||||||
|
// Non-script HTML (img pixels, noscript, iframes, etc.).
|
||||||
|
$output .= '<template data-att-cc-category="' . $category . '" data-att-cc-type="html">' . $snippet . '</template>' . "\n";
|
||||||
|
} else {
|
||||||
|
// Raw JS code without <script> wrapper.
|
||||||
|
$output .= '<script type="text/plain" data-att-cc-category="' . $category . '">' . $snippet . '</script>' . "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $output;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,32 +129,68 @@ var AttConsent = (function() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute custom scripts for the consented categories.
|
* Execute custom scripts for the consented categories.
|
||||||
|
* Scans the DOM for inert type="text/plain" script tags and template tags
|
||||||
|
* with matching data-att-cc-category attributes, then activates them.
|
||||||
*
|
*
|
||||||
* @param {Object} categories Consent categories.
|
* @param {Object} categories Consent categories.
|
||||||
*/
|
*/
|
||||||
function executeConsentedScripts(categories) {
|
function executeConsentedScripts(categories) {
|
||||||
var scripts = config.scripts || {};
|
var cats = Object.keys(categories).filter(function(cat) {
|
||||||
|
return categories[cat];
|
||||||
|
});
|
||||||
|
if (!cats.length) return;
|
||||||
|
|
||||||
Object.keys(categories).forEach(function(cat) {
|
var selectors = [];
|
||||||
if (!categories[cat] || !scripts[cat]) {
|
cats.forEach(function(cat) {
|
||||||
return;
|
selectors.push('script[type="text/plain"][data-att-cc-category="' + cat + '"]');
|
||||||
|
selectors.push('template[data-att-cc-category="' + cat + '"][data-att-cc-type="html"]');
|
||||||
|
});
|
||||||
|
|
||||||
|
var elements = document.querySelectorAll(selectors.join(','));
|
||||||
|
Array.prototype.slice.call(elements).forEach(function(el) {
|
||||||
|
if (el.nodeName === 'SCRIPT') {
|
||||||
|
activateScript(el);
|
||||||
|
} else if (el.nodeName === 'TEMPLATE') {
|
||||||
|
activateTemplate(el);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
['head', 'footer'].forEach(function(placement) {
|
/**
|
||||||
if (!scripts[cat][placement] || !scripts[cat][placement].length) {
|
* Activate an inert script element by creating a fresh <script> tag.
|
||||||
|
* Copies all attributes except type and data-att-cc-category,
|
||||||
|
* then removes the original to prevent double execution.
|
||||||
|
*
|
||||||
|
* @param {HTMLScriptElement} blocked The inert script element.
|
||||||
|
*/
|
||||||
|
function activateScript(blocked) {
|
||||||
|
var script = document.createElement('script');
|
||||||
|
|
||||||
|
Array.prototype.slice.call(blocked.attributes).forEach(function(attr) {
|
||||||
|
if (attr.name === 'type' || attr.name === 'data-att-cc-category') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
script.setAttribute(attr.name, attr.value);
|
||||||
var container = placement === 'head' ? document.head : document.body;
|
|
||||||
|
|
||||||
scripts[cat][placement].forEach(function(snippet) {
|
|
||||||
injectHTML(snippet, container);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clear to prevent double execution.
|
if (!blocked.src) {
|
||||||
scripts[cat][placement] = [];
|
script.textContent = blocked.textContent;
|
||||||
});
|
}
|
||||||
});
|
|
||||||
|
blocked.parentNode.insertBefore(script, blocked);
|
||||||
|
blocked.parentNode.removeChild(blocked);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activate an inert template element by injecting its HTML content,
|
||||||
|
* then removing the template to prevent double execution.
|
||||||
|
*
|
||||||
|
* @param {HTMLTemplateElement} tmpl The template element.
|
||||||
|
*/
|
||||||
|
function activateTemplate(tmpl) {
|
||||||
|
var container = tmpl.parentNode;
|
||||||
|
injectHTML(tmpl.innerHTML, container);
|
||||||
|
container.removeChild(tmpl);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user