: { id, events, conversions?, vendor_events? } } * ga4 { id, events } | null the Google tag's share * lead { plugin, id, t } | null a form sent in this request */ final class PixelRenderer implements Integration { /** * The core bundle's script handle. * * @var string */ public const HANDLE = 'tagsmith-pixels'; /** * The preview panel bundle's script handle. * * @var string */ public const PREVIEW_HANDLE = 'tagsmith-pixels-preview'; /** * The config's format version. * * @var int */ private const CONFIG_VERSION = 1; /** * Settings store. * * @var Settings */ private Settings $settings; /** * The shared tracking rules. * * @var Gate */ private Gate $gate; /** * Whether this is a manager's preview. * * @var PreviewMode */ private PreviewMode $preview; /** * The form lead of this request, if any. * * @var LeadSignal */ private LeadSignal $lead; /** * What must be met before this is registered. * * @var list */ private array $conditions; /** * The folder holding the built bundles and their asset files. * * @var string */ private string $build_dir; /** * The URL of that folder. * * @var string */ private string $build_url; /** * Constructor. * * @param Settings $settings Settings store. * @param Gate $gate The shared tracking rules. * @param PreviewMode $preview The manager's preview. * @param LeadSignal $lead The lead signal. * @param Condition[] $conditions What must be met first (the front end, a pixel on). * @param string|null $build_dir The build folder; the plugin's build/ when not given. * @param string|null $build_url The build folder's URL; the plugin's build/ when not given. */ public function __construct( Settings $settings, Gate $gate, PreviewMode $preview, LeadSignal $lead, array $conditions, ?string $build_dir = null, ?string $build_url = null ) { $this->settings = $settings; $this->gate = $gate; $this->preview = $preview; $this->lead = $lead; $this->conditions = $conditions; $this->build_dir = $build_dir ?? TAGSMITH_DIR . 'build/'; $this->build_url = $build_url ?? TAGSMITH_URL . 'build/'; } /** * The conditions this integration waits for. * * @return list */ public function conditions(): array { return $this->conditions; } /** * Hooks the enqueue, and the preview's no-cache step. */ public function register(): void { $this->preview->register(); add_action( 'wp_enqueue_scripts', array( $this, 'enqueue' ) ); } /** * Enqueues the bundle and adds its config. Runs on wp_enqueue_scripts, so * the page's conditional tags are available. */ public function enqueue(): void { $settings = $this->settings->all(); $platforms = Events::active( $settings ); if ( array() === $platforms || wp_script_is( self::HANDLE, 'enqueued' ) ) { return; } $preview = $this->preview->is_active(); if ( ! $this->may_print( $preview ) ) { return; } $core = $this->asset( 'pixels' ); if ( null === $core ) { return; } $config = $this->config( $settings, $platforms, $preview ); // An empty page must reach the script as an object, not as a list. $config['page'] = array() === $config['page'] ? new \stdClass() : $config['page']; $args = array( 'strategy' => 'defer', 'in_footer' => true, ); wp_enqueue_script( self::HANDLE, $this->build_url . 'pixels.js', $core['dependencies'], $core['version'], $args ); wp_add_inline_script( self::HANDLE, 'window.tagsmithPixels = ' . Json::for_config( $config ) . ';', 'before' ); if ( ! $preview ) { return; } $panel = $this->asset( 'pixels-preview' ); if ( null === $panel ) { return; } wp_enqueue_script( self::PREVIEW_HANDLE, $this->build_url . 'pixels-preview.js', array_merge( array( self::HANDLE ), $panel['dependencies'] ), $panel['version'], $args ); wp_set_script_translations( self::PREVIEW_HANDLE, 'tagsmith' ); if ( is_readable( $this->build_dir . 'pixels-preview.css' ) ) { wp_enqueue_style( self::PREVIEW_HANDLE, $this->build_url . 'pixels-preview.css', array(), $panel['version'] ); } } /** * Whether this request may carry the pixels: the request rule always * (not a feed, an AJAX or REST call, a post preview), then the * environment and the visitor, which a manager's preview skips. * * @param bool $preview Whether this is a manager's preview. */ private function may_print( bool $preview ): bool { if ( ! $this->gate->request_allows() ) { return false; } return $preview || ( $this->gate->environment_allows() && $this->gate->visitor_allows() ); } /** * A built bundle's asset file: its script dependencies and version, or * null when it has not been built. * * @param string $entry The entry name. * @return array{dependencies: list, version: string}|null */ private function asset( string $entry ): ?array { $file = $this->build_dir . $entry . '.asset.php'; if ( ! is_readable( $file ) ) { return null; } $asset = require $file; if ( ! is_array( $asset ) ) { return null; } return array( 'dependencies' => array_values( array_map( 'strval', (array) ( $asset['dependencies'] ?? array() ) ) ), 'version' => (string) ( $asset['version'] ?? TAGSMITH_VERSION ), ); } /** * Builds the config and passes it through the tagsmith_pixels_config * filter. * * @param array $settings The settings. * @param array> $platforms The platforms that are on, with their events. * @param bool $preview Whether this is a manager's preview. * @return array */ private function config( array $settings, array $platforms, bool $preview ): array { $config = array( 'v' => self::CONFIG_VERSION, 'consent' => array( 'mode' => (string) ( $settings['consent_mode'] ?? 'advanced' ), 'region' => (string) ( $settings['consent_region_default'] ?? 'eea_denied' ), ), 'preview' => $preview, 'page' => $this->page( $platforms ), 'platforms' => $this->platforms( $settings, $platforms ), 'ga4' => Events::for_ga4( $settings ), 'lead' => $this->lead->pending(), ); /** * Filters the ad pixels config printed as `window.tagsmithPixels`. * * You can only narrow what the config says; nothing you return can add to * it. Two parts of what you return are used: `events` of a platform * that is already in `platforms` (an event you leave out is not * sent; one you add is ignored; page_view always stays), and `page` * (the page facts, `content` with title, type and category, and * `search`), which is cleaned again: tags are removed, text is cut to * 100 characters, and a text with an email address or a run of seven * or more digits is dropped. A page fact is kept only while some * platform still sends the event that asks for it (`content` for * view_content, `search` for search). Everything else (IDs, consent, * preview, the lead, and the `sensitive` flag in `page`) is ignored, and * platforms cannot be added or removed. * * @since 2.1.0 * * @param array $config The config: v, consent, preview, page, platforms, ga4 and lead. */ $filtered = apply_filters( 'tagsmith_pixels_config', $config ); // Whatever a callback returns, only the parts below are read, and a value that is not a config has none of them. return $this->apply_filtered( $config, (array) $filtered, $settings ); } /** * Takes the parts of a filtered config that the filter may change, and * only to narrow what was built: a platform keeps the events the filter * still lists (page_view always stays), and never gains one. The page * facts the filter returns are cleaned again, and a fact is kept only * while some platform still sends the event that asks for it. * * @param array $config The config as built. * @param array $filtered What the filter returned. * @param array $settings The settings. * @return array */ private function apply_filtered( array $config, array $filtered, array $settings ): array { // Decided by the page's own search term; the filter can neither clear nor raise it. $sensitive = ! empty( $config['page']['sensitive'] ); foreach ( $config['platforms'] as $key => $entry ) { $wanted = $filtered['platforms'][ $key ]['events'] ?? null; if ( ! is_array( $wanted ) ) { continue; } $kept = array_values( array_filter( $entry['events'], static fn( string $event ): bool => 'page_view' === $event || in_array( $event, $wanted, true ) ) ); if ( $kept === $entry['events'] ) { continue; } $config['platforms'][ $key ]['events'] = $kept; unset( $config['platforms'][ $key ]['conversions'] ); $conversions = $this->conversions( $key, $settings, $kept ); if ( array() !== $conversions ) { $config['platforms'][ $key ]['conversions'] = $conversions; } } if ( isset( $filtered['page'] ) && is_array( $filtered['page'] ) ) { $config['page'] = $this->clean_page( $filtered['page'] ); } $config['page'] = $this->only_sent( $config['page'], array_column( $config['platforms'], 'events' ) ); if ( $sensitive ) { $config['page']['sensitive'] = true; } return $config; } /** * The page facts, only for the events some platform sends, and the * `sensitive` flag of a search page whose term is personal, which does not * depend on any event. * * @param array> $platforms The platforms that are on, with their events. * @return array{content?: array{title: string, type: string, category: string}, search?: string, sensitive?: true} */ private function page( array $platforms ): array { return $this->only_sent( PageContext::for_request(), array_values( $platforms ) ); } /** * Drops the page facts no platform asks for: `content` unless some * platform sends view_content, `search` unless one sends search. The * `sensitive` flag is kept. * * @param array $page The page facts. * @param array $platforms Each platform's events. * @return array{content?: array{title: string, type: string, category: string}, search?: string, sensitive?: true} */ private function only_sent( array $page, array $platforms ): array { $sent = array_merge( array(), ...$platforms ); if ( ! in_array( 'view_content', $sent, true ) ) { unset( $page['content'] ); } if ( ! in_array( 'search', $sent, true ) ) { unset( $page['search'] ); } return $page; } /** * Cleans page facts a filter returned, with the same rule as our own: * known fields only, each through PageContext::clean(). * * @param array $page The page facts. * @return array{content?: array{title: string, type: string, category: string}, search?: string} */ private function clean_page( array $page ): array { $clean = array(); if ( isset( $page['content'] ) && is_array( $page['content'] ) ) { $content = array(); foreach ( array( 'title', 'type', 'category' ) as $field ) { $value = $page['content'][ $field ] ?? ''; $content[ $field ] = is_string( $value ) ? PageContext::clean( $value ) : ''; } $clean['content'] = $content; } if ( isset( $page['search'] ) && is_string( $page['search'] ) ) { $term = PageContext::clean( $page['search'] ); if ( '' !== $term ) { $clean['search'] = $term; } } return $clean; } /** * Each platform that is on: its ID, events, conversion values and * renamable vendor event names. * * @param array $settings The settings. * @param array> $platforms The platforms that are on, with their events. * @return array> */ private function platforms( array $settings, array $platforms ): array { $names = $this->vendor_events( array_keys( $platforms ) ); $entries = array(); foreach ( $platforms as $key => $events ) { $entry = array( 'id' => (string) Platforms::normalize_id( $key, $settings['pixels'][ $key ]['id'] ?? '' ), 'events' => $events, ); $conversions = $this->conversions( $key, $settings, $events ); if ( array() !== $conversions ) { $entry['conversions'] = $conversions; } if ( isset( $names[ $key ] ) ) { $entry['vendor_events'] = $names[ $key ]; } $entries[ $key ] = $entry; } return $entries; } /** * A platform's conversion IDs or labels, for the events it sends. * * @param string $key Platform key. * @param array $settings The settings. * @param string[] $events The events the platform sends. * @return array */ private function conversions( string $key, array $settings, array $events ): array { $spec = Platforms::get( $key )['conversion'] ?? null; if ( null === $spec ) { return array(); } $conversions = array(); foreach ( $spec['events'] as $event ) { $value = Platforms::normalize_conversion( $key, $settings['pixels'][ $key ]['conversions'][ $event ] ?? '' ); if ( in_array( $event, $events, true ) && null !== $value && '' !== $value ) { $conversions[ $event ] = $value; } } return $conversions; } /** * The vendor event names that can be renamed, for the platforms that are * on: Pinterest's contact and Snap's lead, with their defaults, passed * through the tagsmith_pixel_vendor_events filter. A returned name is used * only when the vendor's own library lists it (see Platforms). * * @param string[] $keys The platforms that are on. * @return array> */ private function vendor_events( array $keys ): array { $names = array(); foreach ( $keys as $key ) { $platform = Platforms::get( $key ); if ( null !== $platform && array() !== $platform['renamable'] ) { $names[ $key ] = array_intersect_key( $platform['vendor_events'], $platform['renamable'] ); } } if ( array() === $names ) { return array(); } /** * Filters the vendor event names of the ad platforms that let you * choose: Pinterest's `contact` (it only builds audiences, so * advertisers who want contact conversions can send `lead`), and * Snap's `lead` (sent as SIGN_UP, because Snap has no lead event; * CUSTOM_EVENT_1 to CUSTOM_EVENT_5 are the other choice). * * Return the same array with the names changed. A name the vendor's * own library does not list is ignored, as is a platform that is off * and an event that is not in the array. * * @since 2.1.0 * * @param array> $names The vendor event name for each of the platform's events that can be renamed, by platform and normalized event. Default: Pinterest `contact` and Snap `lead`, for the platforms that are on. */ $filtered = apply_filters( 'tagsmith_pixel_vendor_events', $names ); if ( ! is_array( $filtered ) ) { return $names; } foreach ( $names as $key => $events ) { foreach ( array_keys( $events ) as $event ) { $candidate = $filtered[ $key ][ $event ] ?? null; if ( ! is_string( $candidate ) ) { continue; } foreach ( Platforms::get( $key )['renamable'][ $event ] ?? array() as $allowed ) { if ( 0 === strcasecmp( $allowed, $candidate ) ) { $names[ $key ][ $event ] = $allowed; break; } } } } return $names; } }