1/** 2 * Resolve Elementor lead-source visibility rules, in the browser. 3 * 4 * ============================================================================ 5 * WHY THE BROWSER AND NOT PHP. Pages are cached by WP Engine for 180 days and 6 * again by Cloudflare, so a server-side decision would be baked into the cached 7 * HTML: the first visitor's attribution would pick a version and everybody 8 * after them would be served it. See ElementorVisibility.php for the headers. 9 * 10 * ============================================================================ 11 * THIS HIDES CONTENT, IT DOES NOT PROTECT IT. The markup is always sent. Hidden 12 * elements are in view-source and in the DOM. Nothing confidential belongs 13 * behind one of these rules. 14 * 15 * ============================================================================ 16 * THE ORDER OF EVENTS MATTERS MORE THAN ANYTHING ELSE HERE. 17 * 18 * "show only if" is hidden by CSS printed in the head, so it is never 19 * painted. This script reveals it on a match. 20 * 21 * "hide if" renders visible, because the page must be usable without 22 * JavaScript. This script removes it on a match, and runs as 23 * early as it can - every frame it is visible is a frame 24 * somebody sees the wrong copy. 25 * 26 * So this runs at parse time in the head, resolves whatever is already in the 27 * DOM, and then watches for the rest. It does NOT wait for DOMContentLoaded: 28 * waiting would guarantee the flash it exists to avoid. 29 */ 30(function () { 31 'use strict'; 32
33 var ATTR = 'data-pmip-vis'; 34 35 // THE TRACKER MAY NOT HAVE LOADED YET, and this deliberately does not 36 // depend on it - a dependency would order this after it and cost the 37 // early paint. So: try, and if its API is not there yet, try again on 38 // DOMContentLoaded and once more on load. Three attempts covers a deferred 39 // tracker without polling forever. 40 var tiers = null; 41 var resolved = false; 42 43 function readTiers() { 44 try { 45 if (window.ParkerUtmTracking && typeof window.ParkerUtmTracking.derive === 'function') { 46 return window.ParkerUtmTracking.derive() || null; 47 } 48 } catch (e) { /* a broken tracker must not take the page with it */ } 49 50 return null; 51 } 52 53 /** 54 * Does this element's rule match the visitor? 55 * 56 * TIER 4 MATCHES ON CONTAINS, the rest on equals. Tier 4 is free text 57 * holding the campaign - real values look like "spring-dc-campaign | 58 * edu-chat-bot", and nobody is going to type that into a rule. Tiers 1-3 59 * are restricted picklists, where an exact value is the only correct test. 60 */ 61 function matches(tier, wanted, actual) { 62 if (!actual) { return false; } 63 64 var i; 65 66 if (tier === 'tier4') { 67 var haystack = String(actual).toLowerCase(); 68 for (i = 0; i < wanted.length; i++) { 69 if (haystack.indexOf(String(wanted[i]).toLowerCase()) !== -1) { 70 return true; 71 } 72 } 73 return false; 74 } 75 76 for (i = 0; i < wanted.length; i++) { 77 if (String(wanted[i]) === String(actual)) { 78 return true; 79 } 80 } 81 82 return false; 83 } 84 85 /** 86 * Do ALL of an element's conditions hold? 87 * 88 * TIERS NARROW, THEY DO NOT WIDEN. Tier 1 "Paid" with Tier 2 "Paid Search" 89 * means paid search - so every condition must hold, and one that fails 90 * settles it. 91 * 92 * VALUES WITHIN A TIER ARE THE OTHER WAY ROUND: any one counts, because a 93 * visitor has exactly one value per tier and requiring two would describe 94 * nobody. That asymmetry is the whole grammar, and it is not arbitrary - it 95 * follows from a tier holding one value at a time. 96 * 97 * NO CONDITIONS IS NOT A MATCH. An element that reached here with an empty 98 * rule set has no opinion, and callers treat that as "no rule" rather than 99 * as a rule that matches everybody. 100 */ 101 function matchesAll(rules) { 102 if (!rules || !rules.length) { return false; } 103 104 for (var i = 0; i < rules.length; i++) { 105 var rule = rules[i]; 106 var vals = rule && rule.values; 107 108 if (!vals || !vals.length) { return false; } 109 110 if (!matches(rule.tier, vals, tiers ? tiers[rule.tier] : '')) { 111 return false; 112 } 113 } 114 115 return true; 116 } 117 118 /** An element's conditions, parsed once. */ 119 function rulesOf(el) { 120 try { 121 var raw = el.getAttribute(ATTR + '-rules'); 122 var out = raw ? JSON.parse(raw) : []; 123 return Array.isArray(out) ? out : []; 124 } catch (e) { 125 return []; 126 } 127 } 128 129 function resolve(el) { 130 // ONCE PER ELEMENT. The observer can see the same node twice - once 131 // when it is added and again if an ancestor is replaced - and a second 132 // pass on an already-revealed element would re-hide it. 133 if (el.getAttribute('data-pmip-vis-done') === '1') { return; } 134 135 // GROUP MEMBERS ARE NOT RESOLVED HERE. Their visibility is decided by 136 // the draw, which needs the whole group; revealing one early on its 137 // tier rule alone would show two variants at once. 138 if (el.hasAttribute(AB)) { return; } 139 140 var mode = el.getAttribute(ATTR); 141 if (mode !== 'show' && mode !== 'hide') { return; } 142 143 var rules = rulesOf(el); 144 145 // A RULE WITH NO CONDITIONS IS NOT A RULE. "Show only if [nothing]" 146 // would hide the element from everybody; PHP already refuses to emit 147 // one, and this refuses to act on one that arrived anyway. 148 if (!rules.length) { 149 reveal(el); 150 return; 151 } 152 153 var hit = matchesAll(rules); 154 155 if (mode === 'show') { 156 if (hit) { reveal(el); } 157 // No match: it is already hidden by the head CSS. Left alone 158 // rather than hidden again - and deliberately NOT marked done, so 159 // a later attempt can still reveal it if the tracker was slow. 160 else if (resolved) { el.setAttribute('data-pmip-vis-done', '1'); } 161 return; 162 } 163 164 // hide 165 if (hit) { 166 el.style.setProperty('display', 'none', 'important'); 167 } 168 el.setAttribute('data-pmip-vis-done', '1'); 169 } 170 171 function reveal(el) { 172 el.removeAttribute(ATTR); 173 el.setAttribute('data-pmip-vis-done', '1'); 174 } 175 176 // ================================================================ 177 // A/B GROUPS. 178 // 179 // TARGETING DECIDES WHO IS ELIGIBLE, THIS DECIDES WHICH OF THEM SEES WHAT. 180 // Two variants carrying the same tier rule split that audience between 181 // them - a real test, because both groups are drawn at random from one 182 // population rather than being different people to begin with. 183 // 184 // A GROUP CANNOT BE RESOLVED ELEMENT BY ELEMENT, which is why it does not 185 // go through resolve(). The draw has to see every member before choosing 186 // one, so it waits for the document rather than running as the parser 187 // arrives. That costs nothing visually: group members are hidden by the 188 // head CSS regardless of their rule, so nothing is on screen to flash. 189 // ================================================================
190 var AB = 'data-pmip-ab'; 191 var AB_KEY = 'parker_ab'; 192 193 function readAssignments() { 194 try { 195 return JSON.parse(window.localStorage.getItem(AB_KEY) || '{}') || {}; 196 } catch (e) { return {}; } 197 } 198 199 function writeAssignments(map) { 200 try { 201 window.localStorage.setItem(AB_KEY, JSON.stringify(map)); 202 } catch (e) { /* private browsing - the draw still works, just per page */ } 203 } 204 205 /** Is this element eligible - that is, does its tier rule pass? */ 206 function eligible(el) { 207 var mode = el.getAttribute(ATTR); 208 209 // NO TIER RULE MEANS ELIGIBLE FOR EVERYBODY. That is how a default 210 // variant is expressed: it wins whenever the targeted ones do not 211 // apply, so a targeted group still shows something to everyone else. 212 if (mode !== 'show' && mode !== 'hide') { return true; } 213 214 var rules = rulesOf(el); 215 216 if (!rules.length) { return true; } 217 218 var hit = matchesAll(rules); 219 220 return mode === 'show' ? hit : !hit; 221 } 222 223 /** 224 * Pick one member by weight. 225 * 226 * WEIGHTS ARE RELATIVE, NOT PERCENTAGES - 50/50 and 1/1 are the same split, 227 * and nothing has to add up to 100. A total of zero means every member was 228 * retired, which is a real state (somebody set every weight to 0) and must 229 * not divide by zero: it returns nothing and the group shows nothing, which 230 * is what "no variant is running" should look like. 231 */ 232 function draw(members) { 233 var total = 0, i; 234 235 for (i = 0; i < members.length; i++) { 236 total += Math.max(0, parseInt(members[i].getAttribute(AB + '-weight'), 10) || 0); 237 } 238 239 if (total <= 0) { return null; } 240 241 var roll = Math.random() * total; 242 243 for (i = 0; i < members.length; i++) { 244 roll -= Math.max(0, parseInt(members[i].getAttribute(AB + '-weight'), 10) || 0); 245 if (roll < 0) { return members[i]; } 246 } 247 248 return members[members.length - 1]; 249 } 250 251 /** 252 * The sticky decision, as a function so it can be exercised rather than 253 * read. 254 * 255 * WHY STICKINESS IS THE WHOLE TEST. A visitor who saw variant B on the 256 * landing page and C on the next has seen neither, and the numbers measure 257 * the coin rather than the content. Everything else here is plumbing; this 258 * is the property that makes the result mean anything. 259 * 260 * RE-DRAWN ONLY WHEN THE STORED CHOICE IS NO LONGER ELIGIBLE, which happens 261 * when somebody returns through a different campaign. Keeping it would show 262 * paid-search copy to an organic visit; re-drawing every time would destroy 263 * the stickiness above. 264 * 265 * @param {Array} open Eligible members, in page order. 266 * @param {string} want The variant name stored for this visitor, if any. 267 * @return {{pick: (Object|null), fresh: boolean}} fresh = newly drawn, so 268 * the caller knows whether anything needs storing. 269 */ 270 function pickVariant(open, want) { 271 for (var i = 0; i < open.length; i++) { 272 if (want && open[i].getAttribute(AB + '-variant') === want) { 273 return { pick: open[i], fresh: false }; 274 } 275 } 276 277 var drawn = draw(open); 278 279 return { pick: drawn, fresh: !!drawn }; 280 } 281 282 /** 283 * Resolve every group on the page. 284 * 285 * STICKY, AND THAT IS NOT A NICETY. A visitor who saw variant B on the 286 * landing page and variant C on the next one has seen neither, and the 287 * data says nothing - the test measures the coin rather than the content. 288 * So the choice is stored and reused. 289 * 290 * RE-DRAWN ONLY IF THE STORED CHOICE HAS BECOME INELIGIBLE, which happens 291 * when somebody returns through a different campaign. Keeping a now- 292 * ineligible variant would show paid-search copy to an organic visit; 293 * re-drawing every time would destroy the stickiness that makes the test 294 * readable. 295 */ 296 function resolveGroups() { 297 var nodes = document.querySelectorAll('[' + AB + ']'); 298 if (!nodes.length) { return; } 299 300 var groups = {}; 301 var i; 302 303 for (i = 0; i < nodes.length; i++) { 304 var name = nodes[i].getAttribute(AB); 305 if (!name) { continue; } 306 if (!groups[name]) { groups[name] = []; } 307 groups[name].push(nodes[i]); 308 } 309 310 var stored = readAssignments(); 311 var shown = []; 312 var dirty = false;
313 314 Object.keys(groups).forEach(function (name) { 315 var members = groups[name]; 316 var open = []; 317 318 for (var j = 0; j < members.length; j++) { 319 if (eligible(members[j])) { open.push(members[j]); } 320 } 321 322 // NOTHING ELIGIBLE IS A REAL ANSWER, not an error. Every member was 323 // targeted and this visitor matched none of them, so the group 324 // shows nothing - which is why the editor panel says to leave one 325 // variant ruleless if something should always appear. 326 if (!open.length) { return; } 327 328 var chosen = pickVariant(open, stored[name]); 329 if (!chosen.pick) { return; } 330 331 var pick = chosen.pick; 332 333 if (chosen.fresh) { 334 stored[name] = pick.getAttribute(AB + '-variant'); 335 dirty = true; 336 } 337 338 // REVEALED BY REMOVING THE ATTRIBUTE THE CSS KEYS ON, so a losing 339 // member keeps it and stays hidden. Nothing is ever set to 340 // display:block - the element's own layout is whatever Elementor 341 // gave it, and forcing a display value would break flex and grid 342 // containers. 343 pick.removeAttribute(AB); 344 pick.setAttribute('data-pmip-ab-won', '1'); 345 346 shown.push(name + ':' + stored[name]); 347 }); 348 349 if (dirty) { writeAssignments(stored); } 350 351 if (shown.length) { record(shown); } 352 } 353 354 /** 355 * Put the assignment where a lead will carry it. 356 * 357 * WITHOUT THIS THE SPLIT IS THEATRE. Randomising which section somebody 358 * sees and then not recording which one they saw produces a test that can 359 * never be read - and the question "did B convert better than C" is asked 360 * of the leads table, not of this page. 361 * 362 * WRITTEN INTO THE TRACKING STORE rather than posted anywhere: it then 363 * rides into the form on the next fill, exactly as the UTM values do, and 364 * lands on the lead row in tracking_json. 365 */ 366 function record(shown) { 367 var value = shown.sort().join(','); 368 369 try { 370 var raw = window.localStorage.getItem('parker_utm_data'); 371 var store = raw ? JSON.parse(raw) : null; 372 373 if (store && typeof store === 'object') { 374 if (store.ab_variant === value) { return; } 375 store.ab_variant = value; 376 window.localStorage.setItem('parker_utm_data', JSON.stringify(store)); 377 } 378 } catch (e) { /* never worth breaking the page for */ } 379 380 // Re-fill any form already on the page, so a visitor who opens one 381 // without navigating still carries the assignment. 382 try { 383 if (window.ParkerUtmTracking && typeof window.ParkerUtmTracking.refill === 'function') { 384 window.ParkerUtmTracking.refill(); 385 } 386 } catch (e) { /* as above */ } 387 } 388 389 function sweep() { 390 tiers = readTiers(); 391 392 var nodes = document.querySelectorAll('[' + ATTR + ']'); 393 for (var i = 0; i < nodes.length; i++) { 394 resolve(nodes[i]); 395 } 396 } 397 398 // FIRST PASS IMMEDIATELY, on whatever has been parsed so far. In the head 399 // that is nothing, which is fine - the observer below catches every element 400 // as the parser reaches it, which is the earliest any of them can be acted 401 // on. 402 sweep(); 403 404 if (window.MutationObserver) { 405 var observer = new MutationObserver(function (records) { 406 for (var i = 0; i < records.length; i++) { 407 var added = records[i].addedNodes; 408 for (var j = 0; j < added.length; j++) { 409 var node = added[j]; 410 if (node.nodeType !== 1) { continue; } 411 412 if (node.hasAttribute && node.hasAttribute(ATTR)) { 413 resolve(node); 414 } 415 if (node.querySelectorAll) { 416 var inner = node.querySelectorAll('[' + ATTR + ']'); 417 for (var k = 0; k < inner.length; k++) { 418 resolve(inner[k]); 419 } 420 } 421 } 422 } 423 }); 424 425 observer.observe(document.documentElement, { childList: true, subtree: true }); 426 427 // STOPPED ONCE THE PAGE IS DONE. An observer left watching the whole 428 // document for the life of the page is a real cost on a builder page 429 // that animates, and there is nothing left for it to find. 430 window.addEventListener('load', function () { 431 sweep(); 432 observer.disconnect(); 433 }); 434 } 435 436 // THE LAST WORD. By DOMContentLoaded the tracker has certainly defined its 437 // API, so this pass is the one that resolves anything the early passes saw 438 // before the tracker was ready. 'resolved' tells resolve() it may now mark 439 // a non-matching "show" element as finished. 440 document.addEventListener('DOMContentLoaded', function () { 441 resolved = true; 442 sweep(); 443 444 // GROUPS LAST, and only here. The draw has to see every member before 445 // choosing one, so it cannot run while the parser is still arriving. 446 resolveGroups(); 447 }); 448})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.