1/** 2 * Client UTM Persistence 3 * 4 * 1. Captures tracked params from window.location on every page load. 5 * 2. Stores them in localStorage according to the merge strategy. 6 * 3. Appends stored params to outbound links matching the configured scope. 7 * 4. Re-runs against dynamically added links via MutationObserver. 8 */ 9(function () { 10 'use strict'; 11 12 var config = window.CUPConfig || {}; 13 14 // Sensible defaults if the inline config is missing. 15 var PARAMS = config.paramsToTrack || [ 16 'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'gclid', 'fbclid' 17 ]; 18 var STORAGE_KEY = config.storageKey || 'cup_attribution'; 19 var MERGE_STRATEGY = config.mergeStrategy || 'overwrite'; 20 var TTL = typeof config.ttl === 'number' ? config.ttl : 0; 21 var LINK_SCOPE = config.linkScope || 'cross_domain'; 22 var ALLOWED_HOSTS = (config.allowedHosts || []).map(normalizeHost); 23 var EXCLUDED_HOSTS = (config.excludedHosts || []).map(normalizeHost); 24 var WATCH_DOM = config.watchDom !== false; 25 var DEBUG = !!config.debug; 26 27 var CURRENT_HOST = normalizeHost(window.location.hostname); 28 29 function log() { 30 if (DEBUG && window.console) { 31 console.log.apply(console, ['[CUP]'].concat([].slice.call(arguments))); 32 } 33 } 34 35 function normalizeHost(host) { 36 return String(host || '').toLowerCase().replace(/^www\./, ''); 37 } 38 39 // Treat sub.example.com as matching example.com in the excluded list. 40 function hostMatches(host, list) { 41 host = normalizeHost(host); 42 for (var i = 0; i < list.length; i++) { 43 var entry = list[i]; 44 if (host === entry || host.endsWith('.' + entry)) { 45 return true; 46 } 47 } 48 return false; 49 } 50 51 function safeStorage() { 52 try { 53 var test = '__cup_test__'; 54 window.localStorage.setItem(test, test); 55 window.localStorage.removeItem(test); 56 return window.localStorage; 57 } catch (e) { 58 log('localStorage unavailable:', e); 59 return null; 60 } 61 } 62 63 var storage = safeStorage(); 64 65 function readStored() { 66 if (!storage) return null; 67 try { 68 var raw = storage.getItem(STORAGE_KEY); 69 if (!raw) return null; 70 var parsed = JSON.parse(raw); 71 // Expire if TTL is set and exceeded. 72 if (TTL > 0 && parsed.ts && (Date.now() - parsed.ts) > TTL) { 73 storage.removeItem(STORAGE_KEY); 74 return null; 75 } 76 return parsed.data || null; 77 } catch (e) { 78 log('Failed to read storage:', e); 79 return null; 80 } 81 } 82 83 function writeStored(data) { 84 if (!storage) return; 85 try { 86 storage.setItem(STORAGE_KEY, JSON.stringify({ ts: Date.now(), data: data })); 87 } catch (e) { 88 log('Failed to write storage:', e); 89 } 90 } 91 92 // Pull tracked params out of the current URL. 93 function captureFromUrl() { 94 var params = new URLSearchParams(window.location.search); 95 var captured = {}; 96 var hasAny = false; 97 for (var i = 0; i < PARAMS.length; i++) { 98 var key = PARAMS[i]; 99 if (params.has(key)) { 100 var val = params.get(key); 101 if (val !== null && val !== '') { 102 captured[key] = val; 103 hasAny = true; 104 } 105 } 106 } 107 return hasAny ? captured : null; 108 } 109 110 // Apply the configured merge strategy. 111 function mergeAttribution(existing, incoming) { 112 if (!incoming) return existing; 113 if (!existing) return incoming; 114 115 switch (MERGE_STRATEGY) { 116 case 'first_touch': 117 return existing; 118 case 'last_non_direct': 119 // Only overwrite if incoming has at least one utm_* param. 120 var hasUtm = Object.keys(incoming).some(function (k) { 121 return k.indexOf('utm_') === 0; 122 }); 123 return hasUtm ? incoming : existing; 124 case 'overwrite': 125 default: 126 return incoming; 127 } 128 } 129 130 // Decide whether a given link should have UTMs appended. 131 function shouldDecorate(anchor) { 132 // Skip non-http(s) â mailto:, tel:, javascript:, #anchor, etc. 133 var href = anchor.getAttribute('href'); 134 if (!href) return false;
135 if (/^(mailto:|tel:|javascript:|sms:|#)/i.test(href)) return false; 136 137 // Anchor.host/protocol resolves relative URLs against the page. 138 var protocol = anchor.protocol; 139 if (protocol !== 'http:' && protocol !== 'https:') return false; 140 141 var linkHost = normalizeHost(anchor.hostname); 142 if (!linkHost) return false; 143 144 // Excluded hosts win regardless of scope. 145 if (EXCLUDED_HOSTS.length && hostMatches(linkHost, EXCLUDED_HOSTS)) { 146 return false; 147 } 148 149 switch (LINK_SCOPE) { 150 case 'external': 151 return linkHost !== CURRENT_HOST; 152 case 'allowlist': 153 return ALLOWED_HOSTS.length > 0 && hostMatches(linkHost, ALLOWED_HOSTS); 154 case 'cross_domain': 155 default: 156 // All links except same-domain. 157 return linkHost !== CURRENT_HOST; 158 } 159 } 160 161 // Append stored params to a link, preserving any params already on the link. 162 function decorateLink(anchor, attribution) { 163 try { 164 var url = new URL(anchor.href); 165 var changed = false; 166 Object.keys(attribution).forEach(function (key) { 167 // Link-level wins: don't overwrite a param that's already on the link. 168 if (!url.searchParams.has(key)) { 169 url.searchParams.set(key, attribution[key]); 170 changed = true; 171 } 172 }); 173 if (changed) { 174 anchor.href = url.toString(); 175 anchor.setAttribute('data-cup-decorated', '1'); 176 } 177 } catch (e) { 178 log('Failed to decorate link:', anchor.href, e); 179 } 180 } 181 182 function decorateAll(root, attribution) { 183 if (!attribution || !Object.keys(attribution).length) return; 184 var anchors = (root || document).querySelectorAll('a[href]:not([data-cup-decorated])'); 185 var count = 0; 186 for (var i = 0; i < anchors.length; i++) { 187 var a = anchors[i]; 188 if (shouldDecorate(a)) { 189 decorateLink(a, attribution); 190 count++; 191 } 192 } 193 if (count) log('Decorated', count, 'link(s)'); 194 } 195 196 // Watch for links added later (Elementor popups, AJAX widgets, mega menus, etc). 197 function startObserver(attribution) { 198 if (!WATCH_DOM || !window.MutationObserver) return; 199 200 var pending = false; 201 var observer = new MutationObserver(function (mutations) { 202 // Coalesce bursts of mutations into a single rAF callback. 203 if (pending) return; 204 pending = true; 205 window.requestAnimationFrame(function () { 206 pending = false; 207 for (var i = 0; i < mutations.length; i++) { 208 var added = mutations[i].addedNodes; 209 for (var j = 0; j < added.length; j++) { 210 var node = added[j]; 211 if (node.nodeType !== 1) continue; // Element nodes only. 212 if (node.tagName === 'A') { 213 if (shouldDecorate(node) && !node.hasAttribute('data-cup-decorated')) { 214 decorateLink(node, attribution); 215 } 216 } else if (node.querySelectorAll) { 217 decorateAll(node, attribution); 218 } 219 } 220 } 221 }); 222 }); 223 224 observer.observe(document.documentElement, { 225 childList: true, 226 subtree: true 227 }); 228 } 229 230 // Defensive catch-all: if an inline onclick or other handler swaps the 231 // href right before navigation, this re-applies UTMs at click time. 232 function attachClickGuard(getAttribution) { 233 document.addEventListener('click', function (e) { 234 var target = e.target; 235 // Walk up to the anchor (handles clicks on <span> inside <a>, etc). 236 while (target && target !== document && target.tagName !== 'A') { 237 target = target.parentNode; 238 } 239 if (!target || target.tagName !== 'A') return; 240 var attribution = getAttribution(); 241 if (attribution && shouldDecorate(target)) { 242 // Force re-decoration in case href was modified. 243 target.removeAttribute('data-cup-decorated'); 244 decorateLink(target, attribution); 245 } 246 }, true); // Capture phase so we run before most other click handlers. 247 } 248 249 // Main flow.
250 function init() { 251 var captured = captureFromUrl(); 252 var existing = readStored(); 253 var attribution = mergeAttribution(existing, captured); 254 255 if (attribution && attribution !== existing) { 256 writeStored(attribution); 257 log('Stored attribution:', attribution); 258 } 259 260 if (!attribution) { 261 log('No attribution to apply'); 262 return; 263 } 264 265 // Run as soon as the DOM is parseable. 266 if (document.readyState === 'loading') { 267 document.addEventListener('DOMContentLoaded', function () { 268 decorateAll(document, attribution); 269 startObserver(attribution); 270 }); 271 } else { 272 decorateAll(document, attribution); 273 startObserver(attribution); 274 } 275 276 attachClickGuard(function () { return readStored(); }); 277 } 278 279 init(); 280})();
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.