1import { recordJumpLinkClick } from '../analytics/events.js'; 2import { 3 recordJumpLinkImpression, 4 recordTableOfContentsImpression, 5} from '../analytics/impressions.js'; 6import ffetch from '../ffetch.js'; 7import { 8 generateRandomId, 9 getWindowLocation, 10 loadScript, 11} from './browser-utils.js'; 12import { getEnvironment } from './environment.js'; 13import fetchAllMetadata from './fetchMetadata.js'; 14import { PAGE_METADATA_SESSION_KEY } from './metadata-constants.js'; 15import { getSecret } from './secrets.js'; 16import { resolveSiteTemplateFromMetadata } from './site-template.js'; 17 18/** 19 * Adds a CSS class to the specified DOM element. 20 * 21 * @param {Element} element - The DOM element to which the class will be added. 22 * @param {string} className - The name of the CSS class to add. 23 */ 24const addClassToElement = (element, className) => { 25 if (element && className) { 26 element.classList.add(className); 27 } 28}; 29 30/** 31 * Applies CSS classes to an element based on its data attributes. 32 * 33 * For each data attribute (except those named 'sectionStatus' or starting with 'aue'), 34 * adds a class to the element in the format: 35 * - `${blockName}--${kebabKey}` if the value is 'true' 36 * - `${blockName}--${kebabKey}-${normalizedValue}` if the value is not 'false' or 'true' 37 * 38 * @param {HTMLElement} element - The DOM element to apply classes to. 39 * @param {string} blockName - The base name used for generating class names. 40 */ 41const applyDataAttributesAsClasses = (element, blockName) => { 42 if (!element || !element.dataset || !blockName) return; 43 44 Object.entries(element.dataset).forEach(([key, value]) => { 45 if (key === 'sectionStatus' || key.startsWith('aue')) return; 46 47 const kebabKey = key.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase(); 48 const normalizedValue = String(value).trim().toLowerCase(); 49 50 if (normalizedValue === 'false') return; 51 52 const className = 53 normalizedValue === 'true' 54 ? `${blockName}--${kebabKey}` 55 : `${blockName}--${kebabKey}-${normalizedValue}`; 56 57 element.classList.add(className); 58 }); 59}; 60 61/** 62 * Recursively removes all `null` and `undefined` values from an object or array. 63 * - For primitive values (string, number, boolean), returns the value as-is. 64 * - For arrays, returns a new array with all `null` and `undefined` elements removed. 65 * - For objects, returns a new object with all properties whose values are `null` or `undefined` removed. 66 * 67 * @param {*} schema - The input value to clean. Can be an object, array, or primitive. 68 * @returns {*} The cleaned value with all `null` and `undefined` values removed. 69 */ 70const cleanSchema = (schema) => { 71 // If the input is not an object or is null/undefined, return it directly. 72 // This handles primitive values (strings, numbers, booleans) and actual null/undefineds. 73 if (schema === null || schema === undefined || typeof schema !== 'object') { 74 return schema; 75 } 76 77 // If the input is an array 78 if (Array.isArray(schema)) { 79 const newArr = []; 80 for (let i = 0; i < schema.length; i++) { 81 const value = schema[i]; 82 // Recursively process the value 83 const processedValue = cleanSchema(value); 84 // Add to the new array only if it's not null or undefined 85 if (processedValue !== null && processedValue !== undefined) { 86 newArr.push(processedValue); 87 } 88 } 89 return newArr; 90 } 91 92 // If the input is an object (not an array) 93 const newSchema = {}; 94 Object.entries(schema).forEach(([key, value]) => { 95 // Recursively process the value 96 const processedValue = cleanSchema(value); 97 // Add to the new object only if the processed value is not null or undefined 98 if (processedValue !== null && processedValue !== undefined) { 99 newSchema[key] = processedValue; 100 } 101 }); 102 return newSchema; 103}; 104 105/** 106 * Creates a styled button or link element for UI actions. 107 * 108 * @param {Object} options - Button configuration options. 109 * @param {string} [options.btnHrefEl] - (optional) If provided, creates an <a> element with this href. 110 * @param {string} [options.btnTargetEl] - (optional) Target attribute for <a> element (e.g., '_blank'). 111 * @param {string} [options.btnTextEl] - Text content for the button or link. 112 * @param {string} [options.btnTypeEl] - (optional) Button style class, e.g., 'primary', 'link'; defaults to 'primary'. 113 * 114 * @returns {HTMLButtonElement|HTMLAnchorElement} The created button or anchor element with appropriate classes and attributes. 115 */ 116const createButtonElement = ({ 117 btnHrefEl, 118 btnTargetEl, 119 btnTextEl, 120 btnTypeEl, 121}) => { 122 const btnHref = btnHrefEl?.trim() || ''; 123 const btnText = btnTextEl?.trim() || ''; 124 const btnTarget = btnTargetEl?.trim(); 125 const btnType = btnTypeEl?.trim() || 'primary'; 126 127 // Create element: <a> if href exists, otherwise <button> 128 const element = btnHref 129 ? document.createElement('a') 130 : document.createElement('button'); 131 132 element.textContent = btnText; 133 134 if (element.tagName === 'A') { 135 element.href = btnHref; 136 137 if (btnTarget) { 138 element.target = btnTarget; 139 if (btnTarget === '_blank') { 140 element.rel = 'noopener noreferrer'; 141 } 142 } 143 } else { 144 element.type = 'button'; 145 } 146 147 // Exclude 'link' buttons from receiving CSS classes because they are styled differently. 148 if (btnType === 'link') { 149 element.classList.add('link'); 150 if (btnTarget) { 151 if (btnTarget === '_blank') { 152 const externalIcon = useSvg('external-link', '10px', '10px'); 153 externalIcon.setAttribute('aria-hidden', 'true'); 154 externalIcon.setAttribute('focusable', 'false');
155 element.prepend(externalIcon); 156 element.dataset.external = 'true'; 157 } 158 } 159 } else { 160 element.classList.add('btn', `btn--${btnType}`); 161 const forwardArrowFront = useSvg('forward-arrow-front', '11px', '11px'); 162 const forwardArrowBack = useSvg('forward-arrow-back', '11px', '11px'); 163 const forwardArrow = document.createElement('div'); 164 forwardArrow.classList.add('btn__forward-arrow'); 165 forwardArrow.append(forwardArrowFront, forwardArrowBack); 166 element.append(forwardArrow); 167 } 168 169 return element; 170}; 171 172/** 173 * Creates a DOM element representing a card with an icon, heading, description, and optional buttons. 174 * 175 * @param {Object} options - Configuration options for the card. 176 * @param {string} [options.btnOneLink] - URL for the first button. 177 * @param {string} [options.btnOneTarget] - Target attribute for the first button link (e.g., '_blank', '_self'). 178 * @param {string} [options.btnOneText] - Text for the first button. 179 * @param {string} [options.btnOneType] - Type/style for the first button (e.g., 'primary'). 180 * @param {string} [options.btnTwoLink] - URL for the second button. 181 * @param {string} [options.btnTwoTarget] - Target attribute for the second button link. 182 * @param {string} [options.btnTwoText] - Text for the second button. 183 * @param {string} [options.btnTwoType] - Type/style for the second button (e.g., 'secondary'). 184 * @param {HTMLElement} [options.description] - Description element to display in the card. 185 * @param {string} [options.eyebrow] - Eyebrow text to display above the heading. 186 * @param {string} [options.fontAwesomeIcon] - Font Awesome icon class name (e.g., 'fa-solid fa-face-smile'). 187 * @param {HTMLElement} [options.heading] - Heading element to display in the card. 188 * @param {string} [options.headingLink] - URL for the heading link. 189 * @param {string} [options.headingLinkTarget] - Target attribute for the heading link. 190 * @param {string} [options.iconClass] - Icon class name for custom icons. 191 * @param {HTMLElement} [options.iconImage] - Image element for the icon. 192 * @param {string} [options.iconSource] - Source type for the icon ('font-awesome', 'icon-class', or other). 193 * @param {boolean} [options.useHeadingLink] - Whether to use a link for the heading. 194 * @returns {HTMLElement} The constructed card DOM element. 195 */ 196const createCardsIconItem = ({ 197 btnOneLink, 198 btnOneTarget, 199 btnOneText, 200 btnOneType, 201 btnTwoLink, 202 btnTwoTarget, 203 btnTwoText, 204 btnTwoType, 205 description, 206 eyebrow, 207 fontAwesomeIcon, 208 heading, 209 headingLink, 210 headingLinkTarget, 211 iconClass, 212 iconImage, 213 iconSource, 214 useHeadingLink, 215}) => { 216 const cardsIconItem = document.createElement('div'); 217 cardsIconItem.classList.add('cards-icon__item', 'cards__item'); 218 219 if (iconSource === 'font-awesome') { 220 const iconElement = document.createElement('i'); 221 iconElement.className = fontAwesomeIcon || 'fa-solid fa-face-smile'; 222 const iconWrapper = document.createElement('div'); 223 iconWrapper.classList.add('cards-icon__icon'); 224 iconWrapper.appendChild(iconElement); 225 cardsIconItem.appendChild(iconWrapper); 226 } else if (iconSource === 'icon-class') { 227 const iconElement = document.createElement('i'); 228 iconElement.className = iconClass || 'fa-solid fa-face-smile'; 229 const iconWrapper = document.createElement('div'); 230 iconWrapper.classList.add('cards-icon__icon'); 231 iconWrapper.appendChild(iconElement); 232 cardsIconItem.appendChild(iconWrapper); 233 } else if (iconImage && iconImage.firstElementChild) { 234 const iconAsset = iconImage.firstElementChild; 235 iconAsset.classList.add('cards-icon__icon'); 236 237 const image = iconAsset.querySelector('img'); 238 if (image) { 239 image.classList.add('cards-icon__image'); 240 } 241 cardsIconItem.appendChild(iconAsset); 242 } 243 const headingElement = document.createElement('div'); 244 headingElement.classList.add('cards-icon__heading', 'cards__heading'); 245 if (heading && heading.firstElementChild && heading.textContent.trim()) { 246 if (useHeadingLink && headingLink) { 247 const anchor = document.createElement('a'); 248 const targetValue = headingLinkTarget || '_self'; 249 anchor.setAttribute('target', targetValue); 250 if (targetValue === '_blank') { 251 anchor.setAttribute('rel', 'noopener noreferrer'); 252 } 253 anchor.classList.remove('button'); 254 anchor.classList.add('cards__link'); 255 anchor.href = sanitizeUrl(headingLink); 256 const headingEl = heading.firstElementChild; 257 const chevronIcon = document.createElement('i'); 258 chevronIcon.className = 'fa-solid fa-chevron-right cards__chevron'; 259 headingEl.append(chevronIcon); 260 anchor.append(headingEl); 261 if (eyebrow) { 262 const eyebrowElement = document.createElement('div'); 263 eyebrowElement.className = 'cards__eyebrow'; 264 265 const eyebrowSpan = document.createElement('span'); 266 eyebrowSpan.textContent = eyebrow; 267 268 eyebrowElement.appendChild(eyebrowSpan); 269 headingElement.append(eyebrowElement); 270 } 271 headingElement.append(anchor); 272 } else { 273 if (eyebrow) { 274 const eyebrowElement = document.createElement('div'); 275 eyebrowElement.className = 'cards__eyebrow'; 276 277 const eyebrowSpan = document.createElement('span'); 278 eyebrowSpan.textContent = eyebrow; 279 280 eyebrowElement.appendChild(eyebrowSpan); 281 headingElement.append(eyebrowElement); 282 } 283 headingElement.append(heading.firstElementChild); 284 } 285 } 286 cardsIconItem.appendChild(headingElement); 287 if (description && description.textContent.trim()) { 288 description.classList.add('cards-icon__description', 'cards__description'); 289 cardsIconItem.appendChild(description); 290 } else { 291 const descriptionElement = document.createElement('div'); 292 descriptionElement.classList.add( 293 'cards-icon__description', 294 'cards__description', 295 ); 296 cardsIconItem.appendChild(descriptionElement); 297 } 298 if (btnOneLink && btnOneText && !useHeadingLink) { 299 const buttonGroup = document.createElement('div'); 300 const buttonOne = createButtonElement({ 301 btnHrefEl: btnOneLink, 302 btnTargetEl: btnOneTarget || '_self', 303 btnTextEl: btnOneText, 304 btnTypeEl: btnOneType || 'primary', 305 }); 306 buttonGroup.classList.add('cards__buttons', 'cards-icon__button-group'); 307 buttonGroup.appendChild(buttonOne); 308 309 if (btnTwoLink && btnTwoText) { 310 const buttonTwo = createButtonElement({ 311 btnHrefEl: btnTwoLink, 312 btnTargetEl: btnTwoTarget || '_self', 313 btnTextEl: btnTwoText, 314 btnTypeEl: btnTwoType || 'secondary', 315 }); 316 buttonGroup.appendChild(buttonTwo); 317 } 318 cardsIconItem.appendChild(buttonGroup); 319 } 320 return cardsIconItem; 321}; 322 323/** 324 * Creates a desktop navigation element with anchor links for a given section. 325 * 326 * @param {Array<{id: string, text: string}>} anchorLinks - Array of anchor link objects, each containing an `id` and `text`. 327 * @param {string} section - The section identifier used for anchor click tracking. 328 * @param {Object} observerController - The observer controller object with setClickScrolling method. 329 * @returns {HTMLElement} The constructed navigation element containing anchor links. 330 */ 331const createDesktopNavigation = (anchorLinks, section, observerController) => { 332 const nav = document.createElement('nav'); 333 nav.className = 'anchor-navigation--desktop'; 334 nav.setAttribute('aria-label', 'Section navigation'); 335 336 if (anchorLinks.length > 4) { 337 nav.classList.add('anchor-navigation--many-items'); 338 } 339 340 const navList = document.createElement('ul'); 341 navList.className = 'anchor-nav-list'; 342 343 anchorLinks.forEach((link, index) => { 344 const listItem = document.createElement('li'); 345 listItem.className = 'anchor-nav-item'; 346 347 const anchor = document.createElement('a'); 348 anchor.href = `#${link.id}`; 349 anchor.textContent = link.text; 350 anchor.className = 'anchor-nav-link'; 351 anchor.setAttribute('data-target', link.id); 352 353 anchor.addEventListener('click', (e) => { 354 e.preventDefault(); 355 const target = document.getElementById(link.id); 356 if (target) { 357 // Disable scroll observer during click-initiated scroll 358 if (observerController) { 359 observerController.setClickScrolling(true); 360 } 361 362 // Store the clicked ID for later verification 363 const clickedId = link.id; 364 365 target.scrollIntoView({ behavior: 'smooth', block: 'start' }); 366 367 // Manually set active state immediately for visual feedback 368 document 369 .querySelectorAll('.anchor-nav-link.active') 370 .forEach((activeLink) => activeLink.classList.remove('active')); 371 anchor.classList.add('active'); 372 373 window.history.replaceState(null, null, `#${link.id}`); 374 375 // Re-enable scroll observer after scroll completes (1.5s to be safe) 376 // Then force update to ensure correct active state 377 setTimeout(() => { 378 if (observerController) { 379 observerController.setClickScrolling(false); 380 observerController.forceUpdate(clickedId); 381 } 382 }, 1500); 383 } 384 // Anchor click tracking 385 recordJumpLinkClick({ 386 anchorBlock: section, 387 linkIdentifier: link.id, 388 linkIndex: index, 389 linkLabel: link.text, 390 }); 391 }); 392 393 listItem.appendChild(anchor); 394 navList.appendChild(listItem); 395 }); 396 397 nav.appendChild(navList); 398 return nav; 399}; 400 401/** 402 * Creates a new DOM element with the specified tag, classes, and optional text content. 403 * 404 * @param {string} tag - The HTML tag name for the element to create (e.g., 'div', 'span'). 405 * @param {string[]} classes - An array of CSS class names to add to the element. 406 * @param {string} [content=''] - Optional text content to set for the element. 407 * @returns {HTMLElement} The newly created DOM element with the specified classes and content. 408 * @throws {Error} If the tag is not provided or classes is not a non-empty array of str
408ings. 409 */ 410const createElementWithClasses = (tag, classes, content = '') => { 411 if (tag) { 412 const element = document.createElement(tag); 413 if (Array.isArray(classes)) { 414 element.classList.add(...classes); 415 } else { 416 throw new Error('Classes should be a non-empty array of strings'); 417 } 418 if (content) element.textContent = content; 419 return element; 420 } 421 throw new Error('Tag is required to create an element'); 422}; 423 424/** 425 * Asynchronously creates and appends meta tags to the document head based on tags found in the "cq-tags" meta tag. 426 * - Generates two meta tags: "tags-msq" (pipe-separated original tags) and "tags-msq-minimal" (pipe-separated, prefix-stripped, slash-replaced tags). 427 * - Builds a structured facetTags object mapping level 0 tag titles to arrays of level 1 tag titles using the provided allTags hierarchy. 428 * - Appends additional meta tags for each level 0 tag, with their corresponding level 1 titles as content. 429 * 430 * @async 431 * @returns {Promise<Object>} A promise that resolves to the facetTags object, mapping level 0 tag titles to arrays of level 1 tag titles. 432 */ 433const createFacetTags = async (fetchAllTagsImpl = fetchAllTags) => {
434 const allTags = await fetchAllTagsImpl(); 435 436 // Get tags from cq-tags meta 437 const cqTagsMeta = document.querySelector('meta[name="cq-tags"]'); 438 let cqTags = []; 439 if (cqTagsMeta && cqTagsMeta.content) { 440 cqTags = cqTagsMeta.content.split(',').map((tag) => tag.trim()); 441 } 442 443 if (cqTags.length > 0) { 444 const tagsMeta = document.createElement('meta'); 445 tagsMeta.setAttribute('name', 'tags-msq'); 446 tagsMeta.setAttribute('content', cqTags.join('|')); 447 document.head.appendChild(tagsMeta); 448 } 449 450 if (cqTags.length > 0) { 451 const tagsMetaPipe = document.createElement('meta'); 452 tagsMetaPipe.setAttribute('name', 'tags-msq-minimal'); 453 tagsMetaPipe.setAttribute( 454 'content', 455 cqTags 456 .map((tag) => tag.replace(/^msq-tags:/i, '').replace(/\//g, '-')) 457 .join('|'), 458 ); 459 document.head.appendChild(tagsMetaPipe); 460 } 461 462 // Build structured tags JSON using allTags titles and tag hierarchy 463 const facetTags = {}; 464 465 cqTags.forEach((tag) => { 466 if (allTags[tag]) { 467 // Remove prefix (e.g., msq-tags:) and split by "/" 468 const tagWithoutPrefix = tag.replace(/^msq-tags:/i, ''); 469 const levels = tagWithoutPrefix.split('/'); 470 if (levels.length >= 2) { 471 // Get display titles for level 0 and level 1 472 const level0Tag = `msq-tags:${levels[0]}`; 473 const level1Tag = `msq-tags:${levels[0]}/${levels[1]}`; 474 const level0Title = allTags[level0Tag] || levels[0]; 475 const level1Title = allTags[level1Tag] || levels[1]; 476 477 // Store level 1 titles under level 0 key 478 if (!facetTags[level0Title]) facetTags[level0Title] = []; 479 if (!facetTags[level0Title].includes(level1Title)) { 480 facetTags[level0Title].push(level1Title); 481 } 482 } 483 } 484 }); 485 486 // Create separate meta tags for each level 0 tag 487 Object.entries(facetTags).forEach(([level0, level1Arr]) => { 488 if (Array.isArray(level1Arr) && level1Arr.length > 0) { 489 const meta = document.createElement('meta'); 490 meta.setAttribute('name', slugify(level0)); 491 meta.setAttribute('content', level1Arr.join('|')); 492 document.head.appendChild(meta); 493 } 494 }); 495 496 return facetTags; 497}; 498 499const createVideoBlock = ({ 500 background, 501 description, 502 eyebrow, 503 headline, 504 link1, 505 link2, 506 linkText1, 507 linkText2, 508 target1, 509 target2, 510 type1, 511 type2, 512 useCarouselAsHero, 513}) => { 514 const backgroundVideoSlide = document.createElement('div'); 515 516 if (background === 'dark') { 517 backgroundVideoSlide.classList.add('section--bg-dark-blue'); 518 } else if (background === 'light') { 519 backgroundVideoSlide.classList.add('section--bg-white'); 520 } 521 522 const textColumn = document.createElement('div'); 523 textColumn.classList.add('background-video__copy'); 524 525 if (useCarouselAsHero) { 526 const flareLight = useSvg('logo', '122', '122', 'light'); 527 flareLight.classList.add('hero__flare', 'hero__flare--light'); 528 const flareDark = useSvg('logo', '122', '122', 'dark'); 529 flareDark.classList.add('hero__flare', 'hero__flare--dark'); 530 const flareContrast = useSvg('logo', '122', '122', 'contrast'); 531 flareContrast.classList.add('hero__flare', 'hero__flare--contrast'); 532 textColumn.append(flareLight, flareContrast); 533 } 534 535 if (eyebrow) { 536 const eyebrowElement = document.createElement('div'); 537 eyebrowElement.classList.add('background-video__eyebrow'); 538 eyebrowElement.textContent = eyebrow; 539 textColumn.append(eyebrowElement); 540 } 541 542 // Add class to headline 543 if (headline && headline.firstElementChild) { 544 headline.classList.add('background-video__headline'); 545 textColumn.append(headline); 546 } 547 548 // Add class to description 549 if (description && description.firstElementChild) { 550 description.classList.add('background-video__description'); 551 textColumn.append(description); 552 } 553 554 const buttonGroup = document.createElement('div'); 555 buttonGroup.classList.add('background-video__button-group'); 556 557 if (link1 && linkText1) { 558 const primaryButton = createButtonElement({ 559 btnHrefEl: link1, 560 btnTargetEl: target1, 561 btnTextEl: linkText1, 562 btnTypeEl: type1 || 'primary', 563 }); 564 buttonGroup.append(primaryButton); 565 } 566 567 if (link2 && linkText2) { 568 const secondaryButton = createButtonElement({ 569 btnHrefEl: link2, 570 btnTargetEl: target2, 571 btnTextEl: linkText2, 572 btnTypeEl: type2 || 'tertiary', 573 }); 574 buttonGroup.append(secondaryButton); 575 } 576 577 if (buttonGroup.childNodes.length > 0) { 578 textColumn.append(buttonGroup); 579 } 580 581 backgroundVideoSlide.append(textColumn); 582 583 return backgroundVideoSlide;
584}; 585 586/** 587 * Creates the markup for an image-text block with configurable layout, background, content, and buttons. 588 * 589 * @param {Object} params - Configuration for the image-text block. 590 * @param {'dark'|'light'|'bluegradient'|'greengradient'|'none'} params.background - Background style for the block. 591 * @param {HTMLElement} params.description - The description element to display. 592 * @param {string} [params.eyebrow] - Optional eyebrow text to display above the headline. 593 * @param {HTMLElement} params.headline - The headline element to display. 594 * @param {HTMLElement} params.image - The image element to display. 595 * @param {'left'|'right'} params.imagePlacement - The placement of the image relative to the text. 596 * @param {boolean} params.isDecorative - Whether the image is decorative (sets empty alt attribute). 597 * @param {string} [params.link1] - URL for the primary button. 598 * @param {string} [params.link2] - URL for the secondary button. 599 * @param {string} [params.linkText1] - Text for the primary button. 600 * @param {string} [params.linkText2] - Text for the secondary button. 601 * @param {string} [params.target1] - Target attribute for the primary button link. 602 * @param {string} [params.target2] - Target attribute for the secondary button link. 603 * @param {string} [params.type1] - Button type for the primary button (e.g., 'primary', 'secondary'). 604 * @param {string} [params.type2] - Button type for the secondary button (e.g., 'primary', 'secondary'). 605 * @param {boolean} [params.useCarouselAsHero] - Whether to style the block as a hero section for carousels. 606 * @returns {HTMLDivElement} The constructed image-text block element. 607 */ 608const createImageTextBlock = ({ 609 background, 610 description, 611 eyebrow, 612 headline, 613 image, 614 imagePlacement, 615 isDecorative, 616 link1, 617 link2, 618 linkText1, 619 linkText2, 620 target1, 621 target2, 622 type1, 623 type2, 624 useCarouselAsHero, 625}) => { 626 const grid = document.createElement('div'); 627 grid.classList.add( 628 'image-text__grid', 629 `image-text__grid--image-${imagePlacement}`, 630 ); 631 632 if (background === 'dark') { 633 grid.classList.add('section--bg-dark-blue'); 634 } else if (background === 'light') { 635 grid.classList.add('section--bg-white'); 636 } else if (background === 'bluegradient') { 637 grid.classList.add('section--bg-bluegradient'); 638 } else if (background === 'greengradient') { 639 grid.classList.add('section--bg-greengradient'); 640 } 641 642 const textColumn = document.createElement('div'); 643 textColumn.classList.add('image-text__copy'); 644 645 const headingElement = document.createElement('div'); 646 headingElement.classList.add('image-text__heading'); 647 648 if (useCarouselAsHero) { 649 const flareLight = useSvg('logo', '122', '122', 'light'); 650 flareLight.classList.add('hero__flare', 'hero__flare--light'); 651 const flareDark = useSvg('logo', '122', '122', 'dark'); 652 flareDark.classList.add('hero__flare', 'hero__flare--dark'); 653 const flareContrast = useSvg('logo', '122', '122', 'contrast'); 654 flareContrast.classList.add('hero__flare', 'hero__flare--contrast'); 655 headingElement.append(flareLight, flareContrast); 656 } 657 658 if (eyebrow) { 659 const eyebrowElement = document.createElement('div'); 660 eyebrowElement.classList.add('image-text__eyebrow'); 661 const eyebrowSpan = document.createElement('span'); 662 eyebrowSpan.textContent = eyebrow; 663 eyebrowElement.appendChild(eyebrowSpan); 664 headingElement.appendChild(eyebrowElement); 665 } 666 667 // Add class to headline 668 if (headline && headline.firstElementChild) { 669 headline.classList.add('image-text__headline'); 670 headingElement.append(headline); 671 } 672 673 textColumn.append(headingElement); 674 675 // Add class to description 676 if (description && description.firstElementChild) { 677 description.classList.add('image-text__description'); 678 textColumn.append(description); 679 } 680 681 const buttonGroup = document.createElement('div'); 682 buttonGroup.classList.add('image-text__button-group'); 683 684 if (link1 && linkText1) { 685 const primaryButton = createButtonElement({ 686 btnHrefEl: link1, 687 btnTargetEl: target1, 688 btnTextEl: linkText1, 689 btnTypeEl: type1 || 'primary', 690 }); 691 buttonGroup.append(primaryButton); 692 } 693 694 if (link2 && linkText2) { 695 const secondaryButton = createButtonElement({ 696 btnHrefEl: link2, 697 btnTargetEl: target2, 698 btnTextEl: linkText2, 699 btnTypeEl: type2 || 'tertiary', 700 }); 701 buttonGroup.append(secondaryButton); 702 } 703 704 if (buttonGroup.childNodes.length > 0) { 705 textColumn.append(buttonGroup); 706 } 707 708 if (imagePlacement === 'background') { 709 grid.classList.add('image-text__image-background'); 710 grid.style.backgroundImage = `url(${image.querySelector('img')?.src || ''})`; 711 } else if (imagePlacement === 'overlay-right') { 712 grid.classList.add('image-text__image-overlay-right'); 713 } 714 715 const imageColumn = document.createElement('div'); 716 imageColumn.classList.add('image-text__asset'); 717 718 if (image) { 719 image.classList.add('image-text__image'); 720 const img = image.querySelector('img'); 721 if (img && isDecorative) { 722 img.setAttribute('alt', ''); 723 } 724 imageColumn.append(image); 725 } 726 grid.append(textColumn, imageColumn); 727 728 return grid; 729}; 730 731/** 732 * Creates a mobile-friendly anchor navigation menu. 733 * 734 * @param {Array<{id: string, text: string}>} anchorLinks - An array of objects representing anchor links, each with an `id` (target element ID) and `text` (display text). 735 * @returns {HTMLElement} The constructed navigation element for mobile use. 736 * 737 * @example 738 * const links = [ 739 * { id: 'section1', text: 'Section 1' }, 740 * { id: 'section2', text: 'Section 2' } 741 * ]; 742 * document.body.appendChild(createMobileNavigation(links)); 743 */ 744const createMobileNavigation = (anchorLinks) => { 745 const instanceId = `anchor-nav-dropdown-${generateRandomId()}`; 746 const nav = document.createElement('nav'); 747 nav.className = 'anchor-navigation--mobile'; 748 nav.setAttribute('aria-label', 'Section navigation'); 749 750 const toggle = document.createElement('button'); 751 toggle.className = 'anchor-nav-toggle'; 752 toggle.setAttribute('aria-expanded', 'false'); 753 toggle.setAttribute('aria-controls', instanceId); 754 755 const toggleText = document.createElement('span'); 756 toggleText.className = 'anchor-nav-toggle-text'; 757 toggleText.textContent = anchorLinks[0]?.text; 758 toggle.appendChild(toggleText); 759 760 const chevronDown = useSvg('chevron-left', '24px', '24px'); 761 chevronDown.classList.add('anchor-nav-toggle-chevron'); 762 toggle.appendChild(chevronDown); 763 764 const dropdown = document.createElement('div'); 765 dropdown.className = 'anchor-nav-dropdown'; 766 dropdown.id = instanceId; 767 dropdown.setAttribute('aria-hidden', 'true'); 768 dropdown.inert = true; 769 770 const navList = document.createElement('ul'); 771 navList.className = 'anchor-nav-dropdown__list'; 772 anchorLinks.forEach((link) => { 773 const listItem = document.createElement('li'); 774 listItem.className = 'anchor-nav-dropdown__item'; 775 const anchor = document.createElement('a'); 776 anchor.href = `#${link.id}`; 777 anchor.textContent = link.text; 778 anchor.className = 'anchor-nav-link'; 779 780 anchor.addEventListener('click', (e) => { 781 e.preventDefault(); 782 document.getElementById(link.id)?.scrollIntoView({ behavior: 'smooth' }); 783 dropdown.classList.remove('active'); 784 dropdown.setAttribute('aria-hidden', 'true'); 785 dropdown.inert = true; 786 toggle.setAttribute('aria-expanded', 'false'); 787 toggleText.textContent = link.text; 788 }); 789 790 listItem.appendChild(anchor); 791 navList.appendChild(listItem); 792 }); 793 794 toggle.addEventListener('click', () => { 795 const isExpanded = toggle.getAttribute('aria-expanded') === 'true'; 796 toggle.setAttribute('aria-expanded', String(!isExpanded)); 797 dropdown.setAttribute('aria-hidden', String(isExpanded)); 798 dropdown.inert = isExpanded; 799 dropdown.classList.toggle('active'); 800 }); 801 802 dropdown.appendChild(navList); 803 nav.appendChild(toggle); 804 nav.appendChild(dropdown); 805 return nav; 806}; 807 808/** 809 * Creates a section title block element with optional description and call-to-action button. 810 * 811 * @param {Object} params - The parameters for creating the section title block. 812 * @param {string} [params.btnHrefEl] - The href attribute for the button (if present). 813 * @param {string} [params.btnTargetEl] - The target attribute for the button (e.g., '_blank'). 814 * @param {string} [params.btnTextEl] - The text content for the button. 815 * @param {string} [params.btnTypeEl] - The type attribute for the button (e.g., 'primary', 'secondary'). 816 * @param {HTMLElement} [params.description] - The description element (rich text, optional). 817 * @param {string} [params.theme=''] - The theme modifier for the section (appended as a class). 818 * @param {HTMLElement} params.title - The title element (rich text). 819 * @returns {HTMLDivElement} The constructed section title block element. 820 */ 821const createSectionTitleBlock = ({ 822 btnHrefEl, 823 btnTargetEl, 824 btnTextEl, 825 btnTypeEl, 826 description, 827 theme = '', 828 title, 829}) => { 830 const hasButton = btnHrefEl && btnTextEl; 831 const block = document.createElement('div'); 832 block.classList.add('section-title'); 833 834 if (hasButton) { 835 block.classList.add('grid', 'grid--columns-sm-2-left'); 836 } 837 838 if (theme) { 839 block.classList.add(`section--${theme}`); 840 } 841 842 // Content wrapper for title and description 843 const contentDiv = document.createElement('div'); 844 contentDiv.className = 'section-title__content'; 845 846 // Title (rich text) 847 if (title && title.firstElementChild) { 848 const titleDiv = document.createElement('div'); 849 titleDiv.className = 'section-title__title';
850 Array.from(title.childNodes).forEach((node) => titleDiv.append(node)); 851 contentDiv.append(titleDiv); 852 } 853 854 // Description (rich text, optional) 855 if (description && description.firstElementChild) { 856 const descDiv = document.createElement('div'); 857 descDiv.className = 'section-title__description'; 858 Array.from(description.childNodes).forEach((node) => descDiv.append(node)); 859 860 if (hasButton) { 861 contentDiv.append(descDiv); 862 } else { 863 const gridElement = document.createElement('div'); 864 gridElement.className = 'grid grid--columns-sm-2-left'; 865 gridElement.append(descDiv); 866 contentDiv.append(gridElement); 867 } 868 } 869 // Append content and CTA wrapper if they have children 870 if (contentDiv.childElementCount > 0) block.append(contentDiv); 871 872 if (hasButton) { 873 // CTA wrapper 874 const ctaWrapper = document.createElement('div'); 875 ctaWrapper.className = 'section-title__cta-wrapper'; 876 877 if (btnHrefEl?.trim() && btnTextEl?.trim()) { 878 // Create the button element using the utility function 879 const cta = createButtonElement({ 880 btnHrefEl, 881 btnTargetEl, 882 btnTextEl, 883 btnTypeEl, 884 }); 885 ctaWrapper.append(cta); 886 } 887 888 if (ctaWrapper.childElementCount > 0) block.append(ctaWrapper); 889 } 890 891 return block; 892}; 893 894/** 895 * Enhances an anchor element to indicate it is an external link. 896 * - Sets `target="_blank"` to open the link in a new tab. 897 * - Sets `rel="noopener noreferrer"` for security. 898 * - Prepends an external link SVG icon to the link. 899 * 900 * @param {HTMLAnchorElement} link - The anchor element to decorate as an external link. 901 */ 902const decorateExternalLink = (link) => { 903 link.setAttribute('target', '_blank'); 904 link.setAttribute('rel', 'noopener noreferrer'); 905 const externalIcon = useSvg('external-link', '10px', '10px'); 906 externalIcon.setAttribute('aria-hidden', 'true'); 907 externalIcon.setAttribute('focusable', 'false'); 908 link.prepend(externalIcon); 909}; 910 911/** 912 * Extracts and transforms elements from a given block based on specified element types. 913 * 914 * @param {Element} block - The DOM element containing child elements to extract. 915 * @param {string[]} [elementTypes=[]] - An array specifying the type of each child element. Supported types: 'boolean', 'date', 'html', 'link', 'multiText', 'number', 'text'. 916 * @param {boolean} [isBlock=true] - Determines the query selector used to find child elements. 917 * @returns {Array} An array of extracted and transformed values corresponding to the specified element types. 918 */ 919const extractElements = (block, elementTypes = [], isBlock = true) => { 920 let blockElements = []; 921 922 if (isBlock) { 923 blockElements = block.querySelectorAll(':scope > div > div'); 924 } else { 925 blockElements = block.querySelectorAll(':scope > div'); 926 } 927 return Array.from(blockElements).map((blockElement, index) => { 928 const type = elementTypes[index]; // Get the type for the current element 929 const text = blockElement.textContent.trim(); 930 931 switch (type) { 932 case 'boolean': 933 return text === 'true'; 934 case 'date': { 935 const textAsDate = text.replace('T00:00:00.000Z', ' 12:00:00 UTC'); 936 return !Number.isNaN(Date.parse(textAsDate)) 937 ? new Date(textAsDate) 938 : null; 939 } 940 case 'html': 941 return blockElement; 942 case 'link': { 943 const anchor = blockElement.querySelector('a'); 944 const isInsideColumns = blockElement.closest('.columns.block'); 945 946 let href = ''; 947 if (isInsideColumns) { 948 if (text.startsWith('/') || text.startsWith('http')) { 949 href = sanitizeUrl(text.replace('/content/missionsquare-edge', '')); 950 } 951 } else { 952 href = anchor ? sanitizeUrl(anchor.getAttribute('href')) : ''; 953 } 954 return href; 955 } 956 case 'multiText': 957 return text ? text.split(',') : []; 958 case 'number': 959 return text && !Number.isNaN(Number(text)) ? Number(text) : 0; 960 case 'text': { 961 return text; 962 } 963 964 default: 965 return text; 966 } 967 }); 968}; 969 970/** 971 * Fetches all tags from a taxonomy JSON file, normalizes their keys, and caches the result in sessionStorage. 972 * 973 * @async 974 * @function fetchAllTags 975 * @returns {Promise<Object>} A promise that resolves to an object mapping normalized tag names to their titles. 976 * @throws {Error} If there is an error fetching or processing the tags data. 977 */ 978const fetchAllTags = async () => {
979 const DEFAULT_TAGS_PATH = '/taxonomy.json'; 980 const SESSION_KEY = 'tags-data'; 981 try { 982 const cached = sessionStorage.getItem(SESSION_KEY); 983 if (cached) { 984 return JSON.parse(cached); 985 } 986 987 const entries = await ffetch(DEFAULT_TAGS_PATH); 988 989 if (!entries) { 990 console.warn(`No valid tags found at ${DEFAULT_TAGS_PATH}`); 991 return {}; 992 } 993 const tagsArray = await entries.all(); 994 995 if (!Array.isArray(tagsArray)) { 996 console.warn(`No valid tags found at ${DEFAULT_TAGS_PATH}`); 997 return {}; 998 } 999 1000 const allTagsData = tagsArray.reduce((acc, item) => { 1001 const normalizedItem = Object.entries(item).reduce((obj, [k, v]) => { 1002 obj[k.toLowerCase()] = v; 1003 return obj; 1004 }, {}); 1005 1006 const rawTag = normalizedItem.tag?.trim().toLowerCase(); 1007 const rawTitle = normalizedItem.title?.trim(); 1008 if (rawTag && rawTitle) { 1009 acc[rawTag] = rawTitle; 1010 } 1011 1012 return acc; 1013 }, {}); 1014 sessionStorage.setItem(SESSION_KEY, JSON.stringify(allTagsData)); 1015 1016 return allTagsData; 1017 } catch (error) { 1018 console.error(`Error fetching tags from ${DEFAULT_TAGS_PATH}:`, error); 1019 return {}; 1020 } 1021}; 1022 1023/** 1024 * Fetches the query index data with caching using sessionStorage. 1025 * Attempts to retrieve cached data if available and not expired (15 minutes TTL). 1026 * If cache is missing or expired, fetches fresh data from '/query-index.json' and updates the cache. 1027 * Returns an array of query index data, or an empty array if fetching fails. 1028 * Cache is site-specific based on the queryindexpath to support multiple microsites under the same domain. 1029 * 1030 * @async 1031 * @returns {Promise<Array>} The query index data as an array. 1032 */ 1033const fetchQueryIndexWithCache = async () => { 1034 const queryIndexPath = getMetadata('queryindexpath') || '/query-index.json'; 1035 1036 // Generate site-specific cache key 1037 let CACHE_KEY = 'query-index'; 1038 if (queryIndexPath !== '/query-index.json') { 1039 // Extract site name from path (e.g., /site1/query-index.json -> site1) 1040 const pathSegments = queryIndexPath.split('/').filter((segment) => segment); 1041 const siteName = pathSegments.length > 0 ? pathSegments[0] : 'default'; 1042 CACHE_KEY = `query-index-${siteName}`; 1043 } 1044 1045 const CACHE_TTL = 15 * 60 * 1000; // 15 minutes in ms 1046 1047 let data; 1048 const cached = sessionStorage.getItem(CACHE_KEY); 1049 if (cached) { 1050 try { 1051 const { timestamp, value } = JSON.parse(cached); 1052 if (Date.now() - timestamp < CACHE_TTL) { 1053 data = value; 1054 } 1055 } catch (e) { 1056 console.error('Error parsing cached query index:', e); 1057 } 1058 } 1059 if (!data) { 1060 try { 1061 const result = await ffetch(queryIndexPath).all(); 1062 data = result; 1063 sessionStorage.setItem( 1064 CACHE_KEY, 1065 JSON.stringify({ timestamp: Date.now(), value: data }), 1066 ); 1067 } catch (e) { 1068 console.error('Error fetching query index:', e); 1069 } 1070 } 1071 return Array.isArray(data) ? data : []; 1072}; 1073 1074/** 1075 * Determines the background theme ('light' or 'dark') of the closest parent section element. 1076 * 1077 * @param {Element|null} element - The DOM element to start the search from. 1078 * @returns {'light'|'dark'} The background theme based on the section's class names. 1079 * 1080 * - Returns 'light' if the section has a light background class or no section is found. 1081 * - Returns 'dark' if the section has a background class starting with 'section--bg-' but not a light background class. 1082 */ 1083const getBackgroundTheme = (element) => { 1084 if (!element) return 'light'; 1085 1086 const section = element.closest('.section'); 1087 if (!section || !section.classList) return 'light'; 1088
1089 const lightBackgrounds = ['section--bg-light', 'section--bg-white']; 1090 let hasBackgroundClass = false; 1091 1092 if ( 1093 Array.from(section.classList).some((classItem) => 1094 lightBackgrounds.includes(classItem), 1095 ) 1096 ) { 1097 return 'light'; 1098 } 1099 1100 Array.from(section.classList).forEach((classItem) => { 1101 if (classItem.startsWith('section--bg-')) { 1102 hasBackgroundClass = true; 1103 } 1104 }); 1105 1106 return hasBackgroundClass ? 'dark' : 'light'; 1107}; 1108 1109let customPlanSitePromise; 1110 1111/** 1112 * Determines the site template based on the current URL and metadata.json. 1113 * Memoizes the in-flight request so concurrent callers share one fetch. 1114 * @returns {Promise<string>} The site template name for the current site. 1115 */ 1116const getCustomPlanSite = async () => { 1117 if (!customPlanSitePromise) { 1118 customPlanSitePromise = (async () => { 1119 const currentUrl = getWindowLocation().href.toLowerCase(); 1120 1121 const metadata = await fetchAllMetadata(); 1122 const metadataObj = 1123 metadata && typeof metadata === 'object' ? metadata : {}; 1124 const hasCachedMetadata = !!sessionStorage.getItem( 1125 PAGE_METADATA_SESSION_KEY, 1126 ); 1127 1128 if (!hasCachedMetadata && Object.keys(metadataObj).length === 0) { 1129 customPlanSitePromise = undefined; 1130 return 'default'; 1131 } 1132 1133 return resolveSiteTemplateFromMetadata( 1134 metadataObj, 1135 currentUrl, 1136 'default', 1137 ); 1138 })().catch((error) => { 1139 customPlanSitePromise = undefined; // allow retry if request fails 1140 console.error('Error fetching custom plan site metadata:', error); 1141 return 'default'; 1142 }); 1143 } 1144 1145 return customPlanSitePromise; 1146}; 1147 1148/** 1149 * Generates the subject and body content for an email based on the current page's metadata. 1150 * 1151 * @returns {{ body: string, subject: string }} An object containing the URL-encoded email subject and body. 1152 */ 1153const getEmailContent = () => { 1154 const subjectText = 1155 'You may find this content from MissionSquare interesting'; 1156 const metaTitle = 1157 document.querySelector('meta[property="og:title"]')?.content || 1158 document.title; 1159 const metaDescription = 1160 document.querySelector('meta[name="description"]')?.content || ''; 1161 const pageUrl = getWindowLocation().href; 1162 1163 const subject = encodeURIComponent(subjectText); 1164 const body = encodeURIComponent( 1165 `${metaTitle}\n\n${metaDescription}\n\n${pageUrl}`, 1166 ); 1167 1168 return { body, subject }; 1169}; 1170 1171/** 1172 * Retrieves the content of metadata tags. 1173 * @param {string} name The metadata name (or property) 1174 * @param {Document} doc Document object to query for metadata. Defaults to the window's document 1175 * @returns {string} The metadata value(s) 1176 */ 1177function getMetadata(name, doc = document) { 1178 const attr = name && name.includes(':') ? 'property' : 'name'; 1179 const meta = [...doc.head.querySelectorAll(`meta[${attr}="${name}"]`)] 1180 .map((m) => m.content) 1181 .join(', '); 1182 return meta || ''; 1183} 1184 1185/** 1186 * Determines the next appropriate HTML heading tag based on the current heading within a section. 1187 * 1188 * @param {Element|null} element - The DOM element from which to start searching for the section and heading. 1189 * @returns {string} The next heading tag name ('h2', 'h3', 'h4', 'h5', 'h6', or 'p'). 1190 * 1191 * @example 1192 * // If the current heading is <h2>, returns 'h3' 1193 * getNextHeadingTag(someElement); 1194 */ 1195const getNextHeadingTag = (element) => { 1196 if (!element) return 'h2'; 1197 const section = element.closest('.section'); 1198 if (!section) return 'h2'; 1199 const titleDiv = section.querySelector( 1200 '.section-title-wrapper .section-title__title', 1201 ); 1202 if (!titleDiv) return 'h2'; 1203 const heading = titleDiv.querySelector('h1, h2, h3, h4, h5, h6'); 1204 const tag = heading ? heading.tagName.toLowerCase() : ''; 1205 switch (tag) { 1206 case 'h1': 1207 return 'h2'; 1208 case 'h2': 1209 return 'h3'; 1210 case 'h3': 1211 return 'h4'; 1212 case 'h4': 1213 return 'h5'; 1214 case 'h5': 1215 return 'h6'; 1216 case 'h6': 1217 return 'p'; 1218 default: 1219 return 'h2'; 1220 } 1221}; 1222 1223/** 1224 * Tagged template function that returns a raw string from template literals, 1225 * preserving escape sequences and interpolated values. 1226 * 1227 * @param {TemplateStringsArray}
1227 strings - Array of string literals. 1228 * @param {...any} values - Interpolated values to be included in the string. 1229 * @returns {string} The resulting raw string with interpolated values. 1230 */ 1231const html = (strings, ...values) => String.raw({ raw: strings }, ...values); 1232 1233/** 1234 * Initializes anchor navigation for a given section element based on its dataset attributes. 1235 * Adds a specific anchor style class if defined, and sets up anchor navigation after a short delay. 1236 * 1237 * @param {HTMLElement} section - The section element to initialize anchor navigation for. 1238 */ 1239// Delay in milliseconds before initializing anchor navigation 1240// Allows DOM to fully render before calculating positions 1241const ANCHOR_NAV_INIT_DELAY = 100; 1242 1243const initializeAnchorNavigation = (section) => { 1244 const anchorEnabled = section.dataset.anchorNavigation === 'true'; 1245 1246 if (!anchorEnabled) { 1247 return; 1248 } 1249 1250 const { anchorStyle } = section.dataset; 1251 if (anchorStyle && anchorStyle !== 'default') { 1252 section.classList.add(`anchor-${anchorStyle}`); 1253 } 1254 1255 setTimeout(() => { 1256 setupAnchorNavigation(section); 1257 }, ANCHOR_NAV_INIT_DELAY); 1258}; 1259 1260/** 1261 * Injects language and hreflang meta/link tags into the document head based on the current URL pathname. 1262 * - Sets the HTML `lang` attribute. 1263 * - Adds a meta tag for `Content-Language`. 1264 * - Adds a meta tag to disable Adobe Publish. 1265 * - Adds alternate link tags for supported locales and x-default. 1266 * - Removes any existing `Content-Language` meta tags and `alternate` link tags before injecting new ones. 1267 * 1268 * @param {Location} [location=getWindowLocation()] - The location object to use for determining language and path. Defaults to the current window location. 1269 */ 1270const injectLangAndHreflangTags = (location = getWindowLocation()) => { 1271 try { 1272 const { origin, pathname } = location; 1273 1274 const hreflangs = { 1275 en: '/en', 1276 'en-us': '/en-us', 1277 es: '/es', 1278 'x-default': '', 1279 }; 1280 1281 const supportedLocales = Object.keys(hreflangs).filter( 1282 (lang) => lang !== 'x-default', 1283 ); 1284 1285 const localeMatch = pathname.match(/^\/([a-z]{2}(?:-[a-z]{2})?)\b/i); 1286 const extractedLocale = localeMatch ? localeMatch[1].toLowerCase() : null; 1287 1288 const locale = supportedLocales.includes(extractedLocale) 1289 ? extractedLocale 1290 : 'en'; 1291 1292 document.documentElement.lang = locale; 1293 1294 document 1295 .querySelectorAll( 1296 'meta[http-equiv="Content-Language"], link[rel="alternate"]', 1297 ) 1298 .forEach((el) => el.remove()); 1299 1300 const meta = document.createElement('meta'); 1301 meta.setAttribute('http-equiv', 'Content-Language'); 1302 meta.setAttribute('content', locale); 1303 document.head.appendChild(meta); 1304 1305 // Add a meta tag to disable Adobe Publish 1306 const disablePublish = document.createElement('meta'); 1307 disablePublish.setAttribute('name', 'urn:adobe:aue:config:disable'); 1308 disablePublish.setAttribute('content', 'publish'); 1309 document.head.appendChild(disablePublish); 1310 1311 const pathWithoutLang = localeMatch 1312 ? pathname.replace(/^\/[a-z]{2}(?:-[a-z]{2})?/, '') 1313 : pathname; 1314 Object.entries(hreflangs).forEach(([lang, basePath]) => { 1315 const link = document.createElement('link'); 1316 link.setAttribute('rel', 'alternate'); 1317 link.setAttribute('hreflang', lang); 1318 link.setAttribute('href', `${origin}${basePath}${pathWithoutLang}`); 1319 document.head.appendChild(link); 1320 }); 1321 } catch (e) { 1322 console.error('Language and hreflang setup failed', e); 1323 } 1324}; 1325 1326/** 1327 * Injects a JSON-LD schema into the document head as a <script> tag. 1328 * If an ID is provided and a script with that ID exists, it will be replaced. 1329 * 1330 * @param {Object} schema - The JSON-LD schema object to inject. 1331 * @param {string|null} [id=null] - Optional ID for the script tag. If provided, ensures only one script with this ID exists. 1332 */ 1333const injectSchema = (schema, id = null) => { 1334 // Only inject if schema is a plain object and not null/array 1335 if (!schema || typeof schema !== 'object' || Array.isArray(schema)) { 1336 return; 1337 } 1338 1339 const script = document.createElement('script'); 1340 script.type = 'application/ld+json'; 1341 script.textContent = JSON.stringify(schema, null, 2); 1342 1343 if (id) { 1344 const existing = document.getElementById(id); 1345 if (existing) existing.remove(); 1346 script.id = id; 1347 } 1348 1349 document.head.appendChild(script); 1350}; 1351 1352/** 1353 * Determines if a given link element is an external link. 1354 * 1355 * A link is considered external if its hostname differs from the current window's hostname 1356 * and it does not have the 'btn' class. 1357 * 1358 * @param {HTMLAnchorElement} link - The link element to check. 1359 * @returns {boolean} True if the link is external, false otherwise. 1360 */ 1361const isExternalLink = (link) => { 1362 return (
1363 link.hostname !== getWindowLocation().hostname && 1364 !link.classList.contains('btn') 1365 ); 1366}; 1367 1368/** 1369 * Asynchronously loads and configures the Cludo search integration on the page. 1370 * 1371 * This function dynamically creates a script element to set up the global `window.cludoConfig` 1372 * object with search configuration options, including customer and engine IDs, language, 1373 * search URL, input selectors, sorting, and facet options. It then loads the required Cludo 1374 * search scripts for search functionality. 1375 * 1376 * Facet keys are normalized by removing spaces, and multi-select is enabled for all facets. 1377 * The function supports environment-based configuration and allows for custom overrides via 1378 * global window variables. 1379 * 1380 * @async 1381 * @function loadSearchIntegration 1382 * @returns {Promise<void>} Resolves when all Cludo scripts are loaded and configured. 1383 */ 1384const loadSearchIntegration = async () => { 1385 // Cludo search integration 1386 const cludoScript = document.createElement('script'); 1387 cludoScript.type = 'text/javascript'; 1388 cludoScript.defer = true; 1389 const environment = getEnvironment(); 1390 const defaultEngineId = getSecret( 1391 `${environment ? `${environment.toUpperCase()}_` : ''}CLUDO_ENGINE_ID`, 1392 ); 1393 const engineId = window.cludoConfigEngineId || defaultEngineId; 1394 const searchUrl = window.searchUrl || '/search'; 1395 const facetKeys = 1396 Array.isArray(window.cludoConfigFacetkeys) && 1397 window.cludoConfigFacetkeys.length > 0 1398 ? window.cludoConfigFacetkeys 1399 : ['Content Type', 'Life Stage', 'Topics']; 1400 // Remove spaces from facetKeys values 1401 const normalizedFacetKeys = facetKeys.map((key) => key.replace(/\s+/g, '')); 1402 1403 // Build multiSelect object with all normalizedFacetKeys set to true 1404 const multiSelect = normalizedFacetKeys.reduce((acc, key) => { 1405 acc[key] = true; 1406 return acc; 1407 }, {}); 1408 1409 // Set global variable for variable sorting configuration 1410 // Note: window.sortByRecentPubDate controls whether to always sort by date 1411 window.currentSortByDate = true; // Always initialize current sort state to true 1412 // set up beforeSearch callback to manage sorting logic based on query and configuration 1413 window.cludoBeforeSearch = (searchParams) => { 1414 // Executes prior to fetching search results 1415 window.currentSortByDate = !(window.sortByRecentPubDate || false) 1416 ? !( 1417 searchParams.query && 1418 searchParams.query.trim() && 1419 searchParams.query.trim() !== '*' 1420 ) 1421 : true; 1422 1423 // Update the global config, search instance params, and search hash to ensure 1424 // consistency across searches 1425 if (window.cludoConfig) { 1426 const hashParams = new URLSearchParams(window.location.hash.substring(2)); 1427 if (window.currentSortByDate) { 1428 hashParams.set('cludosort', 'Date_date=desc');
1429 window.cludoConfig.sort = { Date_date: 'desc' }; 1430 if ( 1431 window.CludoSearchInstances && 1432 window.CludoSearchInstances[0] && 1433 window.CludoSearchInstances[0].params 1434 ) { 1435 window.CludoSearchInstances[0].params.sort = { Date_date: 'desc' }; 1436 } 1437 } else { 1438 if (hashParams) hashParams.delete('cludosort'); 1439 if (window.cludoConfig.sort) delete window.cludoConfig.sort.Date_date; 1440 if ( 1441 window.CludoSearchInstances && 1442 window.CludoSearchInstances[0] && 1443 window.CludoSearchInstances[0].params 1444 ) { 1445 window.CludoSearchInstances[0].params.sort = {}; 1446 } 1447 } 1448 const newHash = `#?${hashParams.toString()}`; 1449 window.location.hash = newHash; 1450 } 1451 }; 1452 // set up afterSearch callback to format dates in search results 1453 window.cludoAfterSearch = () => { 1454 const searchResults = document.querySelectorAll( 1455 '.cludo-search-results-item__date', 1456 ); 1457 const formatter = new Intl.DateTimeFormat( 1458 document.documentElement.lang || 'en-US', 1459 { 1460 day: 'numeric', 1461 month: 'short', 1462 year: 'numeric', 1463 }, 1464 ); 1465 searchResults.forEach((item) => { 1466 // Cludo provides the date formatted as 'mm/dd/yyyy hh:mm:ss' 1467 // try/catch used to handle unexpected date values from Cludo 1468 try { 1469 // Check if Cludo is returning a mm/dd/yyyy format 1470 const dateRegex = 1471 /^\d{1,2}\/\d{1,2}\/\d{4}(\s\d{1,2}:\d{2}:\d{2})?(\s[AP]M)?$/i; 1472 if (!dateRegex.test(item.textContent.trim())) { 1473 return; 1474 } 1475 const dateStr = item.textContent.split(' ')[0] || item.textContent; 1476 if (dateStr) { 1477 const dateParts = dateStr.trim().split('/');
1478 const month = Number(dateParts[0]) - 1; // Months are 0-indexed in JS Date 1479 const day = Number(dateParts[1]); 1480 const year = Number(dateParts[2]); 1481 item.textContent = formatter.format(new Date(year, month, day)); 1482 } 1483 } catch (error) { 1484 console.error( 1485 '{} Error formatting date: {}', 1486 'Error formatting date:', 1487 error, 1488 ); 1489 } 1490 }); 1491 }; 1492 1493 // Define the script content with the initial Cludo configuration and sorting logic 1494 cludoScript.textContent = ` 1495 window.cludoConfig = { 1496 customerId: "${getSecret('CLUDO_CUSTOMER_ID')}", 1497 engineId: "${engineId}", 1498 language: "en", 1499 searchUrl: "${searchUrl}", 1500 searchInputSelectors: ["#cludo-search-input"], 1501 behavior: { 1502 allowSearchWithoutSearchword: window.cludoShowDefaultResultsWithoutSearchWord ?? true, 1503 }, 1504 sort: { 1505 }, 1506 facets: { 1507 keys: ${JSON.stringify(normalizedFacetKeys)}, 1508 }, 1509 facetOptions: { 1510 multiSelect: ${JSON.stringify(multiSelect)} 1511 }, 1512 callbacks: { 1513 beforeSearch: (searchParams) => { // Executes prior to fetching search results 1514 window.cludoBeforeSearch(searchParams); // Call the global beforeSearch function to handle sorting logic 1515 }, 1516 afterSearch: (searchData) => { 1517 window.cludoAfterSearch(); // Call the global afterSearch function to format dates in results 1518 } 1519 } 1520 }; 1521 `; 1522 document.body.appendChild(cludoScript); 1523 1524 const scriptAttrs = { 1525 defer: true, 1526 type: 'text/javascript', 1527 }; 1528 1529 await loadScript( 1530 'https://customer.cludo.com/scripts/bundles/search-script.min.js', 1531 scriptAttrs, 1532 ); 1533 1534 await loadScript( 1535 'https://customer.cludo.com/templates/rich-inline/v1.0/dist/js/cludo-search-results.js', 1536 scriptAttrs, 1537 ); 1538 1539 const experience404Pages = { 1540 'data-cid': getSecret('CLUDO_CUSTOMER_ID'), 1541 'data-eid': engineId, 1542 defer: true, 1543 id: 'cludo-experience-manager', 1544 }; 1545 await loadScript( 1546 'https://customer.cludo.com/scripts/bundles/experiences/manager.js', 1547 experience404Pages, 1548 ); 1549}; 1550 1551/** 1552 * Processes and groups <p> tags within a column block based on specified block types. 1553 * Wraps matched groups of <p> tags in custom <div> structures according to block type configuration. 1554 * 1555 * @param {HTMLElement} columnBlock - The DOM element containing the column of <p> tags to process. 1556 * @param {Array<Object>} blockTypes - An array of block type objects, each with: 1557 * @param {string} blockTypes[].start - The text content marking the start of a block. 1558 * @param {string} blockTypes[].end - The text content marking the end of a block. 1559 * @param {string} blockTypes[].group - Whether to group all <p> tags together ("true") or wrap individually ("false"). 1560 * @param {string} blockTypes[].name - The class name to assign to the wrapper <div>. 1561 * 1562 * @returns {HTMLElement[]} An array of wrapper <div> elements created for each processed block. 1563 */ 1564const processBlocksInColumn = (columnBlock, blockTypes) => { 1565 const blocksInColumn = []; 1566 if (columnBlock) { 1567 const pTags = Array.from(columnBlock.querySelectorAll('p')); 1568 blockTypes.forEach(({ end, group, name, start }) => { 1569 // Find all start indices for this block type 1570 const startIndices = pTags 1571 .map((p, idx) => (p.textContent.trim() === start ? idx : -1)) 1572 .filter((idx) => idx !== -1); 1573 1574 startIndices.forEach((startIdx) => { 1575 // Find the next end marker after startIdx 1576 let endIdx = -1; 1577 for (let i = startIdx + 1; i < pTags.length; i++) { 1578 if (pTags[i].textContent.trim() === end) { 1579 endIdx = i; 1580 break; 1581 } 1582 } 1583 // If no end found, group until end of pTags 1584 const groupPtags = 1585 endIdx !== -1 1586 ? pTags.slice(startIdx, endIdx + 1) 1587 : pTags.slice(startIdx); 1588 1589 if (groupPtags.length > 0) { 1590 const wrapper = document.createElement('div'); 1591 wrapper.className = name; 1592 1593 if (group === 'true') { 1594 // Group all p tags together as before 1595 const firstOuterDiv = document.createElement('div'); 1596 const firstInnerDiv = document.createElement('div'); 1597 firstInnerDiv.innerHTML = groupPtags[0].innerHTML; 1598 firstOuterDiv.appendChild(firstInnerDiv); 1599 wrapper.appendChild(firstOuterDiv); 1600 1601 if (groupPtags.length > 1) { 1602 const restOuterDiv = document.createElement('div'); 1603 const restInnerDiv = document.createElement('div');
1604 groupPtags.slice(1).forEach((p, idx) => { 1605 // Skip the end marker p tag 1606 if (!(endIdx !== -1 && startIdx + idx + 1 === endIdx)) { 1607 const pElem = document.createElement('p'); 1608 pElem.innerHTML = p.innerHTML; 1609 restInnerDiv.appendChild(pElem); 1610 } 1611 }); 1612 restOuterDiv.appendChild(restInnerDiv); 1613 wrapper.appendChild(restOuterDiv); 1614 } 1615 } else { 1616 // For group === 'false', wrap each p tag individually 1617 groupPtags.forEach((p, idx) => { 1618 // Skip the end marker p tag 1619 if (!(endIdx !== -1 && startIdx + idx === endIdx)) { 1620 const outerDiv = document.createElement('div'); 1621 const innerDiv = document.createElement('div'); 1622 if (name !== 'fragment') { 1623 const pElem = document.createElement('p'); 1624 pElem.innerHTML = p.innerHTML; 1625 innerDiv.appendChild(pElem); 1626 } else { 1627 innerDiv.appendChild(p.cloneNode(true)); 1628 } 1629 outerDiv.appendChild(innerDiv); 1630 wrapper.appendChild(outerDiv); 1631 } 1632 }); 1633 } 1634 1635 columnBlock.insertBefore(wrapper, groupPtags[0]);
1636 groupPtags.forEach((p) => columnBlock.removeChild(p)); 1637 blocksInColumn.push(wrapper); 1638 } 1639 }); 1640 }); 1641 } 1642 return blocksInColumn; 1643}; 1644 1645/** 1646 * Injects a hidden <div> containing SVG icon definitions into the document body. 1647 * The SVGs are registered with unique IDs for use throughout the application. 1648 * This function should be called once to ensure icons are available for referencing via <use> tags. 1649 * 1650 * @function 1651 */ 1652const registerSvgIcons = () => { 1653 document.body.insertAdjacentHTML( 1654 'afterbegin', 1655 html` 1656 <div style="display: none;"> 1657 <svg 1658 id="dark-logo" 1659 xmlns="http://www.w3.org/2000/svg" 1660 viewBox="0 0 81 81" 1661 > 1662 <path 1663 d="m24.36 3.51-7.81 4.52 8.77 15.15h10.43zM56.6 77.24l7.81-4.52-8.78-15.17H45.2l11.39 19.69ZM57.62 55.45l15.27 8.79 4.5-7.82-19.77-11.38zM8.08 16.51l-4.5 7.82 19.81 11.4V25.32zM.5 35.84v9h22.88v-9zM80.5 44.84v-9H57.62v9zM77.36 24.26l-4.52-7.81-15.22 8.8v10.43zM3.61 56.49l4.52 7.81 15.26-8.82V45.05zM16.62 72.77l7.82 4.5L35.8 57.55H25.38zM64.35 7.98l-7.82-4.5-11.35 19.7H55.6zM35.98 80.36h8.96V57.55h-8.96zM44.94.36h-8.96v22.81h8.96z" 1664 fill="#f3f5fb" 1665 ></path> 1666 </svg> 1667 <svg 1668 id="light-logo" 1669 xmlns="http://www.w3.org/2000/svg"
1670 viewBox="0 0 80 80" 1671 > 1672 <path 1673 d="m23.86 3.14-7.81 4.52 8.77 15.15h10.43zM56.1 76.88l7.81-4.52-8.78-15.17H44.7l11.39 19.69Z" 1674 fill="#902b8a" 1675 ></path> 1676 <path 1677 d="m57.12 55.08 15.27 8.79 4.5-7.82-19.77-11.38zM7.58 16.15l-4.51 7.82 19.81 11.41V24.96z" 1678 fill="#5c62a7" 1679 ></path> 1680 <path 1681 d="M0 35.48v9h22.88v-9zm80 8.99v-9H57.12v9z" 1682 fill="#0094cf" 1683 ></path> 1684 <path 1685 d="m76.86 23.89-4.52-7.81-15.22 8.8v10.43zM3.11 56.13l4.52 7.81 15.26-8.83V44.68z" 1686 fill="#00b2ad" 1687 ></path> 1688 <path 1689 d="m16.12 72.41 7.82 4.5L35.3 57.19H24.88zm47.73-64.8-7.82-4.5-11.35 19.7H55.1z" 1690 fill="#89c64a" 1691 ></path> 1692 <path 1693 d="M35.48 80h8.96V57.19h-8.96zm8.96-80h-8.96v22.81h8.96z" 1694 fill="#bf57a0" 1695 ></path> 1696 </svg> 1697 <svg 1698 id="contrast-logo" 1699 xmlns="http://www.w3.org/2000/svg"
1700 viewBox="0 0 80 80" 1701 > 1702 <path 1703 d="m23.86 3.14-7.81 4.52 8.77 15.15h10.43zM56.1 76.88l7.81-4.52-8.78-15.17H44.7l11.39 19.69Z" 1704 fill="#ac4c9c" 1705 ></path> 1706 <path 1707 d="m57.12 55.08 15.27 8.79 4.5-7.82-19.77-11.38zM7.58 16.15l-4.51 7.82 19.81 11.41V24.96z" 1708 fill="#6f7bbb" 1709 ></path> 1710 <path 1711 d="M0 35.48v9h22.88v-9zm80 8.99v-9H57.12v9z" 1712 fill="#6ac0eb" 1713 ></path> 1714 <path 1715 d="m76.86 23.89-4.52-7.81-15.22 8.8v10.43zM3.11 56.13l4.52 7.81 15.26-8.83V44.68z" 1716 fill="#53c4c6" 1717 ></path> 1718 <path 1719 d="m16.12 72.41 7.82 4.5L35.3 57.19H24.88zm47.73-64.8-7.82-4.5-11.35 19.7H55.1z" 1720 fill="#aad269" 1721 ></path> 1722 <path 1723 d="M35.48 80h8.96V57.19h-8.96zm8.96-80h-8.96v22.81h8.96z" 1724 fill="#f179af" 1725 ></path> 1726 </svg> 1727 <svg 1728 id="background-logo" 1729 xmlns="http://www.w3.org/2000/svg"
1730 viewBox="0 0 628 628" 1731 preserveAspectRatio="xMidYMid meet" 1732 > 1733 <path 1734 d="m187.24 24.67-61.31 35.47 68.78 118.88h81.83zM440.19 603.33l61.31-35.47-68.87-119.04H350.8l89.39 154.52ZM448.16 432.28l119.8 68.98 35.34-61.38-155.15-89.33v81.73ZM59.47 126.74l-35.34 61.38 155.44 89.5v-81.73zM0 278.42v70.6h179.56v-70.6zM627.72 349.02v-70.6H448.16v70.6zM603.04 187.53l-35.47-61.31-119.41 69.09v81.83zM24.39 440.48l35.47 61.31 119.71-69.26V350.7zM126.46 568.25l61.38 35.34 89.12-154.78h-81.73zM500.97 59.75l-61.38-35.34-89.02 154.61h81.73zM278.41 627.83h70.29V448.81h-70.29zM348.7 0h-70.29v179.02h70.29z" 1735 fill="currentColor" 1736 ></path> 1737 </svg> 1738 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" id="person"> 1739 <path 1740 d="M8 8c-1.1 0-2.04-.39-2.82-1.18C4.4 6.04 4 5.1 4 4s.39-2.04 1.18-2.82S6.9 0 8 0s2.04.39 2.82 1.18C11.6 1.96 12 2.9 12 4s-.39 2.04-1.18 2.82C10.04 7.6 9.1 8 8 8m-6 8c-1.1 0-2-.9-2-2v-.8c0-.57.15-1.09.44-1.56.29-.48.68-.84 1.16-1.09 1.03-.52 2.08-.9 3.15-1.16a13.7 13.7 0 0 1 6.5 0c1.07.26 2.12.65 3.15 1.16.48.25.87.61 1.16 1.09.29.47.44 1 .44 1.56v.8c0 1.1-.9 2-2 2zm0-2h12v-.8c0-.18-.05-.35-.14-.5a.93.93 0 0 0-.36-.35c-.9-.45-1.81-.79-2.73-1.01S8.93 11 8 11s-1.86.11-2.77.34c-.92.23-1.83.56-2.73 1.01a.93.93 0 0 0-.36.35c-.09.15-.14.32-.14.5zm6-8c.55 0 1.02-.2 1.41-.59S10 4.55 10 4s-.2-1.02-.59-1.41S8.55 2 8 2s-1.02.2-1.41.59S6 3.45 6 4s.2 1.02.59 1.41S7.45 6 8 6" 1741 fill="currentColor" 1742 /> 1743 </svg> 1744 <svg 1745 id="external-link" 1746 xmlns="http://www.w3.org/2000/svg"
1747 viewBox="0 0 12 12" 1748 > 1749 <path 1750 d="M3.59 1.19h7.22m0 0v7.22m0-7.22-9.62 9.62" 1751 stroke="currentColor" 1752 stroke-linecap="round" 1753 stroke-linejoin="round" 1754 stroke-width="1.5" 1755 ></path> 1756 </svg> 1757 <svg 1758 id="forward-arrow-front" 1759 xmlns="http://www.w3.org/2000/svg" 1760 viewBox="0 0 11 11" 1761 > 1762 <path 1763 d="M9.2 6.3c.44 0 .8-.36.8-.8s-.36-.8-.8-.8zM-10 5.5v.8H9.2V4.7H-10z" 1764 fill="currentColor" 1765 ></path> 1766 <path 1767 d="M4.74.71 9.61 5.5l-4.87 4.78" 1768 fill="none" 1769 stroke="currentColor" 1770 stroke-width="2" 1771 ></path> 1772 </svg> 1773 <svg 1774 id="forward-arrow-back" 1775 xmlns="http://www.w3.org/2000/svg" 1776 viewBox="0 0 11 11" 1777 > 1778 <path 1779 d="M9.2 6.3c.44 0 .8-.36.8-.8s-.36-.8-.8-.8zM-10 5.5v.8H9.2V4.7H-10z" 1780 fill="currentColor" 1781 ></path> 1782 </svg> 1783 <svg 1784 id="down-caret" 1785 xmlns="http://www.w3.org/2000/svg"
1786 viewBox="0 0 16 10" 1787 > 1788 <path 1789 d="M1.88 0 8 6.18 14.12 0 16 1.9 8 10 0 1.9z" 1790 fill="currentColor" 1791 ></path> 1792 </svg> 1793 <svg 1794 id="chevron-left" 1795 xmlns="http://www.w3.org/2000/svg" 1796 viewBox="0 0 16 16" 1797 fill="none" 1798 stroke="currentColor" 1799 > 1800 <path 1801 d="M10 12L6 8L10 4" 1802 stroke-width="1.5" 1803 stroke-linecap="round" 1804 stroke-linejoin="round" 1805 /> 1806 </svg> 1807 </div> 1808 `, 1809 ); 1810}; 1811 1812/** 1813 * Sanitizes a URL by allowing only http, https, mailto schemes, or relative URLs. 1814 * Returns the original URL if valid, otherwise returns '#'. 1815 * 1816 * @param {string} url - The URL to sanitize. 1817 * @returns {string} The sanitized URL or '#' if invalid or disallowed. 1818 */ 1819const sanitizeUrl = (url) => { 1820 // Only allow http, https, mailto, tel schemes, or relative URLs 1821 try { 1822 if (!url || typeof url !== 'string') { 1823 return '#'; 1824 } 1825 1826 // Check if it's a relative URL (starts with / or ./ or ../ or doesn't contain :) 1827 if ( 1828 url.startsWith('/') || 1829 url.startsWith('./') || 1830 url.startsWith('../') || 1831 !url.includes(':') 1832 ) { 1833 // Strip characters that could break HTML attributes if this URL is later serialized 1834 return url.replace(/["'<>`]/g, ''); 1835 } 1836 1837 const parsed = new URL(url, getWindowLocation().origin); 1838 if (!['http:', 'https:', 'mailto:', 'tel:'].includes(parsed.protocol)) { 1839 // If protocol is not allowed, fallback to '#' 1840 return '#'; 1841 } 1842 1843 // For mailto or tel, return the fully-qualified href produced by URL. 1844 if (parsed.protocol === 'mailto:' || parsed.protocol === 'tel:') { 1845 return parsed.href.replace(/["'<>`]/g, ''); 1846 } 1847 1848 // For http/https, return a normalized absolute URL composed from the parsed parts. 1849 const pathname = parsed.pathname || ''; 1850 const search = parsed.search || ''; 1851 const hash = parsed.hash || ''; 1852 let result = `${parsed.origin}${pathname}${search}${hash}`; 1853 1854 // Remove trailing '/' if not present in original url parameter 1855 if (!url.endsWith('/') && result.endsWith('/')) { 1856 result = result.slice(0, -1); 1857 } 1858 1859 // Strip characters that could break HTML attributes if this URL is later serialized 1860 return result.replace(/["'<>`]/g, ''); 1861 } catch { 1862 // If URL parsing fails (e.g., malformed), return '#' 1863 return '#'; 1864 } 1865}; 1866 1867/** 1868 * Processes all heading elements (h1-h6) within elements matching 1869 * '.default-content-wrapper' and '.columns-wrapper'. 1870 * - Removes empty headings. 1871 * - Converts h5 and h6 headings to paragraphs, preserving their content and attributes. 1872 * - Changes heading tags to the next appropriate heading tag as determined by `getNextHeadingTag`. 1873 * - Adds the original heading tag name as a class to the new/replaced element. 1874 * 1875 * @function setRTEHeadingTags 1876 * @returns {void} 1877 */ 1878const setRTEHeadingTags = () => { 1879 document 1880 .querySelectorAll('.default-content-wrapper, .columns-wrapper') 1881 .forEach((wrapper) => { 1882 wrapper.querySelectorAll('h1, h2, h3, h4, h5, h6').forEach((heading) => { 1883 if (!heading.textContent.trim()) { 1884 heading.remove(); 1885 return; 1886 } 1887 const originalTag = heading.tagName.toLowerCase(); 1888 const newTag = getNextHeadingTag(heading); 1889 if (originalTag === 'h5' || originalTag === 'h6' || newTag === 'p') { 1890 const p = document.createElement('p'); 1891 p.innerHTML = heading.innerHTML; 1892 p.classList.add(originalTag); 1893 Array.from(heading.attributes).forEach((attr) => { 1894 if (attr.name !== 'class') p.setAttribute(attr.name, attr.value); 1895 }); 1896 heading.replaceWith(p); 1897 } else if (originalTag !== newTag) { 1898 const replacement = document.createElement(newTag); 1899 replacement.innerHTML = heading.innerHTML; 1900 replacement.classList.add(originalTag); 1901 Array.from(heading.attributes).forEach((attr) => { 1902 if (attr.name !== 'class') 1903 replacement.setAttribute(attr.name, attr.value); 1904 }); 1905 heading.replaceWith(replacement); 1906 } else { 1907 heading.classList.add(originalTag); 1908 } 1909 }); 1910 }); 1911}; 1912 1913/** 1914 * Sets up scroll-based active state management for navigation links. 1915 * 1916 * @param {Array<{element: HTMLElement, id: string}>} anchorLinks - Array of anchor link objects. 1917 * @returns {Object} Controller object with init, cleanup, and setClickScrolling methods. 1918 */ 1919const setupActiveStateObserver = (anchorLinks) => { 1920 let activeId = null; 1921 let isClickScrolling = false;
1922 1923 const updateActiveLink = () => { 1924 // Skip active state updates during click-initiated scrolls 1925 if (isClickScrolling) return; 1926 1927 const threshold = 120; 1928 let activeSection = null; 1929 const logMsg = []; 1930 1931 for (let i = anchorLinks.length - 1; i >= 0; i -= 1) { 1932 const anchorId = anchorLinks[i].id; 1933 let { element } = anchorLinks[i]; 1934 1935 if (!element.parentElement) { 1936 element = document.getElementById(anchorId); 1937 if (element) { 1938 anchorLinks[i].element = element; 1939 } else { 1940 logMsg.push(`${i}.${anchorId} MISSING`); 1941 } 1942 } 1943 1944 if (element) { 1945 let rect = element.getBoundingClientRect(); 1946 const msg = `${i}.${anchorId} h2(${rect.top.toFixed(0)},${rect.height.toFixed(0)})`; 1947 1948 if (rect.height === 0) { 1949 const parent = element.closest( 1950 '.section-title, .section-title-wrapper, .default-content-wrapper', 1951 ); 1952 if (parent) { 1953 rect = parent.getBoundingClientRect(); 1954 logMsg.push( 1955 `${msg}->parent(${rect.top.toFixed(0)},${rect.height.toFixed(0)})`, 1956 ); 1957 } else { 1958 const actualParent = element.parentElement; 1959 const pClass = actualParent ? actualParent.className : 'NULL'; 1960 logMsg.push(`${msg}->NO_MATCH(${pClass})`); 1961 } 1962 } else { 1963 logMsg.push(msg); 1964 } 1965 1966 if (rect.height > 0 && rect.top <= threshold) { 1967 activeSection = anchorId; 1968 logMsg.push(`ACTIVE:${anchorId}`); 1969 break; 1970 } 1971 } 1972 } 1973 1974 if (!activeSection && anchorLinks.length > 0) { 1975 activeSection = anchorLinks[0].id; 1976 } 1977 1978 if (activeSection && activeSection !== activeId) { 1979 activeId = activeSection; 1980 document 1981 .querySelectorAll('.anchor-nav-link.active') 1982 .forEach((l) => l.classList.remove('active')); 1983 document.querySelectorAll(`[data-target="${activeId}"]`).forEach((l) => { 1984 l.classList.add('active'); 1985 // Scroll active link into view within the nav list 1986 l.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); 1987 }); 1988 } 1989 }; 1990 1991 let ticking = false; 1992 const handleScroll = () => { 1993 if (!ticking) { 1994 window.requestAnimationFrame(() => { 1995 updateActiveLink(); 1996 ticking = false; 1997 }); 1998 ticking = true; 1999 } 2000 }; 2001 2002 return { 2003 cleanup: () => window.removeEventListener('scroll', handleScroll), 2004 forceUpdate: (targetId) => { 2005 // Force the active state to a specific section 2006 if (targetId) { 2007 activeId = targetId; 2008 document 2009 .querySelectorAll('.anchor-nav-link.active') 2010 .forEach((l) => l.classList.remove('active')); 2011 document 2012 .querySelectorAll(`[data-target="${targetId}"]`) 2013 .forEach((l) => l.classList.add('active')); 2014 } else { 2015 updateActiveLink(); 2016 } 2017 }, 2018 init: () => { 2019 window.addEventListener('scroll', handleScroll, { passive: true }); 2020 updateActiveLink(); 2021 }, 2022 setClickScrolling: (value) => { 2023 isClickScrolling = value; 2024 }, 2025 }; 2026}; 2027 2028/** 2029 * Sets up anchor navigation for a given section by generating anchor links for headings (h2, h3, h4), 2030 * excluding those within elements with the classes 'quote' or 'columns'. Adds navigation elements 2031 * for both desktop and mobile, assigns unique IDs to headings, and tracks impressions for analytics. 2032 * Also manages active state for anchor links and applies styling to quote elements. 2033 * 2034 * @param {HTMLElement} section - The DOM element representing the section to set up anchor navigation for. 2035 */ 2036const setupAnchorNavigation = (section) => { 2037 const headings = section.querySelectorAll('h2, h3, h4'); 2038 2039 const filteredHeadings = Array.from(headings).filter((heading) => { 2040 return ( 2041 heading.closest('.quote') === null && heading.closest('.columns') === null 2042 ); 2043 }); 2044 2045 if (filteredHeadings.length === 0) { 2046 console.warn('No headings found for anchor navigation'); 2047 return; 2048 } 2049 2050 const anchorLinks = [];
2051 filteredHeadings.forEach((heading, index) => { 2052 let anchorId = slugify(heading.textContent); 2053 2054 // If anchorId starts with a number, prefix with 'heading-' 2055 if (/^[0-9]/.test(anchorId)) { 2056 anchorId = `heading-${anchorId}`; 2057 } 2058 const existingIds = document.querySelectorAll(`#${anchorId}`); 2059 if (existingIds.length > 0) { 2060 anchorId = `${anchorId}-${index + 1}`; 2061 } 2062 2063 heading.id = anchorId; 2064 heading.setAttribute('data-anchor', anchorId); 2065 2066 anchorLinks.push({ 2067 element: heading, 2068 id: anchorId, 2069 text: heading.textContent.trim(), 2070 }); 2071 }); 2072 2073 // Set up the observer controller first (but don't start observing yet) 2074 const observerController = setupActiveStateObserver(anchorLinks); 2075 2076 const desktopNav = createDesktopNavigation( 2077 anchorLinks, 2078 section, 2079 observerController, 2080 ); 2081 const mobileNav = createMobileNavigation(anchorLinks); 2082 2083 const firstWrapper = section.querySelector('.default-content-wrapper'); 2084 if (firstWrapper) { 2085 section.insertBefore(desktopNav, firstWrapper); 2086 section.insertBefore(mobileNav, firstWrapper); 2087 } 2088 2089 // Initialize the observer (starts listening to scroll events) 2090 observerController.init(); 2091 2092 // Handle URL hash on page load - scroll to anchor if present 2093 const handleUrlHash = () => { 2094 const hash = window.location.hash.slice(1); // Remove the # symbol 2095 if (hash) { 2096 const targetElement = document.getElementById(hash); 2097 if (targetElement) { 2098 // Disable observer during hash scroll to prevent conflicts 2099 observerController.setClickScrolling(true); 2100 2101 // Wait for page to fully load and render before scrolling 2102 const scrollToHash = () => { 2103 targetElement.scrollIntoView({ behavior: 'smooth', block: 'start' }); 2104 2105 // Set the correct active state 2106 document 2107 .querySelectorAll('.anchor-nav-link.active') 2108 .forEach((link) => link.classList.remove('active')); 2109 const activeLinks = document.querySelectorAll( 2110 `[data-target="${hash}"]`, 2111 ); 2112 activeLinks.forEach((link) => link.classList.add('active')); 2113 2114 // Scroll the anchor nav list to show the active link 2115 const navList = section.querySelector('.anchor-nav-list'); 2116 const activeNavLink = section.querySelector( 2117 `.anchor-nav-link.active[data-target="${hash}"]`, 2118 ); 2119 if (navList && activeNavLink) { 2120 const navListRect = navList.getBoundingClientRect(); 2121 const linkRect = activeNavLink.getBoundingClientRect(); 2122 const navListScrollTop = navList.scrollTop; 2123 2124 // Calculate if link is outside visible area of nav list 2125 const linkTopRelative = linkRect.top - navListRect.top; 2126 const linkBottomRelative = linkRect.bottom - navListRect.top; 2127 2128 // Scroll nav list if active link is not fully visible 2129 if (linkTopRelative < 0) { 2130 // Link is above visible area 2131 navList.scrollTop = navListScrollTop + linkTopRelative - 10; 2132 } else if (linkBottomRelative > navListRect.height) { 2133 // Link is below visible area 2134 navList.scrollTop = 2135 navListScrollTop + 2136 (linkBottomRelative - navListRect.height) + 2137 10; 2138 } 2139 } 2140 2141 // Re-enable observer and force update after scroll completes 2142 setTimeout(() => { 2143 observerController.setClickScrolling(false); 2144 observerController.forceUpdate(hash); 2145 }, 1500); 2146 }; 2147 2148 // Wait for images and content to load before scrolling 2149 if (document.readyState === 'complete') { 2150 // Page already loaded, scroll after a delay for rendering 2151 setTimeout(scrollToHash, 300); 2152 } else { 2153 // Wait for page to fully load 2154 window.addEventListener( 2155 'load', 2156 () => { 2157 setTimeout(scrollToHash, 300); 2158 }, 2159 { once: true }, 2160 ); 2161 } 2162 2163 return true; // Indicate we handled a hash 2164 } 2165 } 2166 return false; // No hash or element not found 2167 }; 2168 2169 // Try to handle URL hash, if no hash was handled, check first heading 2170 if (!handleUrlHash()) { 2171 // TOC impression tracking for desktop nav (only for H2 headings) 2172 if (desktopNav) { 2173 recordTableOfContentsImpression(section, desktopNav, anchorLinks); 2174 } 2175 2176 // Section view tracking for H2 headings (60% threshold) 2177 recordJumpLinkImpression(section, anchorLinks); 2178 const firstHeading = anchorLinks[0]?.element; 2179 if (firstHeading) { 2180 const rect = firstHeading.getBoundingClientRect(); 2181 // Check if the heading is in the viewport (top is above bottom, bottom is below top) 2182 if (rect.top < window.innerHeight && rect.bottom > 0) { 2183 document 2184 .querySelectorAll('.anchor-nav-link.active') 2185 .forEach((link) => link.classList.remove('active')); 2186 document 2187 .querySelectorAll(`[data-target="${firstHeading.id}"]`) 2188 .forEach((link) => link.classList.add('active')); 2189 } 2190 } 2191 } else { 2192 // TOC impression tracking for desktop nav (only for H2 headings) 2193 if (desktopNav) { 2194 recordTableOfContentsImpression(section, desktopNav, anchorLinks); 2195 } 2196 2197 // Section view tracking for H2 headings (60% threshold) 2198 recordJumpLinkImpression(section, anchorLinks); 2199 } 2200 2201 section.querySelectorAll('.quote').forEach((quote) => { 2202 quote.classList.add('full-width'); 2203 }); 2204}; 2205 2206/** 2207 * Converts a given string into a URL-friendly slug. 2208 * 2209 * - Converts to lowercase and trims whitespace. 2210 * - Removes accents from characters. 2211 * - Replaces invalid characters with spaces. 2212 * - Collapses multiple spaces or hyphens into a single hyphen. 2213 * 2214 * @param {string} input - The string to be slugified. 2215 * @returns {string} The slugified version of the input string. 2216 */ 2217const slugify = (input) => { 2218 if (!input) return ''; 2219 2220 // make lower case and trim 2221 let slug = input.toLowerCase().trim(); 2222 2223 // remove accents from charaters 2224 slug = slug.normalize('NFD').replace(/[\u0300-\u036f]/g, ''); 2225 2226 // replace invalid chars with spaces 2227 slug = slug.replace(/[^a-z0-9\s-]/g, ' ').trim(); 2228 2229 // replace multiple spaces or hyphens with a single hyphen 2230 slug = slug.replace(/[\s-]+/g, '-'); 2231 2232 return slug; 2233}; 2234 2235/** 2236 * Smoothly scrolls a given element horizontally from a start position to a target position over a specified duration. 2237 * 2238 * @async 2239 * @param {HTMLElement} element - The element to scroll. 2240 * @param {number} start - The starting scroll position (relative to the element's offsetLeft). 2241 * @param {number} target - The target scroll position (relative to the element's offsetLeft). 2242 * @param {number}
2242 [duration=600] - The duration of the scroll animation in milliseconds. 2243 * @returns {Promise<void>} Resolves when the scrolling animation is complete. 2244 */ 2245const smoothScrollTo = async (element, start, target, duration = 600) => { 2246 const elementOffsetLeft = element.offsetLeft || 0; 2247 const adjustedStart = start - elementOffsetLeft; 2248 const adjustedTarget = target - elementOffsetLeft; 2249 const change = adjustedTarget - adjustedStart; 2250 const startTime = performance.now(); 2251 2252 const easeInOutQuad = (t) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t); 2253 2254 return new Promise((resolve) => { 2255 const animateScroll = (currentTime) => { 2256 const timeElapsed = currentTime - startTime; 2257 const progress = Math.min(timeElapsed / duration, 1); 2258 const easedProgress = easeInOutQuad(progress); 2259 2260 element.scrollLeft = adjustedStart + change * easedProgress; 2261 2262 if (progress < 1) { 2263 requestAnimationFrame(animateScroll); 2264 } else { 2265 resolve(); // Animation complete 2266 } 2267 }; 2268 2269 requestAnimationFrame(animateScroll); 2270 }); 2271}; 2272 2273/** 2274 * Smoothly scrolls a given element vertically from a start position to a target position over a specified duration. 2275 * 2276 * @async 2277 * @param {HTMLElement} element - The element to scroll. 2278 * @param {number} start - The starting scrollTop position. 2279 * @param {number} target - The target scrollTop position. 2280 * @param {number} [duration=600] - The duration of the scroll animation in milliseconds. 2281 * @returns {Promise<void>} A promise that resolves when the scrolling animation is complete. 2282 */ 2283const smoothScrollToVertical = async ( 2284 element, 2285 start, 2286 target, 2287 duration = 600, 2288) => { 2289 const change = target - start; 2290 const startTime = performance.now(); 2291 2292 const easeInOutQuad = (t) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t); 2293 2294 return new Promise((resolve) => { 2295 const animateScroll = (currentTime) => { 2296 const timeElapsed = currentTime - startTime; 2297 const progress = Math.min(timeElapsed / duration, 1); 2298 const easedProgress = easeInOutQuad(progress); 2299 2300 element.scrollTop = start + change * easedProgress; 2301 2302 if (progress < 1) { 2303 requestAnimationFrame(animateScroll); 2304 } else { 2305 resolve(); 2306 } 2307 }; 2308 2309 requestAnimationFrame(animateScroll); 2310 }); 2311}; 2312 2313/** 2314 * Swaps all heading tags (h1-h6) in the given HTML string to a new heading tag determined by `getNextHeadingTag`. 2315 * Optionally demotes h5 and h6 tags to paragraphs with their original tag as a class. 2316 * Preserves all attributes except for heading-specific classes, which are replaced with the original tag as a class. 2317 * 2318 * @param {string} htmlString - The HTML string containing headings to swap. 2319 * @param {Element|null} [contextElement=null] - An optional context element used to determine the next heading tag. 2320 * @param {boolean} [demoteH5H6=false] - If true, demotes h5 and h6 headings to paragraphs. 2321 * @returns {string} - The modified HTML string with swapped headings. 2322 */ 2323const swapHeadings = ( 2324 htmlString, 2325 contextElement = null, 2326 demoteH5H6 = false, 2327) => { 2328 const parser = new DOMParser(); 2329 const doc = parser.parseFromString(htmlString, 'text/html'); 2330 2331 const newTag = getNextHeadingTag(contextElement); 2332 2333 doc.querySelectorAll('h1, h2, h3, h4, h5, h6').forEach((heading) => { 2334 const originalTag = heading.tagName.toLowerCase(); 2335 if (demoteH5H6 && (originalTag === 'h5' || originalTag === 'h6')) { 2336 const p = doc.createElement('p'); 2337 p.classList.add(originalTag); 2338 p.innerHTML = heading.innerHTML; 2339 Array.from(heading.attributes).forEach((attr) => { 2340 if (attr.name !== 'class') p.setAttribute(attr.name, attr.value); 2341 }); 2342 heading.replaceWith(p); 2343 } else if (originalTag === newTag) { 2344 heading.classList.add(originalTag); 2345 } else { 2346 const replacement = doc.createElement(newTag); 2347 replacement.classList.add(originalTag); 2348 replacement.innerHTML = heading.innerHTML; 2349 Array.from(heading.attributes).forEach((attr) => { 2350 if (attr.name === 'class') { 2351 const originalClasses = (heading.getAttribute('class') || '').split( 2352 /\s+/, 2353 ); 2354 const filteredClasses = originalClasses.filter( 2355 (cls) => !/^h[1-6]$/.test(cls), 2356 ); 2357 replacement.classList.add(...filteredClasses, originalTag); 2358 } else { 2359 replacement.setAttribute(attr.name, attr.value); 2360 } 2361 }); 2362 heading.replaceWith(replacement); 2363 } 2364 }); 2365 2366 return doc.body.innerHTML; 2367}; 2368 2369/** 2370 * Updates the inner HTML of heading elements within a given block using the swapHeadings function. 2371 * 2372 * @param {Element} block - The DOM element containing the headings to update. 2373 * @param {string} headingSelector - A CSS selector string to identify heading elements within the block. 2374 * @param {boolean} [demoteH5H6=false] - If true, demotes <h5> and <h6> headings during the swap. 2375 */ 2376const updateBlockHeadings = (block, headingSelector, demoteH5H6 = false) => { 2377 requestAnimationFrame(() => { 2378 const headings = block.querySelectorAll(headingSelector);
2379 headings.forEach((headingEl) => { 2380 headingEl.innerHTML = swapHeadings( 2381 headingEl.innerHTML, 2382 headingEl, 2383 demoteH5H6, 2384 ); 2385 }); 2386 }); 2387}; 2388 2389/** 2390 * Updates all anchor elements in the document by checking if they are external links. 2391 * For each external link that does not already have a 'target' attribute, 2392 * applies decoration using the decorateExternalLink function. 2393 * 2394 * Relies on the helper functions isExternalLink(link) and decorateExternalLink(link). 2395 */ 2396const updateExternalLinks = () => { 2397 const links = document.querySelectorAll('a[href]'); 2398 links.forEach((link) => { 2399 if (isExternalLink(link) && link.hasAttribute('target') === false) { 2400 decorateExternalLink(link); 2401 } 2402 }); 2403}; 2404 2405// Utility to escape HTML special characters 2406const escapeHTML = (str) => { 2407 return String(str) 2408 .replace(/&/g, '&') 2409 .replace(/</g, '<') 2410 .replace(/>/g, '>') 2411 .replace(/"/g, '"') 2412 .replace(/'/g, '''); 2413}; 2414 2415/** 2416 * Escapes HTML special characters in text nodes only, 2417 * preserving existing HTML tags and adds class to non-text nodes. 2418 * 2419 * @param {string} htmlString - The HTML string to process. 2420 * @param {string} className - The class name to add to non-text nodes. 2421 * @returns {string} - HTML with text content escaped but tags preserved. 2422 */ 2423const escapeHtmlPreserveTags = (htmlString, className) => { 2424 const parser = new DOMParser(); 2425 const doc = parser.parseFromString(htmlString, 'text/html'); 2426 // recurses through child nodes 2427 function escapeTextNodes(node) { 2428 node.childNodes.forEach((child) => { 2429 if (child.nodeType === Node.TEXT_NODE) { 2430 child.textContent = escapeHTML(child.textContent); 2431 } else { 2432 if (child.nodeType === Node.ELEMENT_NODE) { 2433 child.classList.add(className); 2434 } 2435 escapeTextNodes(child); 2436 } 2437 }); 2438 } 2439 escapeTextNodes(doc.body); 2440 return doc.body.innerHTML; 2441}; 2442 2443// Decode the specified entities. 2444const ENTITY_MAP = { 2445 '©': '©', 2446 '®': '®', 2447 '™': 'â¢', 2448 '&': '&', 2449 '©': '©', 2450 '®': '®', 2451 '™': 'â¢', 2452}; 2453 2454// Build the regex from ENTITY_MAP keys, longest first to avoid partial matching 2455const ENTITY_REGEX = new RegExp( 2456 Object.keys(ENTITY_MAP) 2457 .sort((a, b) => b.length - a.length) 2458 .join('|'), 2459 'g', 2460); 2461 2462const updateTextCustomTags = () => { 2463 const smallCaps = 'small-caps'; 2464 2465 // Make a non-global copy of the entity regex for .test calls 2466 const ENTITY_TEST_REGEX = new RegExp(ENTITY_REGEX.source); 2467 2468 if ( 2469 !document.body.textContent.includes(`[${smallCaps}]`) && 2470 !/\[b3-(regular|medium|semibold)\]/.test(document.body.textContent) && 2471 !ENTITY_TEST_REGEX.test(document.body.textContent) 2472 ) { 2473 return; 2474 } 2475 2476 const elementsToBeProcessed = () => { 2477 const textElements = document.querySelectorAll( 2478 'a, h1, h2, h3, h4, h5, h6, p, span, td, th', 2479 ); 2480 const customTagsList = []; 2481 textElements.forEach((el) => { 2482 if ( 2483 el.textContent.includes(`[${smallCaps}]`) || 2484 /\[b3-(regular|medium|semibold)\]/.test(el.textContent) || 2485 ENTITY_TEST_REGEX.test(el.textContent) 2486 ) { 2487 customTagsList.push(el); 2488 } 2489 }); 2490 return customTagsList; 2491 }; 2492 2493 elementsToBeProcessed().forEach((el) => { 2494 const CUSTOM_TAG_REGEX = 2495 /\[(small-caps|b3-regular|b3-medium|b3-semibold)\]([\s\S]*?)\[\/\1\]/g; 2496 const originalText = el.innerHTML; 2497 2498 // Replace custom tags: run entity replacement INSIDE the tag content, then escape! 2499 let replacedText = originalText.replace(CUSTOM_TAG_REGEX, (_, p1, p2) => { 2500 const withEntityChars = p2.replace( 2501 ENTITY_REGEX, 2502 (m) => ENTITY_MAP[m] || m, 2503 ); 2504 return `<span class="${p1}">${escapeHtmlPreserveTags(withEntityChars, p1)}</span>`; 2505 }); 2506 2507 // Now replace entities in the rest of the text (outside custom tags) 2508 replacedText = replacedText.replace( 2509 ENTITY_REGEX, 2510 (m) => ENTITY_MAP[m] || m, 2511 ); 2512 2513 if (replacedText !== el.innerHTML) { 2514 el.innerHTML = replacedText; 2515 } 2516 }); 2517}; 2518 2519/** 2520 * Creates an SVG element with a <use> referencing a symbol by ID, optionally themed. 2521 * 2522 * @param {string} ref - The reference ID of the SVG symbol to use. 2523 * @param {number|string} width - The width of the SVG element. 2524 * @param {number|string} height - The height of the SVG element. 2525 * @param {string|null} [theme=null] - Optional theme ('light' or 'dark'). If provided, prefixes the symbol ID with the theme. 2526 * @returns {SVGSVGElement} The constructed SVG element containing the <use> element. 2527 */ 2528const useSvg = (ref, width, height, theme = null) => { 2529 const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); 2530 svg.setAttribute('width', width); 2531 svg.setAttribute('height', height); 2532 2533 const use = document.createElementNS('http://www.w3.org/2000/svg', 'use'); 2534 2535 if (theme) { 2536 if (theme === 'contrast') { 2537 use.setAttribute('href', `#contrast-${ref}`); 2538 } else if (theme === 'light') { 2539 use.setAttribute('href', `#light-${ref}`); 2540 } else { 2541 use.setAttribute('href', `#dark-${ref}`); 2542 } 2543 } else { 2544 use.setAttribute('href', `#${ref}`); 2545 } 2546 2547 svg.appendChild(use); 2548 2549 return svg; 2550}; 2551 2552/** 2553 * Updates the href attributes of a list of link elements by replacing an unescaped query string 2554 * parameter with its correctly escaped version. 2555 * 2556 * This function fixes URLs that incorrectly output the unescaped cludosort=Date_date=desc parameter 2557 * by replacing it with the correctly escaped cludosort=Date_date%3Ddesc required by Cludo. 2558 * 2559 * @param {NodeListOf<HTMLAnchorElement> | HTMLAnchorElement[]} linkUrlsList - 2560 * A list or array of anchor (<a>) elements whose href attributes may need to be updated. 2561 */ 2562const updateDeepLinkUrls = (linkUrlsList) => { 2563 const UNESCAPED_SORT = 'cludosort=Date_date=desc'; 2564 const CORRECT_SORT = 'cludosort=Date_date%3Ddesc'; 2565 if (!linkUrlsList) return; 2566 linkUrlsList.forEach((link) => { 2567 const currentHref = link.getAttribute('href'); 2568 if (currentHref && typeof currentHref === 'string') { 2569 if (currentHref.includes(UNESCAPED_SORT)) { 2570 const updatedHref = currentHref.replace(UNESCAPED_SORT, CORRECT_SORT); 2571 link.setAttribute('href', updatedHref); 2572 } 2573 } 2574 }); 2575}; 2576 2577export { 2578 addClassToElement, 2579 applyDataAttributesAsClasses, 2580 cleanSchema, 2581 createButtonElement, 2582 createCardsIconItem, 2583 createDesktopNavigation,
2584 createElementWithClasses, 2585 createFacetTags, 2586 createImageTextBlock, 2587 createMobileNavigation, 2588 createSectionTitleBlock, 2589 createVideoBlock, 2590 decorateExternalLink, 2591 extractElements, 2592 fetchAllTags, 2593 fetchQueryIndexWithCache, 2594 getBackgroundTheme, 2595 getCustomPlanSite, 2596 getEmailContent, 2597 getNextHeadingTag, 2598 html, 2599 initializeAnchorNavigation, 2600 injectLangAndHreflangTags, 2601 injectSchema, 2602 isExternalLink, 2603 loadSearchIntegration, 2604 processBlocksInColumn, 2605 registerSvgIcons, 2606 sanitizeUrl, 2607 setRTEHeadingTags, 2608 setupActiveStateObserver, 2609 setupAnchorNavigation, 2610 slugify, 2611 smoothScrollTo, 2612 smoothScrollToVertical, 2613 swapHeadings, 2614 updateBlockHeadings, 2615 updateDeepLinkUrls, 2616 updateExternalLinks, 2617 updateTextCustomTags, 2618 useSvg, 2619};
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.