, pattern: string, case: string, example: string} * @phpstan-type Platform array{label: string, company: string, id: IdField, conversion: ConversionField|null, events: list, vendor_events: array, renamable: array>, script: string, hosts: list, service_url: string, docs_url: string, terms_url: string, privacy_url: string} */ final class Platforms { /** * Every normalized event, in the order they are sent. * * @var list */ public const EVENTS = array( 'page_view', 'view_content', 'search', 'lead', 'contact' ); /** * The four events an owner can switch on or off (page_view is always on). * * @var list */ public const CHOICES = array( 'view_content', 'search', 'lead', 'contact' ); /** * The characters trimmed from the ends of an ID: PHP's trim() defaults and * the form feed, so the trim agrees with the `\s` of the REST pattern. * * @var string */ private const TRIM = " \t\n\r\0\x0B\f"; /** * Pinterest's supported event names, case-insensitive (research section 4). * * @var list */ private const PINTEREST_EVENTS = array( 'pagevisit', 'viewcategory', 'search', 'addtocart', 'checkout', 'watchvideo', 'signup', 'lead', 'custom', 'addpaymentinfo', 'addtowishlist', 'initiatecheckout', 'subscribe', 'viewcontent', 'contact', 'schedule', 'findlocation', 'customizeproduct', 'submitapplication', 'starttrial', ); /** * Snap's supported event names (research section 6). There is no LEAD or * CONTACT event. * * @var list */ private const SNAP_EVENTS = array( 'PAGE_VIEW', 'VIEW_CONTENT', 'SEARCH', 'SIGN_UP', 'LOGIN', 'SUBSCRIBE', 'START_TRIAL', 'ADD_CART', 'START_CHECKOUT', 'PURCHASE', 'SAVE', 'SHARE', 'INVITE', 'RESERVE', 'RATE', 'LIST_VIEW', 'VIEW_CART', 'REMOVE_FROM_CART', 'ADD_TO_WISHLIST', 'ADD_BILLING', 'AD_CLICK', 'AD_VIEW', 'APP_INSTALL', 'APP_OPEN', 'COMPLETE_TUTORIAL', 'LEVEL_COMPLETE', 'ACHIEVEMENT_UNLOCKED', 'SPENT_CREDITS', 'CUSTOM_EVENT_1', 'CUSTOM_EVENT_2', 'CUSTOM_EVENT_3', 'CUSTOM_EVENT_4', 'CUSTOM_EVENT_5', ); /** * The registry, keyed by platform and in the order the settings tab lists * them: Meta, Google Ads, TikTok, LinkedIn, Microsoft Advertising, * Pinterest, Snap, X. * * @return array */ public static function all(): array { static $platforms = null; if ( null === $platforms ) { $platforms = self::build(); } return $platforms; } /** * The platform keys, in registry order. * * @return list */ public static function keys(): array { return array_keys( self::all() ); } /** * One platform, or null for a key that is not in the registry. * * @param string $key Platform key. * @return Platform|null */ public static function get( string $key ): ?array { return self::all()[ $key ] ?? null; } /** * Every host any platform loads from or sends to, each once, for the * readme's External services table. * * @return list */ public static function hosts(): array { $hosts = array(); foreach ( self::all() as $platform ) { $hosts = array_merge( $hosts, $platform['hosts'] ); } return array_values( array_unique( $hosts ) ); } /** * Validates and normalizes a pixel ID: trimmed, in the platform's case. * * @param string $key Platform key. * @param mixed $value The proposed ID. * @return string|null The ID to store ('' means "no ID yet"), or null when the value is not an ID for this platform. */ public static function normalize_id( string $key, mixed $value ): ?string { $platform = self::get( $key ); return null === $platform ? null : self::normalize( $platform['id'], $value ); } /** * Validates and normalizes a conversion ID or label (Google Ads, LinkedIn * and X): the value an owner copies from the ad platform for lead or * contact. * * @param string $key Platform key. * @param mixed $value The proposed value. * @return string|null The value to store ('' means "none yet"), or null when invalid or when the platform has no conversion fields. */ public static function normalize_conversion( string $key, mixed $value ): ?string { $platform = self::get( $key ); if ( null === $platform || null === $platform['conversion'] ) { return null; } return self::normalize( $platform['conversion'], $value ); } /** * The JSON-schema pattern the REST API checks an ID or conversion value * with: the platform's pattern, case-insensitive where the platform * normalizes case, with spaces around it and an empty value allowed. The * same values pass normalize_id() and normalize_conversion(). * * @param string $key Platform key. * @param string $field 'id' or 'conversion'. */ public static function rest_pattern( string $key, string $field ): string { $platform = self::get( $key ); $spec = 'id' === $field ? ( $platform['id'] ?? null ) : ( $platform['conversion'] ?? null ); if ( null === $spec ) { return '^\s*$'; } $core = 'none' === $spec['case'] ? $spec['pattern'] : '(?i:' . $spec['pattern'] . ')'; return '^\s*(' . $core . ')?\s*$'; } /** * The stored group for every platform, all off: what Settings starts from. * A platform with conversion fields also has an empty `conversions` pair. * `custom_events` is null while the platform follows the shared events. * * @return array> */ public static function defaults(): array { $defaults = array(); foreach ( self::all() as $key => $platform ) { $row = array( 'enabled' => false, 'id' => '', ); if ( null !== $platform['conversion'] ) { $row['conversions'] = array_fill_keys( $platform['conversion']['events'], '' ); } $row['custom_events'] = null; $defaults[ $key ] = $row; } return $defaults; } /** * The shared event choices, all on. * * @return array */ public static function default_events(): array { return array_fill_keys( self::CHOICES, true ); } /** * What the settings screen needs about each platform, as plain data and in * registry order. This is the contract the JavaScript model tests read * (tests/fixtures/pixels/platforms.json): the patterns are written for the * normalized value, so a client applies `idCase` first and then anchors * the pattern with ^ and $. * * @return list, conversion: ConversionField|null, docsUrl: string}> */ public static function ui_payload(): array { $payload = array(); foreach ( self::all() as $key => $platform ) { $payload[] = array( 'key' => $key, 'label' => $platform['label'], 'idPattern' => $platform['id']['pattern'], 'idCase' => $platform['id']['case'], 'idExample' => $platform['id']['example'], 'events' => $platform['events'], 'conversion' => $platform['conversion'], 'docsUrl' => $platform['docs_url'], ); } return $payload; } /** * Trims, cases and matches one value against a field's pattern. * * @param IdField|ConversionField $field The field's rules. * @param mixed $value The proposed value. * @return string|null The normalized value, '' for an empty one, or null when it does not match. */ private static function normalize( array $field, mixed $value ): ?string { if ( ! is_string( $value ) ) { return null; } $value = trim( $value, self::TRIM ); if ( '' === $value ) { return ''; } if ( 'upper' === $field['case'] ) { $value = strtoupper( $value ); } elseif ( 'lower' === $field['case'] ) { $value = strtolower( $value ); } return 1 === preg_match( '/^' . $field['pattern'] . '\z/', $value ) ? $value : null; } /** * Builds the registry. * * @return array */ private static function build(): array { $all_events = self::EVENTS; return array( 'meta' => array( 'label' => 'Meta Pixel', 'company' => 'Meta', // Section 1, "ID": digits only; 10 to 20 allows real IDs (15 or 16) and any future length. 'id' => array( 'pattern' => '[0-9]{10,20}', 'case' => 'none', 'example' => '1234567890123456', ), 'conversion' => null, 'events' => $all_events, // Section 1, "Events" and section 11. 'vendor_events' => array( 'view_content' => 'ViewContent', 'search' => 'Search', 'lead' => 'Lead', 'contact' => 'Contact', ), 'renamable' => array(), 'script' => 'https://connect.facebook.net/en_US/fbevents.js', // Section 1, "Hosts": the library and config scripts, the collection endpoint, and Instagram attribution (used conditionally). 'hosts' => array( 'connect.facebook.net', 'www.facebook.com', 'www.instagram.com' ), 'service_url' => 'https://www.facebook.com/business/tools/meta-pixel', 'docs_url' => 'https://www.facebook.com/business/help/952192354843755', 'terms_url' => 'https://www.facebook.com/legal/technology_terms', 'privacy_url' => 'https://www.facebook.com/privacy/policy/', ), 'gads' => array( 'label' => 'Google Ads', 'company' => 'Google', // Not in the research: the design spec's amendment, AW- plus 6 to 12 digits. 'id' => array( 'pattern' => 'AW-[0-9]{6,12}', 'case' => 'upper', 'example' => 'AW-123456789', ), // The spec's amendment: labels are 4 to 40 letters, digits, _ or -. They count conversions, so view_content and search are never sent. 'conversion' => array( 'events' => array( 'lead', 'contact' ), 'pattern' => '[A-Za-z0-9_-]{4,40}', 'case' => 'none', 'example' => 'AbC-dEfGhIjKlMnO', ), 'events' => array( 'page_view', 'lead', 'contact' ), 'vendor_events' => array(), 'renamable' => array(), // Google Ads shares the Google tag's gtag.js; the ID is appended as ?id=AW-... 'script' => 'https://www.googletagmanager.com/gtag/js', // Not in the research: gtag.js, then the hosts Ads conversion and remarketing hits go to (PR #9 review). 'hosts' => array( 'www.googletagmanager.com', 'www.googleadservices.com', 'googleads.g.doubleclick.net', 'www.google.com' ), 'service_url' => 'https://ads.google.com', 'docs_url' => 'https://support.google.com/google-ads/answer/6095821', 'terms_url' => 'https://policies.google.com/terms', 'privacy_url' => 'https://policies.google.com/privacy', ), 'tiktok' => array( 'label' => 'TikTok Pixel', 'company' => 'TikTok', // Section 2, "ID": exactly 20 characters, A-Z and 0-9; upper-case the input first. 'id' => array( 'pattern' => '[A-Z0-9]{20}', 'case' => 'upper', 'example' => 'CA1B2C3D4E5F6G7H8I9J', ), 'conversion' => null, 'events' => $all_events, // Section 2, "Events": SubmitForm was renamed Lead in 2025, so Lead. 'vendor_events' => array( 'view_content' => 'ViewContent', 'search' => 'Search', 'lead' => 'Lead', 'contact' => 'Contact', ), 'renamable' => array(), 'script' => 'https://analytics.tiktok.com/i18n/pixel/events.js', // Section 2: the script and events, the IPv6 enrichment plugin ("Hosts"), and the host of the third-party _pangle cookie ("Cookies": `_pangle` on analytics.pangle-ads.com, line 234 of the research). 'hosts' => array( 'analytics.tiktok.com', 'analytics-ipv6.tiktokw.us', 'analytics.pangle-ads.com' ), 'service_url' => 'https://ads.tiktok.com/resources/help/article/tiktok-pixel', 'docs_url' => 'https://ads.tiktok.com/resources/help/article/tiktok-pixel', 'terms_url' => 'https://ads.tiktok.com/i18n/official/policy/business-products-terms', 'privacy_url' => 'https://www.tiktok.com/legal/page/row/privacy-policy/en', ), 'linkedin' => array( 'label' => 'LinkedIn Insight Tag', 'company' => 'LinkedIn', // Section 3, "ID": the partner ID is numeric, 3 to 10 digits. 'id' => array( 'pattern' => '[0-9]{3,10}', 'case' => 'none', 'example' => '1234567', ), // Section 3, "Events": no standard names; each conversion is an ID the owner creates in Campaign Manager. The length bound is ours (the research example is 5577106). 'conversion' => array( 'events' => array( 'lead', 'contact' ), 'pattern' => '[0-9]{3,12}', 'case' => 'none', 'example' => '5577106', ), 'events' => array( 'page_view', 'lead', 'contact' ), 'vendor_events' => array(), 'renamable' => array(), 'script' => 'https://snap.licdn.com/li.lms-analytics/insight.min.js', 'hosts' => array( 'snap.licdn.com', 'px.ads.linkedin.com' ), 'service_url' => 'https://business.linkedin.com/advertise/ads/insight-tag', 'docs_url' => 'https://www.linkedin.com/help/lms/answer/a418880', 'terms_url' => 'https://www.linkedin.com/legal/user-agreement', 'privacy_url' => 'https://www.linkedin.com/legal/privacy-policy', ), 'uet' => array( 'label' => 'Microsoft Advertising UET tag', 'company' => 'Microsoft', // Section 7, "ID": numeric. 'id' => array( 'pattern' => '[0-9]{4,12}', 'case' => 'none', 'example' => '123456789', ), 'conversion' => null, 'events' => $all_events, // Section 7, "Events": GA-style action names; a conversion only counts once the owner creates a matching goal. 'vendor_events' => array( 'view_content' => 'view_content', 'search' => 'search', 'lead' => 'generate_lead', 'contact' => 'contact', ), 'renamable' => array(), 'script' => 'https://bat.bing.com/bat.js', // Section 7, "Hosts": bat.bing.com for the script and hits, bat.bing.net for errors. 'hosts' => array( 'bat.bing.com', 'bat.bing.net' ), 'service_url' => 'https://ads.microsoft.com', 'docs_url' => 'https://learn.microsoft.com/en-us/advertising/msa-help/hlp_ba_proc_uetv2addtag', 'terms_url' => 'https://about.ads.microsoft.com/en/resources/policies/microsoft-advertising-agreement', 'privacy_url' => 'https://privacy.microsoft.com/privacystatement', ), 'pinterest' => array( 'label' => 'Pinterest Tag', 'company' => 'Pinterest', // Section 4, "ID": a 13-digit number (a 12-digit one is an advertiser ID). 'id' => array( 'pattern' => '[0-9]{13}', 'case' => 'none', 'example' => '2612345678901', ), 'conversion' => null, 'events' => $all_events, // Section 4, "Events": view_content is pagevisit; contact is audience-only (it does not count as a conversion). 'vendor_events' => array( 'view_content' => 'pagevisit', 'search' => 'search', 'lead' => 'lead', 'contact' => 'contact', ), // Advertisers who want contact conversions can send it as another event (research section 12, point 4). 'renamable' => array( 'contact' => self::PINTEREST_EVENTS ), 'script' => 'https://s.pinimg.com/ct/core.js', 'hosts' => array( 's.pinimg.com', 'ct.pinterest.com' ), 'service_url' => 'https://business.pinterest.com', 'docs_url' => 'https://help.pinterest.com/en/business/article/install-the-base-code', 'terms_url' => 'https://policy.pinterest.com/en/terms-of-service', 'privacy_url' => 'https://policy.pinterest.com/en/privacy-policy', ), 'snapchat' => array( 'label' => 'Snap Pixel', 'company' => 'Snap', // Section 6, "ID": a UUID, case-insensitive; stored lower-case. 'id' => array( 'pattern' => '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}', 'case' => 'lower', 'example' => '6abc82ca-4a3a-4391-98ba-0317a8471234', ), 'conversion' => null, // Section 6, "Events": there is no CONTACT event, so contact is not sent to Snap. 'events' => array( 'page_view', 'view_content', 'search', 'lead' ), // SIGN_UP is the closest standard event to a lead. 'vendor_events' => array( 'view_content' => 'VIEW_CONTENT', 'search' => 'SEARCH', 'lead' => 'SIGN_UP', ), // Whether SIGN_UP or a CUSTOM_EVENT_n suits the campaign is the advertiser's call (research section 12, point 3). 'renamable' => array( 'lead' => self::SNAP_EVENTS ), 'script' => 'https://sc-static.net/scevent.min.js', // Section 6, "Hosts": the script, and the collection hosts. 'hosts' => array( 'sc-static.net', 'tr.snapchat.com', 'tr6.snapchat.com', 'tr-shadow.snapchat.com' ), 'service_url' => 'https://businesshelp.snapchat.com/s/article/snap-pixel-about', 'docs_url' => 'https://businesshelp.snapchat.com/s/article/snap-pixel-about', 'terms_url' => 'https://www.snap.com/terms', 'privacy_url' => 'https://values.snap.com/privacy/privacy-policy', ), 'x' => array( 'label' => 'X Pixel', 'company' => 'X', // Section 5, "ID": 5 to 10 lower-case letters and digits; lower-case the input first. 'id' => array( 'pattern' => '[a-z0-9]{5,10}', 'case' => 'lower', 'example' => 'o6ou1', ), // Section 5, "Events": every conversion needs an event ID the owner creates, tw--. 'conversion' => array( 'events' => array( 'lead', 'contact' ), 'pattern' => 'tw-[a-z0-9]{5,10}-[a-z0-9]{5,10}', 'case' => 'lower', 'example' => 'tw-o6ou1-o9l96', ), 'events' => array( 'page_view', 'lead', 'contact' ), 'vendor_events' => array(), 'renamable' => array(), 'script' => 'https://static.ads-twitter.com/uwt.js', // Section 5: the script, and the hit hosts (t.co as well as analytics.twitter.com). 'hosts' => array( 'static.ads-twitter.com', 'analytics.twitter.com', 't.co', 'ads-twitter.com', 'ads-api.twitter.com' ), 'service_url' => 'https://business.x.com', 'docs_url' => 'https://help.x.com/en/business-and-advertising/conversion-tracking-for-websites', 'terms_url' => 'https://x.com/en/tos', 'privacy_url' => 'https://x.com/en/privacy', ), ); } }