Enable Dynamic Population.
* 3. Parameter Name: abtestversion_{slug} (e.g., abtestversion_hero-cta).
* 4. The value saved will be "{slug}:{version}" (e.g., hero-cta:contentversionb).
*
* B. Using Merge Tags:
* Use {abtestversion:slug} in notifications or confirmations to display the visitor's version.
*
* --- USAGE: EXTERNAL TRACKING (GA4, etc.) ---
* 1. Automatic Resolution Events:
* a) 'bl_abtest_resolved' — dispatched on every page where a test is resolved.
* Use this to set GA4 User Properties to tie the version to ALL future events.
* Example:
* document.addEventListener('bl_abtest_resolved', function(e) {
* const { slug, version } = e.detail;
* // GA4 User Property (Ties ALL future events like clicks/purchases to this version)
* if (window.gtag) {
* gtag('set', 'user_properties', { ['ab_test_' + slug]: version });
* gtag('event', 'ab_test_view', { 'test_name': slug, 'test_version': version });
* }
* });
* b) 'bl_abtest_first_resolved' — dispatched exactly once per slug when the cookie is first created.
* Use this to associate a user with a cohort a single time.
*
* 2. Manual Retrieval:
* You can access resolved versions anytime via window.BL_ABTest.versions
* Example: const pricingVersion = window.BL_ABTest.versions['pricing-table'];
*/
/**
* Core class for handling A/B testing functionality.
*
* The `BL_ABTest` class provides utilities for managing A/B testing of
* content through shortcodes, Gutenberg blocks, and integration with Gravity Forms.
* It allows the tracking and serving of different content versions to users,
* maintaining state using cookies and choosing versions through random selection or block-level logic.
*/
class BL_ABTest {
const COOKIE_PREFIX = 'bl_abtest_';
const COOKIE_TTL_DAYS = 30;
/**
* Per-request counters for block occurrences
*
* @var array
*/
private static $block_seen = [];
/**
* Initializes the class by setting up actions and filters.
*
* This method registers the necessary WordPress actions and filters required
* for the functionality of the class, including shortcodes, block rendering,
* and integration with Gravity Forms for merge tags and dynamic population.
*
* @return void
*/
public static function init() {
add_action( 'init', [ __CLASS__, 'register_shortcodes' ] );
add_filter( 'render_block', [ __CLASS__, 'filter_render_block' ], 10, 2 );
// Gravity Forms merge tags
add_filter( 'gform_replace_merge_tags', [ __CLASS__, 'gf_replace_merge_tags' ] );
// Gravity Forms dynamic population: {Field} param name "abtestversion_slug"
add_filter( 'gform_field_value', [ __CLASS__, 'gf_dynamic_population' ], 10, 3 );
add_action( 'wp_head', [ __CLASS__, 'print_assets' ] );
add_action( 'wp_footer', [ __CLASS__, 'print_footer_scripts' ] );
}
/**
* Prints CSS and JS to the head to manage A/B test visibility and selection.
*/
public static function print_assets() {
?>
'',
],
$atts,
'abtest'
);
$slug = self::normalize_slug( $atts['title'] );
if ( '' === $slug ) {
return do_shortcode( $content );
}
// Simply output both versions with data attributes.
// The JS will handle showing the correct one.
return sprintf(
'
%2$s
',
esc_attr( $slug ),
do_shortcode( $content )
);
}
/**
* Processes the [abcontent] shortcode within the context of A/B testing.
*
* @param array $atts The attributes passed to the shortcode.
* @param string $content The enclosed content between the shortcode tags.
*
* @return string
*/
public static function sc_abcontent( $atts, $content = '' ) {
$atts = shortcode_atts(
[
'contentversion' => '',
],
$atts,
'abcontent'
);
$ver = strtolower( trim( (string) $atts['contentversion'] ) );
if ( 'contentversiona' !== $ver && 'contentversionb' !== $ver ) {
return do_shortcode( $content );
}
return sprintf(
'%s
',
esc_attr( $ver ),
do_shortcode( $content )
);
}
/*
---------------------------
Gutenberg block filter
---------------------------
*/
/**
* Filters the rendered block content to modify it for A/B testing purposes.
*
* This method checks if the block contains an A/B test configuration based on its class name,
* assigns a version (A or B) based on the block's occurrence, and adds appropriate
* data attributes (`data-abtest-slug` and `data-abtest-version`) for JavaScript handling.
*
* @param string $block_content The HTML content of the rendered block.
* @param array $block The block configuration and attributes array.
*
* @return string The modified block content with added data attributes for A/B testing.
*/
public static function filter_render_block( $block_content, $block ) {
// Check for the class in the block attributes (Gutenberg way)
$class_name = $block['attrs']['className'] ?? '';
if ( ! $class_name || stripos( $class_name, 'abtestcontainer-' ) === false ) {
return $block_content;
}
if ( ! preg_match( '/\babtestcontainer-([a-z0-9-]+)\b/i', $class_name, $match ) ) {
return $block_content;
}
$slug = self::normalize_slug( $match[1] );
if ( '' === $slug ) {
return $block_content;
}
if ( ! isset( self::$block_seen[ $slug ] ) ) {
self::$block_seen[ $slug ] = 0;
}
++self::$block_seen[ $slug ];
$occurrence = self::$block_seen[ $slug ];
$assigned = null;
if ( 1 === $occurrence ) {
$assigned = 'contentversiona';
} elseif ( 2 === $occurrence ) {
$assigned = 'contentversionb';
}
if ( ! $assigned ) {
return $block_content;
}
// Add data attributes for the JS to handle selection
$block_content = self::add_data_attr_to_first_tag( $block_content, 'data-abtest-slug', $slug );
return self::add_data_attr_to_first_tag( $block_content, 'data-abtest-version', $assigned );
}
/**
* Adds a data attribute to the first HTML tag in a string.
*
* @param string $html HTML string.
* @param string $attr Attribute name.
* @param string $value Attribute value.
*
* @return string
*/
private static function add_data_attr_to_first_tag( $html, $attr, $value ) {
return preg_replace(
'/<([a-z0-9-]+)/i',
'<$1 ' . esc_attr( $attr ) . '="' . esc_attr( $value ) . '"',
$html,
1
);
}
/*
---------------------------
Gravity Forms merge tag replacement
---------------------------
*/
/**
* Replaces merge tags in the provided text with the corresponding values.
*
* @param string $text The text containing merge tags to be replaced.
*
* @return string The text with the merge tags replaced by their corresponding values.
*/
public static function gf_replace_merge_tags( $text ) {
// Support {abtestversion:slug}
if ( ! str_contains( $text, '{abtestversion:' ) ) {
return $text;
}
return preg_replace_callback(
'/{abtestversion:([a-z0-9-_]+)}/i',
function ( $m ) {
$slug = self::normalize_slug( $m[1] );
return esc_html( self::get_token( $slug ) );
},
$text
);
}
/**
* Dynamic population for any field parameter that starts with "abtestversion_".
* Example parameter name: abtestversion_pricinghero
*
* @param string $value value.
* @param object $field field.
* @param string $name Name.
* @return string
*/
public static function gf_dynamic_population( $value, $field, $name ) {
if ( stripos( $name, 'abtestversion_' ) !== 0 ) {
return $value;
}
$slug = substr( $name, strlen( 'abtestversion_' ) );
$slug = self::normalize_slug( $slug );
if ( '' === $slug ) {
return $value;
}
// We add a data-attribute so JS can find this input and update it correctly.
// Note: Using a unique hook name per field to avoid conflicts with multiple A/B tests on one form.
add_filter(
'gform_field_content',
function ( $field_content, $field_obj ) use ( $slug, $field ) {
if ( (int) $field_obj->id === (int) $field->id ) {
// We look for name="input_X" and inject data-abtest-slug
return str_replace( 'name="input_', 'data-abtest-slug="' . esc_attr( $slug ) . '" name="input_', $field_content );
}
return $field_content;
},
10,
2
);
return self::get_token( $slug );
}
}
BL_ABTest::init();