Files
att-consent/includes/class-scripts-manager.php
Steve Hanlon 0a73181ea7 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>
2026-02-17 09:28:11 +00:00

225 lines
6.0 KiB
PHP

<?php
/**
* Custom scripts CRUD manager.
*
* @package ATT_Consent
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class ATT_Consent_Scripts_Manager {
/**
* Get the table name.
*
* @return string
*/
private static function table_name() {
global $wpdb;
return $wpdb->prefix . 'att_cc_scripts';
}
/**
* Get all scripts, optionally filtered.
*
* @param string|null $category Filter by category.
* @param string|null $placement Filter by placement.
* @return array
*/
public static function get_scripts( $category = null, $placement = null ) {
global $wpdb;
$table = self::table_name();
$where = array( 'status = %s' );
$params = array( 'active' );
if ( null !== $category ) {
$where[] = 'category = %s';
$params[] = $category;
}
if ( null !== $placement ) {
$where[] = 'placement = %s';
$params[] = $placement;
}
$where_sql = implode( ' AND ', $where );
return $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$table} WHERE {$where_sql} ORDER BY priority ASC, id ASC", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
...$params
),
ARRAY_A
);
}
/**
* Get all scripts (including inactive) for the admin list.
*
* @return array
*/
public static function get_all_scripts() {
global $wpdb;
$table = self::table_name();
return $wpdb->get_results(
"SELECT * FROM {$table} ORDER BY priority ASC, id ASC", // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
ARRAY_A
);
}
/**
* Get a single script by ID.
*
* @param int $id Script ID.
* @return array|null
*/
public static function get_script( $id ) {
global $wpdb;
$table = self::table_name();
return $wpdb->get_row(
$wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $id ), // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
ARRAY_A
);
}
/**
* Save (insert or update) a script.
*
* @param array $data Script data.
* @return int|false The script ID on success, false on failure.
*/
public static function save_script( $data ) {
global $wpdb;
$table = self::table_name();
$allowed_categories = array( 'functional', 'analytics', 'marketing' );
$allowed_placements = array( 'head', 'footer' );
$allowed_statuses = array( 'active', 'inactive' );
$fields = array(
'name' => sanitize_text_field( $data['name'] ?? '' ),
'snippet' => wp_unslash( $data['snippet'] ?? '' ),
'category' => in_array( $data['category'] ?? '', $allowed_categories, true ) ? $data['category'] : 'analytics',
'placement' => in_array( $data['placement'] ?? '', $allowed_placements, true ) ? $data['placement'] : 'head',
'status' => in_array( $data['status'] ?? '', $allowed_statuses, true ) ? $data['status'] : 'active',
'priority' => absint( $data['priority'] ?? 10 ),
);
if ( ! empty( $data['id'] ) ) {
$fields['updated_at'] = current_time( 'mysql' );
$result = $wpdb->update(
$table,
$fields,
array( 'id' => absint( $data['id'] ) ),
array( '%s', '%s', '%s', '%s', '%s', '%d', '%s' ),
array( '%d' )
);
return false !== $result ? absint( $data['id'] ) : false;
}
$fields['created_at'] = current_time( 'mysql' );
$fields['updated_at'] = current_time( 'mysql' );
$result = $wpdb->insert( $table, $fields );
return false !== $result ? $wpdb->insert_id : false;
}
/**
* Delete a script by ID.
*
* @param int $id Script ID.
* @return bool
*/
public static function delete_script( $id ) {
global $wpdb;
$table = self::table_name();
return false !== $wpdb->delete( $table, array( 'id' => absint( $id ) ), array( '%d' ) );
}
/**
* Get scripts grouped by category and placement for frontend output.
*
* @return array
*/
public static function get_scripts_for_frontend() {
$scripts = self::get_scripts();
$grouped = array(
'functional' => array( 'head' => array(), 'footer' => array() ),
'analytics' => array( 'head' => array(), 'footer' => array() ),
'marketing' => array( 'head' => array(), 'footer' => array() ),
);
foreach ( $scripts as $script ) {
$cat = $script['category'];
$placement = $script['placement'];
if ( isset( $grouped[ $cat ][ $placement ] ) ) {
$grouped[ $cat ][ $placement ][] = $script['snippet'];
}
}
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;
}
}