PageSourceSearch

https://www.missionsquare.com/scripts/utils/tools.js

js missionsquare.com collected 2026-10-10 00:30:45 UTC 92,325 bytes, 2,619 lines download raw bytes

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, '&amp;')
2409    .replace(/</g, '&lt;')
2410    .replace(/>/g, '&gt;')
2411    .replace(/"/g, '&quot;')
2412    .replace(/'/g, '&#39;');
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  '&#169;': '©',
2446  '&#174;': '®',
2447  '&#8482;': '™',
2448  '&amp;': '&',
2449  '&copy;': '©',
2450  '&reg;': '®',
2451  '&trade;': '™',
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.