PageSourceSearch

https://www.visitasilomar.com/blocks/fragment/fragment.js

js visitasilomar.com collected 2026-09-24 10:19:10 UTC 51,316 bytes, 1,660 lines download raw bytes

1/**
2 * Fragment Block
3 * - Provides lifecycle hooks (onBefore/onAfter)
4 * - Dispatches before/after events
5 * - Loads and includes content from other pages as fragments
6 * - Renders detail-page content from authored content fragment configuration
7 */
8
9import {
10  decorateMain,
11  moveInstrumentation,
12  readVariant,
13} from '../../scripts/scripts.js';
14
15import {
16  buildBlock,
17  decorateBlock,
18  loadBlock,
19  loadSections,
20} from '../../scripts/aem.js';
21
22import {
23  getConfigValue,
24  readConfig,
25} from '../../scripts/baici/utils/config.js';
26
27import { pushLinkClickAnalytics } from '../../scripts/analytics.js';
28
29import {
30  CTA_DEFAULTS,
31  CTA_TOKENS,
32} from '../cta/tokens.js';
33
34import {
35  DETAIL_LAYOUTS,
36  buildArticle as buildBaseArticle,
37  buildDetailContent as buildBaseDetailContent,
38  buildGallery as buildBaseGallery,
39  detectDetailPageType,
40  getEventCategoryDisplayValue,
41  getFragmentValue,
42  appendRenderedValue,
43} from './detailBlockLayout/detail-page-renderer.js';
44
45/**
46 * Cache of in-flight (or resolved) fetch Promises keyed by path.
47 * Stores the raw HTML text Promise — NOT the DOM element — so each caller
48 * gets their own mutable DOM tree while only one network request is made.
49 * The header block also seeds this cache via window.hlx.navFragmentFetch
50 * so the very first nav fetch (started at the top of loadEager) is reused here.
51 */
52const fragmentHtmlCache = new Map();
53const resourceJsonCache = new Map();
54const schemaFragmentCache = new Map();
55
56const FRAGMENT_FIELDS = [
57  { key: 'reference', defaultValue: '' },
58  { key: 'detailBlockLayout', defaultValue: 'article' },
59  { key: 'contentFragmentConfig', defaultValue: '' },
60];
61
62const DETAIL_TRUE_VALUES = new Set(['true', '1', 'yes', 'y']);
63const CTA_FRAGMENT_CONFIGS = [
64  {
65    fragmentName: 'cta1-fields',
66    screenReaderKey: 'cta1SRText',
67    subTextKey: 'detailPageCta1SubText',
68    defaultStyle: CTA_TOKENS.STYLE.FILLED,
69    defaultColor: CTA_TOKENS.COLOR.PRIMARY,
70  },
71  {
72    fragmentName: 'cta2-fields',
73    screenReaderKey: 'cta2SRText',
74    subTextKey: 'detailPageCta2SubText',
75    defaultStyle: CTA_TOKENS.STYLE.OUTLINED,
76    defaultColor: CTA_TOKENS.COLOR.WHITE,
77  },
78];
79const CTA_STYLE_OPTIONS = new Set(Object.values(CTA_TOKENS.STYLE));
80const CTA_COLOR_OPTIONS = new Set(Object.values(CTA_TOKENS.COLOR));
81const CTA_COLOR_ALIASES = new Map([
82  ['primary', CTA_TOKENS.COLOR.PRIMARY],
83  ['primarycolor', CTA_TOKENS.COLOR.PRIMARY],
84  ['colorprimary', CTA_TOKENS.COLOR.PRIMARY],
85  ['secondary', CTA_TOKENS.COLOR.SECONDARY],
86  ['secondarycolor', CTA_TOKENS.COLOR.SECONDARY],
87  ['colorsecondary', CTA_TOKENS.COLOR.SECONDARY],
88  ['tertiary', CTA_TOKENS.COLOR.TERTIARY],
89  ['tertiarycolor', CTA_TOKENS.COLOR.TERTIARY],
90  ['colortertiary', CTA_TOKENS.COLOR.TERTIARY],
91  ['black', CTA_TOKENS.COLOR.BLACK],
92  ['colorblack', CTA_TOKENS.COLOR.BLACK],
93  ['white', CTA_TOKENS.COLOR.WHITE],
94  ['colorwhite', CTA_TOKENS.COLOR.WHITE],
95]);
96const IMAGE_FRAGMENT_NAMES = ['image-with-alt-fields', 'media-image-fields'];
97
98function toText(value) {
99  return value === null || value === undefined ? '' : String(value).trim();
100}
101
102function humanizeFieldName(value) {
103  return toText(value)
104    .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
105    .replace(/[-_]+/g, ' ')
106    .replace(/\s+/g, ' ')
107    .trim()
108    .replace(/\b\w/g, (char) => char.toUpperCase());
109}
110
111function isObject(value) {
112  return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
113}
114
115function isTruthyValue(value) {
116  const normalized = toText(value).toLowerCase();
117  return DETAIL_TRUE_VALUES.has(normalized);
118}
119
120function getConditionVar(field) {
121  const conditions = field?.condition?.['!!'];
122  if (!Array.isArray(conditions)) return '';
123
124  return toText(conditions.find((entry) => entry?.var)?.var);
125}
126
127function getFieldSuffix(baseFieldName, fieldName) {
128  const base = toText(baseFieldName);
129  const field = toText(fieldName);
130
131  if (!base || !field.toLowerCase().startsWith(base.toLowerCase())) {
132    return '';
133  }
134
135  return field.slice(base.length);
136}
137
138function deriveImageAltSuffixes(fields) {
139  const sourceField = fields.find(({ component }) => component === 'reference');
140  if (!sourceField) {
141    return [];
142  }
143
144  return fields
145    .filter((field) => {
146      const conditionVar = getConditionVar(field);
147      return conditionVar === sourceField.name && field.component === 'text';
148    })
149    .map((field) => getFieldSuffix(sourceField.name, field.name))
150    .filter(Boolean);
151}
152
153function getSchemaFragmentUrl(fragmentName) {
154  return `/models/fragments/${fragmentName}.json`;
155}
156
157async function loadSchemaFragment(fragmentName) {
158  const url = getSchemaFragmentUrl(fragmentName);
159
160  if (!schemaFragmentCache.has(url)) {
161    schemaFragmentCache.set(
162      url,
163      fetch(url)
164        .then((response) => (response.ok ? response.json() : []))
165        .catch(() => []),
166    );
167  }
168
169  const fragment = await schemaFragmentCache.get(url);
170  return Array.isArray(fragment) ? fragment : [];
171}
172
173function deriveCtaSchema(fragmentConfig, fields) {
174  const linkField = fields.find((field) => /link$/i.test(toText(field.name)));
175  if (!linkField) {
176    return null;
177  }
178
179  const ctaKey = linkField.name.replace(/Link$/i, '');
180  const textField = fields.find((field) => field.name === `${ctaKey}Text`);
181  const styleField = fields.find((field) => field.name === `${ctaKey}Style`);
182  const colorField = fields.find((field) => field.name === `${ctaKey}Color`);
183
184  return {
185    key: ctaKey,
186    linkField: linkField.name,
187    textField: textField?.name || `${ctaKey}Text`,
188    styleField: styleField?.name || `${ctaKey}Style`,
189    styleOptions: new Set(
190      (styleField?.options || []).map(({ value }) => toText(value).toLowerCase()),
191    ),
192    defaultStyle: toText(styleField?.value).toLowerCase()
193      || fragmentConfig.defaultStyle
194      || CTA_DEFAULTS.STYLE,
195    colorField: colorField?.name || `${ctaKey}Color`,
196    colorOptions: new Set(
197      (colorField?.options || []).map(({ value }) => toText(value).toLowerCase()),
198    ),
199    defaultColor: toText(colorField?.value).toLowerCase()
200      || fragmentConfig.defaultColor
201      || CTA_DEFAULTS.COLOR,
202    themeColorField: `${ctaKey}ThemeColor`,
203    screenReaderField: fragmentConfig.screenReaderKey,
204    subTextField: fragmentConfig.subTextKey,
205  };
206}
207
208function normalizeColorAlias(value) {
209  const rawValue = toText(value).toLowerCase();
210  if (!rawValue) return '';
211
212  const compact = rawValue.replace(/[^a-z0-9]/g, '');
213  return CTA_COLOR_ALIASES.get(compact) || rawValue;
214}
215
216function findFieldNameCaseInsensitive(fieldsByLower, name, fallback = '') {
217  const normalizedName = toText(name).toLowerCase();
218  if (!normalizedName) return fallback;
219
220  return fieldsByLower.get(normalizedName)
221    || fieldsByLower.get(normalizedName.replace(/subtext$/, 'subtext'))
222    || fallback;
223}
224
225/**
226 * Build CTA schemas directly from detail fields declared in config rows.
227 * This keeps CTA rendering aligned with the authored CSV/model config and
228 * avoids requiring cta{n}-fields schema JSON availability.
229 */
230function buildCtaSchemasFromDetailFields(fields) {
231  const fieldsByLower = new Map(fields.map((field) => [field.name.toLowerCase(), field.name]));
232
233  return CTA_FRAGMENT_CONFIGS.map((fragmentConfig) => {
234    const ctaKey = toText(fragmentConfig.screenReaderKey)
235      .replace(/SRText$/i, '')
236      .toLowerCase();
237
238    if (!ctaKey) return null;
239
240    const resolve = (suffix) => (
241      findFieldNameCaseInsensitive(fieldsByLower, `${ctaKey}${suffix}`, `${ctaKey}${suffix}`)
242    );
243    const linkField = resolve('Link');
244
245    if (!fieldsByLower.has(linkField.toLowerCase())) {
246      return null;
247    }
248
249    return {
250      key: ctaKey,
251      linkField,
252      textField: resolve('Text'),
253      styleField: resolve('Style'),
254      styleOptions: CTA_STYLE_OPTIONS,
255      defaultStyle: fragmentConfig.defaultStyle,
256      colorField: resolve('Color'),
257      colorOptions: CTA_COLOR_OPTIONS,
258      defaultColor: fragmentConfig.defaultColor,
259      themeColorField: resolve('ThemeColor'),
260      screenReaderField: findFieldNameCaseInsensitive(
261        fieldsByLower,
262        fragmentConfig.screenReaderKey,
263        fragmentConfig.screenReaderKey,
264      ),
265      subTextField: findFieldNameCaseInsensitive(
266        fieldsByLower,
267        fragmentConfig.subTextKey,
268        fragmentConfig.subTextKey,
269      ),
270    };
271  }).filter(Boolean);
272}
273
274let fragmentRenderSchemaPromise;
275let imageAltSuffixesPromise;
276
277async function loadImageAltSuffixes() {
278  if (!imageAltSuffixesPromise) {
279    imageAltSuffixesPromise = (async () => {
280      const imageFragments = await Promise.all(
281        IMAGE_FRAGMENT_NAMES.map((name) => loadSchemaFragment(name)),
282      );
283
284      return [
285        ...new Set(imageFragments.flatMap((fields) => deriveImageAltSuffixes(fields))),
286      ];
287    })();
288  }
289
290  return imageAltSuffixesPromise;
291}
292
293async function loadFragmentRenderSchema() {
294  if (!fragmentRenderSchemaPromise) {
295    fragmentRenderSchemaPromise = (async () => {
296      const imageAltSuffixes = await loadImageAltSuffixes();
297      const ctaFragments = await Promise.all(
298        CTA_FRAGMENT_CONFIGS.map(async (fragmentConfig) => ({
299          fragmentConfig,
300          fields: await loadSchemaFragment(fragmentConfig.fragmentName),
301        })),
302      );
303
304      return {
305        imageAltSuffixes,
306        ctas: ctaFragments
307          .map(({ fragmentConfig, fields }) => deriveCtaSchema(fragmentConfig, fields))
308          .filter(Boolean),
309      };
310    })();
311  }
312
313  return fragmentRenderSchemaPromise;
314}
315
316function appendSuffix(value, suffix) {
317  const source = toText(value);
318  if (!source || source.endsWith(suffix)) return source;
319
320  if (/^https?:\/\//i.test(source)) {
321    const url = new URL(source);
322    url.pathname = `${url.pathname}${suffix}`;
323    return url.toString();
324  }
325
326  return `${source}${suffix}`;
327}
328
329function extractContentPath(source) {
330  const value = toText(source);
331  if (!value) return '';
332
333  const candidates = [value];
334
335  try {
336    const url = new URL(value, window.location.href);
337    const hashValue = url.hash.startsWith('#') ? url.hash.slice(1) : url.hash;
338
339    candidates.push(
340      url.pathname,
341      hashValue,
342      decodeURIComponent(hashValue),
343      url.searchParams.get('path') || '',
344      url.searchParams.get('item') || '',
345    );
346  } catch {
347    // no-op: fall back to direct source parsing
348  }
349
350  const normalizedCandidates = candidates
351    .map((candidate) => toText(candidate))
352    .filter(Boolean);
353
354  const damMatch = normalizedCandidates
355    .map((candidate) => candidate.match(/\/content\/dam\/[^?#\s]+/))
356    .find(Boolean);
357
358  if (damMatch) {
359    return damMatch[0].replace(/\/+$/, '');
360  }
361
362  const contentMatch = normalizedCandidates
363    .map((candidate) => candidate.match(/\/content\/[^?#\s]+/))
364    .find(Boolean);
365
366  return contentMatch ? contentMatch[0].replace(/\/+$/, '') : '';
367}
368
369function normalizeAuthoredReference(value) {
370  const source = toText(value);
371  if (!source) return '';
372
373  const contentPath = extractContentPath(source);
374  return contentPath || source;
375}
376
377function extractReferenceValue(value) {
378  const source = toText(value);
379  if (!source || !source.includes('<')) return normalizeAuthoredReference(source);
380
381  const container = document.createElement('div');
382  container.innerHTML = source;
383
384  return normalizeAuthoredReference(
385    container.querySelector('a')?.getAttribute('href')
386    || container.querySelector('img')?.getAttribute('src')
387    || container.textContent
388    || source,
389  );
390}
391
392async function resolveContentPath(value) {
393  const reference = extractReferenceValue(value);
394  if (!reference) return '';
395
396  try {
397    const url = new URL(reference, window.location.href);
398
399    if (url.pathname.startsWith('/content/dam/')) {
400      const publishUrl = await readConfigValueSafe('publish_url');
401      if (publishUrl) {
402        return `${publishUrl.replace(/\/$/, '')}${url.pathname}`;
403      }
404    }
405
406    if (/^https?:\/\//i.test(reference)) {
407      return url.toString();
408    }
409
410    return `${url.pathname}${url.search}${url.hash}`;
411  } catch {
412    return reference;
413  }
414}
415
416async function readConfigValueSafe(key, fallback = '') {
417  try {
418    return await getConfigValue(key);
419  } catch {
420    return fallback;
421  }
422}
423
424function canUseLegacyFallback(reference) {
425  const source = extractReferenceValue(reference);
426  if (!source) return false;
427
428  try {
429    const url = new URL(source, window.location.href);
430
431    if (!url.pathname.startsWith('/')) return false;
432    if (url.pathname.startsWith('/content/dam/')) return false;
433    if (url.pathname.endsWith('.json')) return false;
434
435    return true;
436  } catch {
437    return source.startsWith('/')
438      && !source.startsWith('/content/dam/')
439      && !source.endsWith('.json');
440  }
441}
442
443async function getJsonCandidates(value) {
444  const reference = extractReferenceValue(value);
445  if (!reference) return [];
446
447  try {
448    const url = new URL(reference, window.location.href);
449    const path = `${url.pathname}${url.search}${url.hash}`;
450    const isDamPath = url.pathname.startsWith('/content/dam/');
451    const isContentPath = url.pathname.startsWith('/content/');
452    const candidates = [appendSuffix(path, '.json')];
453
454    // DAM content fragments often require .infinity.json for complete field data.
455    // Prefer .infinity.json for DAM, then keep .json as fallback.
456    if (isDamPath) {
457      candidates.unshift(appendSuffix(path, '.infinity.json'));
458    }
459
460    if (isDamPath) {
461      const publishUrl = await readConfigValueSafe('publish_url');
462      if (publishUrl) {
463        const publishPath = `${publishUrl.replace(/\/$/, '')}${path}`;
464        candidates.push(appendSuffix(publishPath, '.infinity.json'));
465        candidates.push(appendSuffix(publishPath, '.json'));
466      }
467    }
468
469    // Metadata/config content under /content/... may not resolve from the current
470    // page origin in delivery. Try publish_url-hosted JSON variants as fallback.
471    if (!isDamPath && isContentPath) {
472      // Prefer .infinity.json first for metadata sheets to maximize available columns.
473      candidates.unshift(appendSuffix(path, '.infinity.json'));
474
475      const publishUrl = await readConfigValueSafe('publish_url');
476      if (publishUrl) {
477        const publishPath = `${publishUrl.replace(/\/$/, '')}${path}`;
478        candidates.push(appendSuffix(publishPath, '.infinity.json'));
479        candidates.push(appendSuffix(publishPath, '.json'));
480      }
481    }
482
483    if (/^https?:\/\//i.test(reference) && url.origin !== window.location.origin) {
484      if (isDamPath) {
485        candidates.push(appendSuffix(url.toString(), '.infinity.json'));
486      }
487      candidates.push(appendSuffix(url.toString(), '.json'));
488    }
489
490    return [...new Set(candidates.filter(Boolean))];
491  } catch {
492    const fallbackCandidates = [appendSuffix(reference, '.json')];
493    if (toText(reference).startsWith('/content/dam/')) {
494      fallbackCandidates.unshift(appendSuffix(reference, '.infinity.json'));
495    }
496
497    return [...new Set(fallbackCandidates.filter(Boolean))];
498  }
499}
500
501async function loadJsonResource(value) {
502  const candidates = await getJsonCandidates(value);
503
504  const fetchCandidate = (candidate) => {
505    if (!resourceJsonCache.has(candidate)) {
506      resourceJsonCache.set(
507        candidate,
508        fetch(candidate)
509          .then((response) => (response.ok ? response.json() : null))
510          .catch(() => null),
511      );
512    }
513
514    return resourceJsonCache.get(candidate);
515  };
516
517  return candidates.reduce(
518    (promise, candidate) => promise.then((payload) => (payload || fetchCandidate(candidate))),
519    Promise.resolve(null),
520  );
521}
522
523function normalizeConfigRows(payload) {
524  if (Array.isArray(payload?.data)) return payload.data;
525  if (Array.isArray(payload?.fields)) return payload.fields;
526  if (Array.isArray(payload)) return payload;
527
528  // AEM author .infinity.json often returns metadata rows in a JCR envelope.
529  const jcrContent = payload?.['jcr:content'];
530  if (jcrContent) {
531    if (Array.isArray(jcrContent.data)) return jcrContent.data;
532
533    if (isObject(jcrContent.data)) {
534      const dataRows = Object.entries(jcrContent.data)
535        .filter(([key, value]) => (
536          isObject(value) && !key.startsWith('jcr:') && !key.startsWith('cq:')
537        ))
538        .map(([, value]) => value);
539      if (dataRows.length) return dataRows;
540    }
541
542    const jcrRows = Object.entries(jcrContent)
543      .filter(([key, value]) => (
544        isObject(value) && !key.startsWith('jcr:') && !key.startsWith('cq:')
545      ))
546      .map(([, value]) => value);
547    if (jcrRows.length) return jcrRows;
548  }
549
550  if (isObject(payload?.fields)) {
551    return Object.entries(payload.fields).map(([name, value]) => ({ name, ...value }));
552  }
553
554  if (isObject(payload)) {
555    return Object.entries(payload)
556      .filter(([, value]) => isObject(value))
557      .map(([name, value]) => ({ name, ...value }));
558  }
559
560  return [];
561}
562
563function getDetailFieldName(row) {
564  return toText(
565    row?.content_fragment_model_fieldname
566    || row?.contentFragmentModelFieldname
567    || row?.fieldName
568    || row?.fieldname
569    || row?.name
570    || row?.key
571    || row?.id,
572  );
573}
574
575function getDetailFieldLabel(row, name) {
576  return toText(
577    row?.label
578    || row?.title
579    || row?.display_name
580    || row?.displayName
581    || humanizeFieldName(name),
582  );
583}
584
585function extractDetailFields(configPayload) {
586  const seen = new Set();
587
588  return normalizeConfigRows(configPayload).reduce((fields, row) => {
589    const name = getDetailFieldName(row);
590    const showOnDetailPage = row?.detail_page ?? row?.detailPage;
591
592    if (!name || !isTruthyValue(showOnDetailPage) || seen.has(name)) {
593      return fields;
594    }
595
596    seen.add(name);
597    fields.push({
598      name,
599      label: getDetailFieldLabel(row, name),
600    });
601    return fields;
602  }, []);
603}
604
605export function extractDetailFieldNames(configPayload) {
606  return extractDetailFields(configPayload).map(({ name }) => name);
607}
608
609function unwrapFragmentValue(value) {
610  if (Array.isArray(value)) {
611    return value.map((item) => unwrapFragmentValue(item)).filter((item) => item !== '');
612  }
613
614  if (!isObject(value)) {
615    return value;
616  }
617
618  if (Object.hasOwn(value, 'value')) return unwrapFragmentValue(value.value);
619  if (Object.hasOwn(value, 'values')) return unwrapFragmentValue(value.values);
620  if (Object.hasOwn(value, 'html')) return value.html;
621  if (Object.hasOwn(value, 'text')) return value.text;
622  if (Object.hasOwn(value, 'plainText')) return value.plainText;
623  if (Object.hasOwn(value, 'url')) return value.url;
624  if (Object.hasOwn(value, 'path')) return value.path;
625  if (Object.hasOwn(value, 'src')) return value.src;
626  return value;
627}
628
629function mapFragmentValues(payload) {
630  return Object.entries(payload).reduce((accumulator, [key, value]) => {
631    accumulator[key] = unwrapFragmentValue(value);
632    return accumulator;
633  }, {});
634}
635
636function normalizeFragmentData(payload) {
637  if (!payload) return {};
638
639  if (isObject(payload?.elements)) {
640    return mapFragmentValues(payload.elements);
641  }
642
643  if (isObject(payload?.['jcr:content']?.data?.master)) {
644    return mapFragmentValues(payload['jcr:content'].data.master);
645  }
646
647  if (isObject(payload?.item)) {
648    return mapFragmentValues(payload.item);
649  }
650
651  if (Array.isArray(payload?.items) && payload.items.length > 0) {
652    return normalizeFragmentData(payload.items[0]);
653  }
654
655  if (isObject(payload?.data)) {
656    return mapFragmentValues(payload.data);
657  }
658
659  if (isObject(payload)) {
660    return mapFragmentValues(payload);
661  }
662
663  return {};
664}
665
666function hasRenderableValue(value) {
667  if (value === null || value === undefined) return false;
668  if (Array.isArray(value)) return value.some((item) => hasRenderableValue(item));
669  if (typeof value === 'string') return toText(value).length > 0;
670  if (isObject(value)) return Object.keys(value).length > 0;
671  return true;
672}
673
674function normalizeCtaStyle(value, ctaSchema) {
675  const normalizedValue = toText(value).toLowerCase();
676  return ctaSchema?.styleOptions.has(normalizedValue)
677    ? normalizedValue
678    : ctaSchema?.defaultStyle || CTA_DEFAULTS.STYLE;
679}
680
681function normalizeCtaColor(value, ctaSchema) {
682  const normalizedValue = normalizeColorAlias(value);
683  const normalizedDefault = normalizeColorAlias(ctaSchema?.defaultColor || CTA_DEFAULTS.COLOR);
684  return ctaSchema?.colorOptions.has(normalizedValue)
685    ? normalizedValue
686    : normalizedDefault;
687}
688
689async function buildGalleryWithResolvedSources(fields, fragmentData, imageAltSuffixes = []) {
690  const gallery = await buildBaseGallery(fields, fragmentData, imageAltSuffixes);
691  if (!gallery) return null;
692
693  const images = [...gallery.querySelectorAll('.fragment-detail-gallery-image')];
694  const resolvedSources = await Promise.all(
695    images.map((image) =>
695 resolveContentPath(image.getAttribute('src'))),
696  );
697
698  resolvedSources.forEach((source, index) => {
699    if (source) {
700      images[index].src = source;
701    }
702  });
703
704  return gallery;
705}
706
707async function buildCta(ctaSchema, fragmentData) {
708  const linkValue = extractReferenceValue(getFragmentValue(fragmentData, ctaSchema.linkField));
709  const text = toText(getFragmentValue(fragmentData, ctaSchema.textField));
710
711  if (!linkValue || !text) {
712    return null;
713  }
714
715  const screenReaderText = toText(getFragmentValue(fragmentData, ctaSchema.screenReaderField));
716  const style = normalizeCtaStyle(getFragmentValue(fragmentData, ctaSchema.styleField), ctaSchema);
717  const color = normalizeCtaColor(
718    getFragmentValue(fragmentData, ctaSchema.colorField)
719    || getFragmentValue(fragmentData, ctaSchema.themeColorField),
720    ctaSchema,
721  );
722  const href = await resolveContentPath(linkValue);
723
724  const cta = document.createElement('div');
725  cta.className = 'fragment-detail-cta';
726  cta.classList.add(`fragment-detail-cta-${ctaSchema.key.toLowerCase()}`);
727  cta.classList.add('cta', style, color, CTA_DEFAULTS.SIZE, CTA_DEFAULTS.SHAPE);
728
729  const link = document.createElement('a');
730  link.className = 'fragment-detail-cta-link';
731  link.classList.add(
732    `fragment-detail-cta-link-${style}`,
733    `fragment-detail-cta-link-${color}`,
734  );
735  link.href = href || linkValue;
736
737  if (screenReaderText) {
738    link.setAttribute('aria-label', screenReaderText);
739  }
740
741  const textNode = document.createElement('span');
742  textNode.className = 'fragment-detail-cta-link-text';
743  textNode.textContent = text;
744  link.append(textNode);
745
746  if (screenReaderText && screenReaderText !== text) {
747    const srText = document.createElement('span');
748    srText.className = 'fragment-detail-visually-hidden';
749    srText.textContent = ` ${screenReaderText}`;
750    link.append(srText);
751  }
752
753  cta.append(link);
754
755  const subTextValue = getFragmentValue(fragmentData, ctaSchema.subTextField);
756  if (hasRenderableValue(subTextValue)) {
757    const subText = document.createElement('div');
758    subText.className = 'fragment-detail-cta-subtext';
759    subText.classList.add(`fragment-detail-cta-subtext-${ctaSchema.key.toLowerCase()}`);
760    appendRenderedValue(subText, subTextValue);
761    if (subText.childNodes.length) {
762      cta.append(subText);
763    }
764  }
765
766  return cta;
767}
768
769async function buildCtaGroup(fields, fragmentData, excluded = new Set()) {
770  const configDrivenSchemas = buildCtaSchemasFromDetailFields(fields);
771  let ctaSchemas = configDrivenSchemas;
772
773  if (!ctaSchemas.length) {
774    const { ctas } = await loadFragmentRenderSchema();
775    ctaSchemas = ctas;
776  }
777
778  const ctas = (await Promise.all(
779    ctaSchemas.map((ctaSchema) => buildCta(ctaSchema, fragmentData)),
780  ))
781    .filter(Boolean);
782
783  if (!ctas.length) {
784    return null;
785  }
786
787  ctaSchemas.forEach((ctaSchema) => {
788    [
789      ctaSchema.linkField,
790      ctaSchema.textField,
791      ctaSchema.screenReaderField,
792      ctaSchema.styleField,
793      ctaSchema.colorField,
794      ctaSchema.themeColorField,
795      ctaSchema.subTextField,
796    ].forEach((fieldName) => {
797      const matchingField = fields.find(
798        ({ name }) => name.toLowerCase() === fieldName.toLowerCase(),
799      );
800      if (matchingField) {
801        excluded.add(matchingField.name);
802      }
803    });
804  });
805
806  const ctaGroup = document.createElement('div');
807  ctaGroup.className = 'fragment-detail-ctas';
808  ctas.forEach((cta) => ctaGroup.append(cta));
809  return ctaGroup;
810}
811
812async function buildArticleWithCtas(
813  fields,
814  fragmentData,
815  excluded = new Set(),
816  pageType = 'unknown',
817) {
818  return buildBaseArticle(fields, fragmentData, {
819    excluded,
820    buildCtaGroupFn: buildCtaGroup,
821    pageType,
822  });
823}
824
825function getLegacyReference(block) {
826  const link = block.querySelector('a[href]');
827  return link?.getAttribute('href') || block.textContent.trim();
828}
829
830function getFragmentConfig(block) {
831  // Capture anchor hrefs from each row before readConfig removes them.
832  // getColValue returns textContent for p.button-container elements — which is
833  // the AEM path used as link display text — losing the EDS-routable href.
834  // Pre-capture hrefs so we can fall back to them when extractReferenceValue
835  // resolves to an AEM /content/… path that won't resolve on the E
835DS origin.
836  const rowHrefs = [...block.children].map((row) => {
837    const a = row.querySelector('a[href]');
838    return a ? a.getAttribute('href') : '';
839  });
840
841  const config = readConfig(block, FRAGMENT_FIELDS, false) || {};
842
843  const resolveRef = (value, fallbackHref) => {
844    const extracted = extractReferenceValue(value);
845    if (extracted && !extracted.startsWith('/content/')) return extracted;
846    return normalizeAuthoredReference(fallbackHref) || extracted || '';
847  };
848
849  return {
850    reference: resolveRef(config.reference, rowHrefs[0]) || getLegacyReference(block),
851    detailBlockLayout: toText(config.detailBlockLayout).toLowerCase(),
852    contentFragmentConfig: resolveRef(config.contentFragmentConfig, rowHrefs[2]),
853  };
854}
855
856function isDetailMode(config) {
857  return Boolean(
858    config.reference
859    && config.contentFragmentConfig
860    && config.detailBlockLayout !== 'none',
861  );
862}
863
864function getDetailPageSlug(reference) {
865  const path = extractReferenceValue(reference);
866  try {
867    return new URL(path, window.location.href).pathname.split('/').filter(Boolean).pop() || '';
868  } catch {
869    return path.split('/').filter(Boolean).pop() || '';
870  }
871}
872
873function logDetailPageRender(config, fragmentData = {}) {
874  const reference = extractReferenceValue(config.reference);
875  const pageType = detectDetailPageType({
876    contentFragmentConfig: config.contentFragmentConfig,
877    reference,
878    route: window.location.pathname,
879    fragmentData,
880  });
881
882  console.info('[fragment] Details Page rendered', {
883    pageType,
884    contentType: pageType,
885    route: window.location.pathname,
886    slug: getDetailPageSlug(reference),
887    reference,
888    contentFragmentConfig: extractReferenceValue(config.contentFragmentConfig),
889  });
890}
891
892function getDetailPageType(config, fragmentData = {}) {
893  return detectDetailPageType({
894    contentFragmentConfig: config.contentFragmentConfig,
895    reference: extractReferenceValue(config.reference),
896    route: window.location.pathname,
897    fragmentData,
898  });
899}
900
901function updateModeClasses(block, layout = null) {
902  block.classList.remove('fragment-detail-mode');
903  block.classList.remove('fragment-layout-article');
904  block.classList.remove('fragment-layout-details-with-image-gallery');
905
906  if (layout) {
907    block.classList.add('fragment-detail-mode');
908    block.classList.add(`fragment-layout-${layout}`);
909  }
910}
911
912/**
913 * Parse a CTA paragraph from the flat EDS HTML produced by the detail template.
914 * Format: <a href="URL">text</a>srText<strong>style|color</strong>subtext
915 * Returns null if the paragraph isn't a CTA.
916 */
917function parseCtaParagraph(p) {
918  const anchor = p.querySelector('a');
919  const strong = p.querySelector('strong');
920  if (!anchor || !strong) return null;
921
922  const href = anchor.getAttribute('href') || '';
923  const text = anchor.textContent.trim();
924
925  // Parse style|color from <strong>
926  const [style, color] = (strong.textContent || '').split('|').map((s) => s.trim());
927
928  // SR text: text between </a> and <strong>
929  // Subtext: text after </strong>
930  let srText = '';
931  let subtext = '';
932
933  const nodes = [...p.childNodes];
934  const anchorIdx = nodes.indexOf(anchor);
935  const strongIdx = nodes.indexOf(strong);
936
937  // Collect text nodes between anchor and strong
938  for (let i = anchorIdx + 1; i < strongIdx; i += 1) {
939    if (nodes[i].nodeType === Node.TEXT_NODE) {
940      srText += nodes[i].textContent;
941    }
942  }
943  srText = srText.trim();
944
945  // Collect text nodes after strong
946  for (let i = strongIdx + 1; i < nodes.length; i += 1) {
947    if (nodes[i].nodeType === Node.TEXT_NODE) {
948      subtext += nodes[i].textContent;
949    }
950  }
951  subtext = subtext.trim();
952
953  return {
954    href, text, srText, style: style || 'filled', color: normalizeColorAlias(color || 'primary'), subtext,
955  };
956}
957
958/**
959 * Detects whether a paragraph contains only a link (no surrounding text or
960 * other elements). EDS processing can strip the <strong>style|color</strong>
961 * marker and merge adjacent text into the link — leaving a bare <p><a>…</a></p>
962 * that should still be treated as a CTA with default styling.
963 */
964function isLinkOnlyParagraph(el) {
965  if (el.tagName !== 'P') return false;
966  const anchor = el.querySelector('a');
967  if (!anchor) return false;
968  const significant = [...el.childNodes].filter((node) => {
969    if (node === anchor) return true;
970    if (node.nodeType === Node.TEXT_NODE && !node.textContent.trim()) return false;
971    return true;
972  });
973  return significant.length === 1 && significant[0] === anchor;
974}
975
976/**
977 * Renders a detail layout from flat EDS HTML loaded via loadFragment.
978 * Parses the pipeline-processed HTML (eyebrow in <em>, title in <h1>,
979 * images in <picture>, body in <p>, CTAs as <a>+<strong>) and builds
980 * the fragment-detail-* DOM structure.
981 */
982async function renderDetailFromHtml(block, config) {
983  const layout = config.detailBlockLayout || DETAIL_LAYOUTS.DETAILS_WITH_IMAGE_GALLERY;
984  const pageType = getDetailPageType(config);
985  const reference = extractReferenceValue(config.reference);
986  if (!reference) return false;
987
988  // Fetch raw HTML (same cache as loadFragment)
989  if (!fragmentHtmlCache.has(reference)) {
990    fragmentHtmlCache.set(
991      reference,
992      fetch(`${reference}.plain.html`)
993        .then((r) => (r.ok ? r.text() : null))
994        .catch(() => null),
995    );
996  }
997
998  const html = await fragmentHtmlCache.get(reference);
999  if (!html) return false;
1000
1001  // Parse into a temporary container
1002  const container = document.createElement('div');
1003  container.innerHTML = html;
1004
1005  // Fix relative media URLs
1006  container.querySelectorAll('img[src^="./media_"]').forEach((img) => {
1007    img.src = new URL(img.getAttribute('src'), new URL(reference, window.location)).href;
1008  });
1009  container.querySelectorAll('source[srcset^="./media_"]').forEach((source) => {
1010    source.srcset = new URL(source.getAttribute('srcset'), new URL(reference, window.location)).href;
1011  });
1012
1013  // Extract content parts from the flat HTML
1014  let eyebrowText = '';
1015  let titleText = '';
1016  const images = [];
1017  const bodyParagraphs = [];
1018  const ctas = [];
1019
1020  const children = [...container.querySelectorAll(':scope > div > *, :scope > *')];
1021
1022  children.forEach((el) => {
1023    // Eyebrow: <p><em>text</em></p>
1024    const em = el.querySelector(':scope > em');
1025    if (el.tagName === 'P' && em && !eyebrowText) {
1026      eyebrowText = em.textContent.trim();
1027    } else if (/^H[1-6]$/.test(el.tagName) && !titleText) {
1028      // Title: <h1>
1029      titleText = el.textContent.trim();
1030    } else if (el.tagName === 'P' && el.querySelector('picture, img') && !el.querySelector('a')) {
1031      // Image: <p> containing <picture> or <img>
1032      const pic = el.querySelector('picture') || el.querySelector('img');
1033      if (pic) {
1034        images.push(pic.cloneNode(true));
1035      }
1036    } else if (el.tagName === 'P' && el.querySelector('a') && el.querySelector('strong')) {
1037      // CTA: <p> with <a> + <strong>style|color</strong>
1038      const ctaData = parseCtaParagraph(el);
1039      if (ctaData && ctaData.text) {
1040        ctas.push(ctaData);
1041      } else if (!ctaData) {
1042        bodyParagraphs.push(el);
1043      }
1044      // ctaData with no text is dropped: a labeled marker with no visible
1045      // link text is not a real CTA (e.g. an authoring gap in cta{n}Text).
1046    } else if (isLinkOnlyParagraph(el)) {
1047      // Link-only paragraph. Two things collapse to this exact shape:
1048      // 1) A real CTA that lost its <strong>style|color</strong> marker in
1049      //    the EDS content pipeline — it still has visible link text.
1050      // 2) The hidden canonical detail-page-path marker anchor emitted by
1051      //    the CF overlay templates (`class="cf-detail-page-path"
1052      //    aria-hidden="true" tabindex="-1"`, no text) — its attributes are
1053      //    stripped by the EDS pipeline, but it never has link text.
1054      // Only (1) is a CTA; (2) must be dropped, or it renders as an empty CTA.
1055      const anchor = el.querySelector('a');
1056      const anchorText = anchor.textContent.trim();
1057      if (anchorText) {
1058        ctas.push({
1059          href: anchor.getAttribute('href') || '',
1060          text: anchorText,
1061          srText: '',
1062          style: CTA_DEFAULTS.STYLE,
1063          color: CTA_DEFAULTS.COLOR,
1064          subtext: '',
1065        });
1066      }
1067    } else if (el.tagName === 'P' || el.tagName === 'UL' || el.tagName === 'OL' || /^H[1-6]$/.test(el.tagName)) {
1068      // Body paragraph or list (DIVs in .plain.html are structural wrappers, not content)
1069      bodyParagraphs.push(el);
1070    }
1071  });
1072
1073  // If no meaningful content was extracted, signal fallback to legacy rendering
1074  if (!titleText && !images.length && !bodyParagraphs.length && !ctas.length) {
1075    return false;
1076  }
1077
1078  // Build fragment-detail-* DOM structure
1079  const wrapper = document.createElement('div');
1080  wrapper.className = `fragment-detail fragment-detail-${layout} fragment-layout-${layout}`;
1081  if (pageType === 'event') {
1082    wrapper.classList.add('fragment-detail-event');
1083  }
1084
1085  // Gallery column — use the image-media-gallery-carousel block
1086  if (images.length && layout === DETAIL_LAYOUTS.DETAILS_WITH_IMAGE_GALLERY) {
1087    const gallery = document.createElement('div');
1088    gallery.className = 'fragment-detail-gallery';
1089
1090    const carouselRows = images.map((pic) => [pic]);
1091    const carouselBlock = buildBlock('image-media-gallery-carousel', carouselRows);
1092    carouselBlock.classList.add('carousel-1-item');
1093    gallery.append(carouselBlock);
1094    wrapper.append(gallery);
1095
1096    decorateBlock(carouselBlock);
1097    await loadBlock(carouselBlock);
1098  }
1099
1100  // Article column
1101  const article = document.createElement('article');
1102  article.className = 'fragment-detail-article';
1103
1104  // Header (eyebrow + title)
1105  const header = document.createElement('header');
1106  header.className = 'fragment-detail-header';
1107
1108  if (eyebrowText) {
1109    const eyebrow = document.createElement('p');
1110    eyebrow.className = 'fragment-detail-eyebrow';
1111    eyebrow.textContent = pageType === 'event'
1112      ? getEventCategoryDisplayValue(eyebrowText)
1113      : eyebrowText;
1114    header.append(eyebrow);
1115  }
1116
1117  const titleEl = document.createElement('h2');
1118  titleEl.className = 'fragment-detail-title';
1119  titleEl.textContent = titleText;
1120  header.append(titleEl);
1121
1122  const divider = document.createElement('span');
1123  divider.className = 'brand-divider';
1124  divider.setAttribute('aria-hidden', 'true');
1125  header.append(divider);
1126
1127  article.append(header);
1128
1129  // Body paragraphs
1130  if (bodyParagraphs.length) {
1131    const body = document.createElement('div');
1132    body.className = 'fragment-detail-body fragment-detail-body-description';
1133    bodyParagraphs.forEach((p) => body.append(p.cloneNode(true)));
1134    article.append(body);
1135  }
1136
1137  // CTAs
1138  if (ctas.length) {
1139    const ctaGroup = document.createElement('div');
1140    ctaGroup.className = 'fragment-detail-ctas';
1141
1142    // Build and append each CTA element synchronously, in ctas order, before
1143    // awaiting loadBlock — otherwise DOM order would depend on whichever
1144    // CTA's async loadBlock happens to resolve first.
1145    const ctaBlocks = ctas.map((ctaData) => {
1146      const ctaEl = document.createElement('div');
1147      ctaEl.className = 'fragment-detail-cta';
1148
1149      const a = document.createElement('a');
1150      a.href = ctaData.href;
1151      a.textContent = ctaData.text;
1152      if (ctaData.srText) a.title = ctaData.srText;
1153
1154      const ctaBlock = buildBlock('cta', [[a]]);
1155      ctaBlock.classList.add(
1156        ctaData.style || 'filled',
1157        ctaData.color || CTA_TOKENS.COLOR.PRIMARY,
1158        CTA_DEFAULTS.SIZE,
1159        CTA_DEFAULTS.SHAPE,
1160      );
1161
1162      ctaEl.append(ctaBlock);
1163      decorateBlock(ctaBlock);
1164
1165      if (ctaData.subtext) {
1166        const sub = document.createElement('p');
1167        sub.className = 'fragment-detail-cta-subtext';
1168        sub.textContent = ctaData.subtext;
1169        ctaEl.append(sub);
1170      }
1171
1172      ctaGroup.append(ctaEl);
1173      return ctaBlock;
1174    });
1175
1176    await Promise.all(ctaBlocks.map((ctaBlock) => loadBlock(ctaBlock)));
1177    article.append(ctaGroup);
1178  }
1179
1180  // Insert breadcrumbs above article for details-with-image-gallery layout
1181  if (layout === DETAIL_LAYOUTS.DETAILS_WITH_IMAGE_GALLERY) {
1182    const breadcrumbBlock = buildBlock('breadcrumbs', [['']]);
1183    breadcrumbBlock.classList.add('block');
1184    wrapper.append(breadcrumbBlock);
1185    decorateBlock(breadcrumbBlock);
1186    await loadBlock(breadcrumbBlock);
1187  }
1188
1189  wrapper.append(article);
1190
1191  // Render into block
1192  block.textContent = '';
1193  updateModeClasses(block, layout);
1194  block.append(wrapper);
1195
1196  return true;
1197}
1198
1199async function renderLegacyFragment(block, reference) {
1200  updateModeClasses(block);
1201
1202  const fragment = await loadFragment(reference);
1203  if (!fragment) return false;
1204
1205  const fragmentSection = fragment.querySelector(':scope .section');
1206  if (!fragmentSection) return false;
1207
1208  block.closest('.section')?.classList.add(...fragmentSection.classList);
1209  moveInstrumentation(block, block.parentElement);
1210  block.replaceWith(...fragment.childNodes);
1211  return true;
1212}
1213
1214async function renderDetailFragment(block, config) {
1215  const [configPayload, fragmentPayload] = await Promise.all([
1216    loadJsonResource(config.contentFragmentConfig),
1217    loadJsonResource(config.reference),
1218  ]);
1219
1220  const detailFields = extractDetailFields(configPayload);
1221  if (!detailFields.length) {
1222    console.warn('Fragment block: no detail_page fields were found in the config payload.');
1223    block.textContent = '';
1224    updateModeClasses(block, config.detailBlockLayout || DETAIL_LAYOUTS.ARTICLE);
1225    return false;
1226  }
1227
1228  const fragmentData = normalizeFragmentData(fragmentPayload);
1229  const layout = config.detailBlockLayout || DETAIL_LAYOUTS.ARTICLE;
1230  const pageType = getDetailPageType(config, fragmentData);
1231  const imageAltSuffixes = layout === DETAIL_LAYOUTS.DETAILS_WITH_IMAGE_GALLERY
1232    ? (await loadFragmentRenderSchema()).imageAltSuffixes
1233    : await loadImageAltSuffixes();
1234
1235  const detailContent = await buildBaseDetailContent(
1236    layout,
1237    detailFields,
1238    fragmentData,
1239    {
1240      imageAltSuffixes,
1241      buildGalleryFn: (fields, data) => buildGalleryWithResolvedSources(
1242        fields,
1243        data,
1244        imageAltSuffixes,
1245      ),
1246      buildArticleFn: (fields, data, options = {}) => buildArticleWithCtas(
1247        fields,
1248        data,
1249        options.excluded || new Set(),
1250        pageType,
1251      ),
1252      pageType,
1253    },
1254  );
1255
1256  block.textContent = '';
1257  updateModeClasses(block, layout);
1258
1259  if (!detailContent) {
1260    return false;
1261  }
1262
1263  // Insert breadcrumbs above article for details-with-image-gallery layout
1264  if (layout === DETAIL_LAYOUTS.DETAILS_WITH_IMAGE_GALLERY) {
1265    const articleEl = detailContent.querySelector('.fragment-detail-article');
1266    if (articleEl) {
1267      const breadcrumbBlock = buildBlock('breadcrumbs', [['']]);
1268      breadcrumbBlock.classList.add('block');
1269      detailContent.insertBefore(breadcrumbBlock, articleEl);
1270      decorateBlock(breadcrumbBlock);
1271      await loadBlock(breadcrumbBlock);
1272    }
1273  }
1274
1275  block.append(detailContent);
1276
1277  return true;
1278}
1279
1280/**
1281 * Loads a fragment from a given path
1282 * @param {string} path The path to the fragment
1283 * @returns {Promise<HTMLElement|null>} The root element of the fragment or null
1284 */
1285export async function loadFragment(path) {
1286  if (!path?.startsWith('/')) return null;
1287
1288  if (!fragmentHtmlCache.has(path)) {
1289    fragmentHtmlCache.set(
1290      path,
1291      fetch(`${path}.plain.html`)
1292        .then((r) => (r.ok ? r.text() : null))
1293        .catch(() => null),
1294    );
1295  }
1296
1297  const html = await fragmentHtmlCache.get(path);
1298  if (!html) return null;
1299
1300  const main = document.createElement('main');
1301  main.dataset.fragmentPath = path;
1302  main.innerHTML = html;
1303
1304  [...main.children].forEach((child) => {
1305    child.dataset.fragmentPath = path;
1306  });
1307
1308  // reset base path for media to fragment base
1309  const resetAttribute = (tag, attr) => {
1310    main.querySelectorAll(`${tag}[${attr}^="./media_"]`).forEach((elem) => {
1311      elem[attr] = new URL(elem.getAttribute(attr), new URL(path, window.location)).href;
1312    });
1313  };
1314  resetAttribute('img', 'src');
1315  resetAttribute('source', 'srcset');
1316
1317  decorateMain(main);
1318  await loadSections(main);
1319  return main;
1320}
1321
1322const FAREHARBOR_HOST_PATTERN = /fareharbor\.com/i;
1323const ECID_TIMEOUT_MS = 2000;
1324const fareHarborEcidState = new WeakMap();
1325
1326function getFareHarborEcidState(link) {
1327  if (!fareHarborEcidState.has(link)) {
1328    fareHarborEcidState.set(link, { inFlight: false, completed: false });
1329  }
1330
1331  return fareHarborEcidState.get(link);
1332}
1333
1334/**
1335 * Ensure FareHarbor Lightframe API is available for detail CTA links.
1336 */
1337function loadFareHarborLightframe() {
1338  const existingScript = document.querySelector('script[src*="fareharbor.com/embeds/api"]');
1339  if (existingScript) {
1340    console.info('[fragment] FareHarbor script already present', {
1341      src: existingScript.src,
1342      readyState: existingScript.readyState,
1343    });
1344    return;
1345  }
1346
1347  const script = document.createElement('script');
1348  script.src = 'https://fareharbor.com/embeds/api/v1/?autolightframe=yes';
1349  script.async = true;
1350  script.onload = () => {
1351    console.info('[fragment] FareHarbor script loaded', { src: script.src });
1352  };
1353  script.onerror = () => {
1354    console.error('[fragment] Failed to load FareHarbor Lightframe script');
1355  };
1356  console.info('[fragment] Appending FareHarbor script', { src: script.src });
1357  document.body.append(script);
1358}
1359
1360/**
1361 * Resolve Adobe Experience Cloud ID from Web SDK with a timeout fallback.
1362 * @param {string} reason
1363 * @returns {Promise<string>}
1364 */
1365async function getExperienceCloudId(reason = 'unknown') {
1366  if (typeof window.alloy !== 'function') {
1367    console.warn('[fragment] Web SDK alloy() is unavailable; continuing without ECID', { reason });
1368    return '';
1369  }
1370
1371  let timeoutId;
1372  const timeoutSignal = Symbol('ecid-timeout');
1373
1374  try {
1375    const identity = await Promise.race([
1376      window.alloy('getIdentity', { namespaces: ['ECID'] }),
1377      new Promise((resolve) => {
1378        timeoutId = window.setTimeout(() => resolve(timeoutSignal), ECID_TIMEOUT_MS);
1379      }),
1380    ]);
1381
1382    if (identity === timeoutSignal) {
1383      console.warn(
1384        `[fragment] ECID lookup timed out after ${ECID_TIMEOUT_MS}ms; continuing without ECID`,
1385      );
1386      return '';
1387    }
1388
1389    const ecid = identity?.identity?.ECID || '';
1390
1391    if (ecid) {
1392      console.info('[fragment] ECID resolved for FareHarbor links', { reason });
1393    } else {
1394      console.warn('[fragment] ECID not found in alloy getIdentity() response', { reason });
1395    }
1396
1397    return ecid;
1398  } catch (err) {
1399    console.warn('[fragment] Unable to retrieve ECID from Web SDK:', err, { reason });
1400    return '';
1401  } finally {
1402    if (timeoutId) {
1403      window.clearTimeout(timeoutId);
1404    }
1405  }
1406}
1407
1408/**
1409 * Add `ref=<ECID>` to FareHarbor URLs while preserving existing query params.
1410 * @param {string} href
1411 * @param {string} ecid
1412 * @returns {string}
1413 */
1414function appendFareHarborRef(href, ecid) {
1415  if (!ecid) return href;
1416
1417  try {
1418    const parsedUrl = new URL(href, window.location.origin);
1419    parsedUrl.searchParams.set('ref', ecid);
1420    console.info('[fragment] Appended FareHarbor ref param', {
1421      url: parsedUrl.origin + parsedUrl.pathname,
1422      hasQuery: Boolean(parsedUrl.search),
1423    });
1424    return parsedUrl.toString();
1425  } catch (err) {
1426    console.warn('[fragment] Failed to append FareHarbor ref parameter in URL object:', err);
1427    const separator = href.includes('?') ? '&' : '?';
1428    return `${href}${separator}ref=${encodeURIComponent(ecid)}`;
1429  }
1430}
1431
1432function hasFareHarborRef(href) {
1433  try {
1434    return new URL(href, window.location.origin).searchParams.has('ref');
1435  } catch (_err) {
1436    return /[?&]ref=/.test(href);
1437  }
1438}
1439
1440async function appendFareHarborRefToLink(link, reason) {
1441  const state = getFareHarborEcidState(link);
1442  if (state.completed || state.inFlight) return;
1443
1444  const href = link.getAttribute('href') || '';
1445  if (!href) return;
1446  if (hasFareHarborRef(href)) {
1447    state.completed = true;
1448    return;
1449  }
1450
1451  state.inFlight = true;
1452
1453  try {
1454    const ecid = await getExperienceCloudId(reason);
1455    if (!ecid) {
1456      console.warn('[fragment] No ECID resolved; FareHarbor link remains unchanged', { reason });
1457      return;
1458    }
1459
1460    link.setAttribute('href', appendFareHarborRef(href, ecid));
1461    state.completed = true;
1462  } finally {
1463    state.inFlight = false;
1464  }
1465}
1466
1467function wireFareHarborInteractionFallback(link) {
1468  if (link.dataset.fragmentFareHarborEcidWired === 'true') return;
1469  link.dataset.fragmentFareHarborEcidWired = 'true';
1470
1471  const appendOnInteraction = () => {
1472    appendFareHarborRefToLink(link, 'interaction').catch((err) => {
1473      console.warn('[fragment] Interaction ECID append failed:', err);
1474    });
1475  };
1476
1477  link.addEventListener('pointerdown', appendOnInteraction, true);
1478  link.addEventListener('click', appendOnInteraction, true);
1479}
1480
1481/**
1482 * Update FareHarbor CTA links in fragment detail mode with ECID ref parameter.
1483 * @param {HTMLElement} block
1484 * @returns {Promise<void>}
1485 */
1486async function appendFareHarborRefToDetailCtas(block) {
1487  const links = [...block.querySelectorAll('.fragment-detail-ctas a[href]')];
1488  const fareHarborLinks = links.filter((link) => isFareHarborBookingLink(link));
1489
1490  console.info('[fragment] Detail CTA FareHarbor scan completed', {
1491    totalCtaLinks: links.length,
1492    fareHarborLinks: fareHarborLinks.length,
1493  });
1494
1495  if (!fareHarborLinks.length) {
1496    return;
1497  }
1498
1499  loadFareHarborLightframe();
1500  fareHarborLinks.forEach((link) => wireFareHarborInteractionFallback(link));
1501
1502  try {
1503    await Promise.all(
1504      fareHarborLinks.map((link) => appendFareHarborRefToLink(link, 'decorate')),
1505    );
1506
1507    console.info('[fragment] FareHarbor detail CTA links updated with ECID ref parameter', {
1508      count: fareHarborLinks.length,
1509    });
1510  } catch (err) {
1511    console.warn('[fragment] Unable to append FareHarbor ECID ref parameter:', err);
1512  }
1513}
1514
1515/**
1516 * @param {HTMLAnchorElement} link
1517 * @returns {boolean}
1518 */
1519function isFareHarborBookingLink(link) {
1520  const rawHref = link.getAttribute('href') || '';
1521  return FAREHARBOR_HOST_PATTERN.test(rawHref) || FAREHARBOR_HOST_PATTERN.test(link.href || '');
1522}
1523
1524/**
1525 * FareHarbor autolightframe hooks `click` and calls `preventDefault`, which can prevent
1526 * bubble-phase analytics on the anchor. Track on `pointerdown` for those URLs; use click
1527 * (capture) otherwise. Keyboard activation uses `click` only.
1528 *
1529 * @param {HTMLAnchorElement} link
1530 */
1531function wireFragmentDetailCtaLinkAnalytics(link) {
1532  if (link.dataset.fragmentCtaAnalyticsWired === 'true') return;
1533  link.dataset.fragmentCtaAnalyticsWired = 'true';
1534
1535  const track = () => {
1536    pushLinkClickAnalytics(link);
1537  };
1538
1539  if (isFareHarborBookingLink(link)) {
1540    let tracked = false;
1541    const trackOnce = () => {
1542      if (tracked) return;
1543      tracked = true;
1544      pushLinkClickAnalytics(link);
1545      queueMicrotask(() => {
1546        tracked = false;
1547      });
1548    };
1549    link.addEventListener('pointerdown', trackOnce);
1550    link.addEventListener('click', trackOnce);
1551    return;
1552  }
1553
1554  link.addEventListener('click', track, true);
1555}
1556
1557/**
1558 * Wires link-click analytics on detail-page CTA anchors inside `.fragment-detail-ctas`.
1559 * Covers CF overlay `.fragment-detail-cta-link` anchors and nested `.cta.block` buttons.
1560 *
1561 * @param {HTMLElement} block
1562 */
1563function wireFragmentDetailCtaAnalytics(block) {
1564  block.querySelectorAll('.fragment-detail-ctas a[href]').forEach((link) => {
1565    if (isFareHarborBookingLink(link)) {
1566      wireFareHarborInteractionFallback(link);
1567    }
1568    wireFragmentDetailCtaLinkAnalytics(link);
1569  });
1570}
1571
1572/**
1573 * Decorates the fragment block
1574 * @param {Element} block The fragment block element
1575 * @param {Object} options Configuration options
1576 * @param {Function} options.onBefore Lifecycle hook called before decoration
1577 * @param {Function} options.onAfter Lifecycle hook called after decoration
1578 * @returns {Promise<void>}
1579 */
1580export async function decorate(block, options = {}) {
1581  const ctx = { block, options };
1582
1583  // lifecycle hook + event (before)
1584  options.onBefore?.(ctx);
1585  block.dispatchEvent(new CustomEvent('fragment:before', { detail: ctx, bubbles: true }));
1586
1587  // === FRAGMENT BLOCK LOGIC ===
1588  readVariant(block);
1589
1590  const config = getFragmentConfig(block);
1591  let detailRendered = false;
1592
1593  try {
1594    if (isDetailMode(config)) {
1595      const rendered = await renderDetailFragment(block, config);
1596      detailRendered = rendered;
1597      if (!rendered && canUseLegacyFallback(config.reference)) {
1598        const htmlRendered = await renderDetailFromHtml(block, config);
1599        detailRendered = htmlRendered;
1600        if (!htmlRendered) {
1601          const legacyRendered = await renderLegacyFragment(block, config.reference);
1602          if (!legacyRendered) {
1603            block.textContent = '';
1604            updateModeClasses(block);
1605          }
1606        }
1607      } else if (!rendered) {
1608        block.textContent = '';
1609        updateModeClasses(block);
1610      }
1611    } else if (config.reference) {
1612      if (canUseLegacyFallback(config.reference)) {
1613        const legacyRendered = await renderLegacyFragment(block, config.reference);
1614        if (!legacyRendered) {
1615          block.textContent = '';
1616          updateModeClasses(block);
1617        }
1618      } else {
1619        block.textContent = '';
1620        updateModeClasses(block);
1621      }
1622    }
1623  } catch (error) {
1624    console.error('Fragment block failed to render.', error);
1625
1626    if (config.reference && canUseLegacyFallback(config.reference)) {
1627      try {
1628        const fallbackRendered = isDetailMode(config)
1629          ? await renderDetailFromHtml(block, config)
1630          : await renderLegacyFragment(block, config.reference);
1631        detailRendered = isDetailMode(config) && fallbackRendered;
1632        if (!fallbackRendered) {
1633          block.textContent = '';
1634          updateModeClasses(block);
1635        }
1636      } catch {
1637        block.textContent = '';
1638        updateModeClasses(block);
1639      }
1640    }
1641  }
1642
1643  if (isDetailMode(config) && detailRendered) {
1644    logDetailPageRender(config);
1645  }
1646
1647  await appendFareHarborRefToDetailCtas(block);
1648  wireFragmentDetailCtaAnalytics(block);
1649
1650  // lifecycle hook + event (after)
1651  options.onAfter?.(ctx);
1652  block.dispatchEvent(new CustomEvent('fragment:after', { detail: ctx, bubbles: true }));
1653}
1654
1655/**
1656 * Default export
1657 * - Calls decorate()
1658 * - Allows global hook injection via window.Fragment?.hooks
1659 */
1660export default (block) => decorate(block, window.Fragment?.hooks);

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.