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.