1/** 2 * Nexus Phone Swap â frontend applier. 3 * 4 * UNCONDITIONALLY turns any plain-text phone number on the page (no tel: link 5 * at all â a header or footer a theme never made clickable) into a real 6 * `<a href="tel:...">`, on every Nexus Client page whether or not a campaign 7 * is active. Then, if this page load resolves an "active" campaign number, 8 * rewrites every clickable tel: link on the page (now including the ones just 9 * created) â href, visible phone-number text, and aria-label/title â to that 10 * number. A MutationObserver keeps both passes running against content 11 * injected after load (sticky footers, modals, lazy sections). 12 * 13 * Active-number precedence (per page load): 14 * 1. FRESH OVERRIDE â the URL carries a trigger param (ntm_location, then 15 * location) whose value matches a mapping in THIS page's config tag(s). 16 * If that mapping row was configured with an optional utm_content, the 17 * URL's ?utm_content= must ALSO match for it to count; a row configured 18 * WITHOUT utm_content still matches by location alone (today's default 19 * behavior, unchanged). See resolveMappingKey() below. That number is 20 * applied AND written to the persistence cookie. 21 * 2. PERSISTED â no fresh match, but the session cookie holds a valid number 22 * (set on an earlier landing page). It is applied here. This is what makes 23 * the swap follow the visitor across every page in the session. 24 * 3. DEFAULT â neither: the page is left untouched (native numbers). 25 * 26 * The cookie is a session cookie by default (cleared when the browser session 27 * ends). It is written and read only here â the server never sees it, so 28 * full-page caching is unaffected. Stored values are re-sanitized on read so a 29 * tampered cookie can never inject markup or a non-tel href. 30 * 31 * Runs site-wide (enqueued on every page) but returns immediately unless a 32 * fresh match resolves or a persisted value exists. 33 */ 34( function () { 35 'use strict'; 36 37 var CFG = ( typeof window !== 'undefined' && window.TMBPhoneSwapCfg ) || {}; 38 var COOKIE = ( CFG.cookie && String( CFG.cookie ) ) || 'nexus_phone_swap'; 39 var PARAMS = ( Array.isArray( CFG.params ) && CFG.params.length ) ? CFG.params : [ 'ntm_location', 'location' ]; 40 var CONTENT_PARAM = ( CFG.contentParam && String( CFG.contentParam ) ) || 'utm_content'; 41 var KEY_DELIMITER = ( CFG.keyDelimiter && String( CFG.keyDelimiter ) ) || '|'; 42 var MAX_AGE = parseInt( CFG.maxAge, 10 ) || 0; // 0 = session cookie 43 44 var PROCESSED = 'nexusPhoneSwapped'; 45 // Phone-looking text: optional +, then â¥7 digits allowing (), ., -, spaces. 46 var PHONE_TEXT_RE = /\+?\(?\d[\d\s().\-]{5,}\d/g; 47 // A usable phone number for schema/injection needs at least 7 digits. 48 var MIN_DIGITS = 7; 49 50 // ââ Trigger params ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 51 52 // Normalize identically to the PHP slug sanitizer: lowercase [a-z0-9-]. 53 // Pure/self-contained (no outer closure) so it can be extracted and unit 54 // tested on its own â see tests/nexus-phone-swap.test.js. 55 function normalizeTriggerValue( raw ) { 56 if ( ! raw ) { 57 return ''; 58 } 59 return String( raw ).trim().toLowerCase().replace( /[^a-z0-9\-]/g, '' ); 60 } 61 62 function getTriggerSlug() { 63 var params; 64 try { 65 params = new URLSearchParams( window.location.search ); 66 } catch ( e ) { 67 return ''; 68 } 69 for ( var i = 0; i < PARAMS.length; i++ ) { 70 var slug = normalizeTriggerValue( params.get( PARAMS[ i ] ) ); 71 if ( slug ) { 72 return slug; 73 } 74 } 75 return ''; 76 } 77 78 function getTriggerContent() { 79 var params; 80 try { 81 params = new URLSearchParams( window.location.search ); 82 } catch ( e ) { 83 return ''; 84 } 85 return normalizeTriggerValue( params.get( CONTENT_PARAM ) ); 86 } 87 88 // ââ Composite-key resolution ââââââââââââââââââââââââââââââââââââââââââââââââ 89 // 90 // A mapping row without utm_content is stored (by the PHP sanitizer) under 91 // its bare location key and matches by location alone. A row WITH 92 // utm_content is stored under "location|utm_content" and only matches when 93 // BOTH the location and utm_content triggers match â so two rows (or two 94 // block/shortcode instances) can share the same location and resolve to 95 // different phone numbers independently. A location match whose 96 // utm_content doesn't match anything configured for it does NOT fall back 97 // to some other row's number â it simply doesn't match. 98 // 99 // Pure/self-contained (no outer closure) so it can be extracted and unit 100 // tested on its own â see tests/nexus-phone-swap.test.js. 101 function resolveMappingKey( mappings, slug, content, delimiter ) { 102 var d = delimiter || '|'; 103 if ( ! slug || ! mappings || typeof mappings !== 'object' ) { 104 return ''; 105 }
106 if ( content && Object.prototype.hasOwnProperty.call( mappings, slug + d + content ) ) { 107 return slug + d + content; 108 } 109 if ( Object.prototype.hasOwnProperty.call( mappings, slug ) ) { 110 return slug; 111 } 112 return ''; 113 } 114 115 // ââ Config tags (mappings) ââââââââââââââââââââââââââââââââââââââââââââââââââ 116 117 function readMappings() { 118 var merged = {}; 119 var tags = document.querySelectorAll( 'script.nexus-phone-swap-config' ); 120 121 Array.prototype.forEach.call( tags, function ( tag ) { 122 try { 123 var config = JSON.parse( tag.textContent || '{}' ); 124 var map = config && config.mappings; 125 if ( ! map || typeof map !== 'object' ) { 126 return; 127 } 128 Object.keys( map ).forEach( function ( key ) { 129 if ( ! ( key in merged ) && typeof map[ key ] === 'string' ) { 130 merged[ key ] = map[ key ]; 131 } 132 } ); 133 } catch ( e ) { 134 /* malformed config tag â ignore */ 135 } 136 } ); 137 138 return merged; 139 } 140 141 // ââ Number normalization + validation âââââââââââââââââââââââââââââââââââââââ 142 143 function countDigits( value ) { 144 var m = String( value ).match( /\d/g ); 145 return m ? m.length : 0; 146 } 147 148 function toDialable( phone ) { 149 var digits = String( phone ).replace( /[^\d]/g, '' ); 150 return String( phone ).charAt( 0 ) === '+' ? '+' + digits : digits; 151 } 152 153 // Accept only a display string that looks like a phone number; return a 154 // {display, dialable} pair or null. Used for BOTH fresh mappings and cookie 155 // reads, so a poisoned cookie is rejected exactly like bad config. 156 function normalizeNumber( raw ) { 157 if ( typeof raw !== 'string' ) { 158 return null; 159 } 160 var display = raw.replace( /[^0-9()+\-.\s]/g, '' ).trim(); 161 if ( countDigits( display ) < MIN_DIGITS ) { 162 return null; 163 } 164 var dialable = toDialable( display ); 165 if ( countDigits( dialable ) < MIN_DIGITS ) { 166 return null; 167 } 168 return { display: display, dialable: dialable }; 169 } 170 171 // ââ Persistence cookie ââââââââââââââââââââââââââââââââââââââââââââââââââââââ 172 173 function readCookieRaw( name ) { 174 var target = name + '='; 175 var parts = ( document.cookie || '' ).split( ';' ); 176 for ( var i = 0; i < parts.length; i++ ) { 177 var c = parts[ i ].trim(); 178 if ( c.indexOf( target ) === 0 ) { 179 return c.substring( target.length ); 180 } 181 } 182 return ''; 183 } 184 185 function readPersisted() { 186 var raw = readCookieRaw( COOKIE ); 187 if ( ! raw ) { 188 return null; 189 } 190 try { 191 var obj = JSON.parse( decodeURIComponent( raw ) ); 192 // Re-sanitize the stored display; recompute dialable (never trust it). 193 return obj && typeof obj.d === 'string' ? normalizeNumber( obj.d ) : null; 194 } catch ( e ) { 195 return null; 196 } 197 } 198 199 function writePersisted( active, slug ) { 200 var value = encodeURIComponent( JSON.stringify( { d: active.display, s: slug || '' } ) ); 201 var attrs = '; path=/; SameSite=Lax'; 202 if ( window.location.protocol === 'https:' ) { 203 attrs += '; Secure'; 204 } 205 if ( MAX_AGE > 0 ) { 206 attrs += '; Max-Age=' + MAX_AGE; 207 } 208 try { 209 document.cookie = COOKIE + '=' + value + attrs; 210 } catch ( e ) { 211 /* cookie write blocked â swap still applies for this page */ 212 } 213 } 214 215 // ââ DOM swapping (unchanged behavior) âââââââââââââââââââââââââââââââââââââââ 216 217 function replacePhoneText( node, displayPhone ) { 218 var walker = document.createTreeWalker( node, NodeFilter.SHOW_TEXT, null, false ); 219 var current = walker.nextNode(); 220 221 while ( current ) { 222 if ( PHONE_TEXT_RE.test( current.nodeValue ) ) { 223 current.nodeValue = current.nodeValue.replace( PHONE_TEXT_RE, displayPhone ); 224 } 225 PHONE_TEXT_RE.lastIndex = 0; 226 current = walker.nextNode(); 227 } 228 } 229 230 function swapLink( link, displayPhone, dialable ) { 231 if ( link.dataset[ PROCESSED ] ) { 232 return; 233 } 234 link.dataset[ PROCESSED ] = '1'; 235 236 link.setAttribute( 'href', 'tel:' + dialable ); 237 replacePhoneText( link, displayPhone ); 238 239 [ 'aria-label', 'title' ].forEach( function ( attr ) { 240 var value = link.getAttribute( attr ); 241 if ( value && PHONE_TEXT_RE.test( value ) ) { 242 link.setAttribute( attr, value.replace( PHONE_TEXT_RE, displayPhone ) ); 243 } 244 PHONE_TEXT_RE.lastIndex = 0; 245 } ); 246 } 247 248 function swapAll( root, displayPhone, dialable ) { 249 if ( ! root || ! root.querySelectorAll ) { 250 return; 251 } 252 253 var candidates = Array.prototype.slice.call( root.querySelectorAll( 'a[href]' ) ); 254 if ( root.matches && root.matches( 'a[href]' ) ) { 255 candidates.push( root ); 256 } 257
258 candidates.forEach( function ( link ) { 259 var href = ( link.getAttribute( 'href' ) || '' ).trim().toLowerCase(); 260 if ( href.indexOf( 'tel:' ) === 0 ) { 261 swapLink( link, displayPhone, dialable ); 262 } 263 } ); 264 } 265 266 // ââ Linkify (numbers with no tel: link at all) ââââââââââââââââââââââââââââââ 267 // 268 // swapAll()/swapLink() only ever touch an existing `<a href="tel:...">` â 269 // a theme that renders its phone number as plain, unlinked text (confirmed 270 // on more than one live site) is invisible to it, campaign or no campaign. 271 // Rather than special-casing that in the swap logic, this makes plain-text 272 // numbers real tel: links UNCONDITIONALLY â every Nexus Client page, whether 273 // or not any phone-swap campaign is active on it â so click-to-call always 274 // works and, whenever a campaign number IS active, swapAll() picks the new 275 // link up like any other and swaps it exactly the same way. 276 // 277 // This runs against the whole page, so it deliberately requires a much 278 // stricter shape than PHONE_TEXT_RE (which only ever runs inside an 279 // already-matched tel: link): a real 10-digit NANP number with a separator 280 // between every group (area code / exchange / line), which a ZIP+4 281 // ("12345-6789", one separator, 5+4 digits) or most other digit runs on a 282 // site never happen to form. 283 284 // Pure/self-contained (regex literal inlined, no outer closure) so it can be 285 // extracted and unit tested on its own â see tests/nexus-phone-swap.test.js. 286 // No lookbehind: Safari < 16.4 (notably older iOS â exactly what a lot of 287 // Facebook ad-click traffic arrives on) throws a SyntaxError on `(?<!...)`, 288 // which would silently kill this entire IIFE. The leading boundary is 289 // captured instead and re-emitted as its own text run. 290 // 291 // Returns an array of pieces describing how to rebuild the text node: 292 // { type: 'text', value } for a plain run, { type: 'tel', value, dialable } 293 // for a phone match. A text node with no match returns a single 'text' 294 // piece equal to the original string, so callers can treat every result 295 // uniformly instead of special-casing "no match." 296 function splitPlainTextPhones( text ) { 297 var re = /(^|[^\d])((?:\+?1[\s.\-]?)?\(?\d{3}\)?[\s.\-]\d{3}[\s.\-]\d{4})(?!\d)/g; 298 var pieces = []; 299 var lastIndex = 0; 300 var match; 301 302 while ( ( match = re.exec( text ) ) ) { 303 var boundary = match[ 1 ]; 304 var phoneText = match[ 2 ]; 305 var phoneStart = match.index + boundary.length; 306 307 if ( phoneStart > lastIndex ) { 308 pieces.push( { type: 'text', value: text.slice( lastIndex, phoneStart ) } ); 309 } 310 pieces.push( { type: 'tel', value: phoneText, dialable: toDialable( phoneText ) } ); 311 lastIndex = re.lastIndex; 312 } 313 314 if ( ! pieces.length ) { 315 return [ { type: 'text', value: text } ]; 316 } 317 if ( lastIndex < text.length ) { 318 pieces.push( { type: 'text', value: text.slice( lastIndex ) } ); 319 } 320 return pieces; 321 } 322 323 // True if any ancestor (including the node itself) is ALREADY a link (of 324 // any kind â never nest an <a> inside an <a>) or an element whose text 325 // shouldn't be touched at all (script/style/form input/contenteditable). 326 function isSkippableForLinkify( el ) { 327 while ( el ) { 328 if ( el.nodeType === 1 ) { 329 var tag = el.tagName; 330 if ( tag === 'A' || tag === 'SCRIPT' || tag === 'STYLE' || tag === 'TEXTAREA' || tag === 'INPUT' ) { 331 return true; 332 } 333 if ( el.isContentEditable ) { 334 return true; 335 } 336 } 337 el = el.parentElement; 338 } 339 return false; 340 } 341 342 function linkifyTextNode( node ) { 343 var pieces = splitPlainTextPhones( node.nodeValue ); 344 if ( pieces.length === 1 && pieces[ 0 ].type === 'text' ) { 345 return; // no phone-shaped text in this node â nothing to do 346 } 347 348 var frag = document.createDocumentFragment(); 349 pieces.forEach( function ( piece ) { 350 if ( piece.type === 'tel' ) { 351 var a = document.createElement( 'a' ); 352 a.setAttribute( 'href', 'tel:' + piece.dialable ); 353 a.textContent = piece.value; 354 frag.appendChild( a ); 355 } else { 356 frag.appendChild( document.createTextNode( piece.value ) ); 357 } 358 } ); 359 360 node.parentNode.replaceChild( frag, node ); 361 } 362 363 function linkifyPlainText( root ) { 364 if ( ! root || typeof document.createTreeWalker !== 'function' ) { 365 return; 366 } 367 var walker = document.createTreeWalker( root, NodeFilter.SHOW_TEXT, null, false ); 368 // Snapshot first â replacing nodes mid-traversal confuses the walker. 369 var textNodes = []; 370 var current = walker.nextNode(); 371 while ( current ) { 372 textNodes.push( current ); 373 current = walker.nextNode(); 374 } 375
376 textNodes.forEach( function ( node ) { 377 if ( node.parentNode && ! isSkippableForLinkify( node.parentElement ) ) { 378 linkifyTextNode( node ); 379 } 380 } ); 381 } 382 383 // ââ Resolve active number + run âââââââââââââââââââââââââââââââââââââââââââââ 384 385 function resolveActive() { 386 var slug = getTriggerSlug(); 387 388 // 1. Fresh override: param matches a mapping on this page â exact 389 // location+utm_content match wins, else a location-only row. 390 if ( slug ) { 391 var mappings = readMappings(); 392 var content = getTriggerContent(); 393 var key = resolveMappingKey( mappings, slug, content, KEY_DELIMITER ); 394 395 if ( key ) { 396 var fresh = normalizeNumber( mappings[ key ] ); 397 if ( fresh ) { 398 writePersisted( fresh, slug ); 399 return fresh; 400 } 401 } 402 } 403 404 // 2. Persisted value from an earlier landing page (carries across pages). 405 // An unmatched param never clears this. 406 return readPersisted(); 407 } 408 409 function init() { 410 // Always linkify plain-text numbers first â independent of whether a 411 // campaign override resolves below, every page gets clickable numbers. 412 linkifyPlainText( document ); 413 414 var active = resolveActive(); 415 if ( active ) { 416 swapAll( document, active.display, active.dialable ); 417 } 418 // else: 3. Default â no campaign override on this page; the numbers 419 // stay as their own (now-linked) native values. 420 421 if ( typeof MutationObserver === 'function' && document.body ) { 422 new MutationObserver( function ( records ) { 423 records.forEach( function ( record ) { 424 Array.prototype.forEach.call( record.addedNodes, function ( node ) { 425 if ( node.nodeType === 1 ) { 426 linkifyPlainText( node ); 427 if ( active ) { 428 swapAll( node, active.display, active.dialable ); 429 } 430 } 431 } ); 432 } ); 433 } ).observe( document.body, { childList: true, subtree: true } ); 434 } 435 } 436 437 if ( document.readyState === 'loading' ) { 438 document.addEventListener( 'DOMContentLoaded', init ); 439 } else { 440 init(); 441 } 442} )();
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.