1/** 2 * Detail Page Renderer 3 * - Renders detail page content from authored content fragment data 4 * - Provides reusable base for rendering detail pages from different CF types 5 * - Supports layouts: article, details-with-image-gallery 6 */ 7 8export const DETAIL_LAYOUTS = { 9 ARTICLE: 'article', 10 DETAILS_WITH_IMAGE_GALLERY: 'details-with-image-gallery', 11}; 12 13export const DETAIL_TRUE_VALUES = new Set(['true', '1', 'yes', 'y']); 14 15export const TITLE_FIELDS = ['title', 'name', 'heading', 'headline']; 16export const EYEBROW_FIELDS = ['category']; 17export const EVENT_CATEGORY_FIELDS = ['category', 'categories', 'eventCategory']; 18export const EVENT_INFORMATION_FIELDS = [ 19 { names: ['eventDate', 'startDate', 'date', 'eventStartDate'], type: 'date' }, 20 { names: ['eventLocationName'], type: 'text' }, 21 { names: ['eventLocationAddress'], type: 'text' }, 22 { 23 names: ['eventTime', 'startTime'], 24 endNames: ['endTime', 'eventEndTime'], 25 type: 'time', 26 }, 27]; 28export const EVENT_DETAIL_FIELDS = ['detailPageDescriptionText']; 29export const BODY_FIELDS = [ 30 'body', 31 'description', 32 'summary', 33 'shortdescription', 34 'detailpagedescription', 35 'detailpagedescriptiontext', 36 'detailpageadditionaltext', 37 'longdescription', 38 'articlebody', 39]; 40 41const DETAIL_PAGE_TYPE_ALIASES = [ 42 ['houseboat', ['houseboat', 'houseboats']], 43 ['offer', ['offer', 'offers']], 44 ['event', ['event', 'events']], 45 ['promotion', ['promotion', 'promotions']], 46 ['place', ['place', 'places']], 47 ['news', ['news']], 48 ['activity', ['activity', 'activities']], 49 ['product', ['product', 'products']], 50]; 51 52// Utility functions 53function toText(value) { 54 return value === null || value === undefined ? '' : String(value).trim(); 55} 56 57function isObject(value) { 58 return Boolean(value) && typeof value === 'object' && !Array.isArray(value); 59} 60 61function looksLikeHtml(value) { 62 return /^\s*<[^>]+>/.test(toText(value)); 63} 64 65function findDetailPageType(value) { 66 const normalizedValue = toText(value).toLowerCase(); 67 if (!normalizedValue) return ''; 68 69 const matchingType = DETAIL_PAGE_TYPE_ALIASES.find(([, aliases]) => ( 70 aliases.some((alias) => new RegExp(`(?:^|[-_/])${alias}(?:$|[-_/\\d])`, 'i') 71 .test(normalizedValue)) 72 )); 73 74 return matchingType?.[0] || ''; 75} 76 77/** 78 * Identifies the content type represented by a detail page. 79 * The authored detail config is the canonical source; the content fragment 80 * reference provides a route-based fallback for older configurations. 81 * 82 * @param {Object} identifiers detail page identifiers 83 * @param {string} identifiers.contentFragmentConfig model config path 84 * @param {string} identifiers.reference content fragment reference path 85 * @param {string} identifiers.route current page route 86 * @param {Object} identifiers.fragmentData normalized fragment fields 87 * @returns {string} normalized content type or `unknown` 88 */ 89export function detectDetailPageType({ 90 contentFragmentConfig = '', 91 reference = '', 92 route = '', 93 fragmentData = {}, 94} = {}) { 95 const explicitType = Object.entries(fragmentData || {}) 96 .find(([key]) => /^(pageType|contentType|type|model)$/i.test(key)); 97 return findDetailPageType(explicitType?.[1]) 98 || findDetailPageType(contentFragmentConfig) 99 || findDetailPageType(reference) 100 || findDetailPageType(route) 101 || 'unknown'; 102} 103 104const EVENT_CATEGORY_LABELS = new Map([ 105 ['aramark-destinations:global/event-category/family', 'Family'], 106 ['aramark-destinations:global/event-category/other', 'Other'], 107]); 108 109function getEventCategoryValue(value) { 110 if (isObject(value)) { 111 return value.value || value.path || value.id || value.name || ''; 112 } 113 114 return value; 115} 116 117function getEventCategoryLabel(value) { 118 const categoryValue = getEventCategoryValue(value); 119 const normalizedValue = toText( 120 categoryValue, 121 ).toLowerCase(); 122 const mappedLabel = EVENT_CATEGORY_LABELS.get(normalizedValue); 123 if (mappedLabel) return mappedLabel; 124 125 const categoryKey = normalizedValue.split('/').filter(Boolean).pop(); 126 if (categoryKey === 'family') return 'Family'; 127 if (categoryKey === 'other') return 'Other'; 128 return toText(categoryValue); 129} 130 131export function getEventCategoryLabels(value) { 132 const values = Array.isArray(value) 133 ? value.flatMap((item) => getEventCategoryLabels(item)) 134 : [getEventCategoryValue(value)]; 135 136 return [...new Set(values.map((item) =>
136 getEventCategoryLabel(item)).filter(Boolean))]; 137} 138 139export function getEventCategoryDisplayValue(value) { 140 return getEventCategoryLabels(value).join(', '); 141} 142 143function getScalarValue(value) { 144 if (Array.isArray(value)) return value.map((item) => getScalarValue(item)).join(', '); 145 if (isObject(value)) return value.value || value.text || value.label || value.path || ''; 146 return value; 147} 148 149export function formatEventDate(value) { 150 const text = toText(getScalarValue(value)); 151 const match = text.match(/^(\d{4})-(\d{2})-(\d{2})/); 152 const date = match 153 ? new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3])) 154 : new Date(text); 155 156 if (Number.isNaN(date.getTime())) return text; 157 return new Intl.DateTimeFormat('en-US', { 158 month: 'long', 159 day: 'numeric', 160 year: 'numeric', 161 }).format(date); 162} 163 164export function formatEventTime(value) { 165 const text = toText(getScalarValue(value)); 166 const match = text.match(/(?:T|\s|^)(\d{1,2}):(\d{2})(?::\d{2})?/); 167 if (!match) return text; 168 169 const hours = Number(match[1]); 170 const minutes = Number(match[2]); 171 if (hours > 23 || minutes > 59) return text; 172 173 const period = hours >= 12 ? 'PM' : 'AM'; 174 const displayHours = hours % 12 || 12; 175 return `${String(displayHours).padStart(2, '0')}:${match[2]} ${period}`; 176} 177 178export function getFragmentValue(fragmentData, fieldName) { 179 const requestedField = toText(fieldName); 180 if (!requestedField) return undefined; 181 182 if (Object.hasOwn(fragmentData, requestedField)) { 183 return fragmentData[requestedField]; 184 } 185 186 const normalizedRequestedField = requestedField.toLowerCase(); 187 const matchedField = Object.keys(fragmentData) 188 .find((key) => key.toLowerCase() === normalizedRequestedField); 189 190 return matchedField ? fragmentData[matchedField] : undefined; 191} 192 193function hasRenderableValue(value) { 194 if (value === null || value === undefined) return false; 195 if (Array.isArray(value)) return value.some((item) => hasRenderableValue(item)); 196 if (typeof value === 'string') return toText(value).length > 0; 197 if (isObject(value)) return Object.keys(value).length > 0; 198 return true; 199} 200 201function isImagePath(value) { 202 return /\.(avif|gif|jpe?g|png|svg|webp)(?:$|\?)/i.test(toText(value)); 203} 204 205function collectImageSources(value) { 206 if (Array.isArray(value)) { 207 return value.flatMap((item) => collectImageSources(item)).filter(Boolean); 208 } 209 210 if (isImagePath(value)) { 211 return [toText(value)]; 212 } 213 214 if (isObject(value)) { 215 const url = toText(value.url || value.path || value.src); 216 if (isImagePath(url)) return [url]; 217 } 218 219 return []; 220} 221 222function collectAltTexts(value) { 223 if (Array.isArray(value)) { 224 return value.flatMap((item) => collectAltTexts(item)).filter(Boolean); 225 } 226 227 if (typeof value === 'string') { 228 const text = toText(value); 229 return text ? [text] : []; 230 } 231 232 if (isObject(value)) { 233 const text = toText( 234 value.alt 235 || value.altText 236 || value.imageAlt 237 || value.label, 238 ); 239 240 return text ? [text] : []; 241 } 242 243 return []; 244} 245 246function appendParagraph(container, text) { 247 const paragraph = document.createElement('p'); 248 paragraph.textContent = text; 249 container.append(paragraph); 250} 251 252function buildEventInformation(fields, fragmentData) { 253 const information = document.createElement('dl'); 254 information.className = 'fragment-detail-event-information'; 255 256 EVENT_INFORMATION_FIELDS.forEach(({ names, endNames = [], type }) => { 257 const field = fields.find(({ name: fieldName }) => names.some( 258 (name) => fieldName.toLowerCase() === name.toLowerCase(), 259 )); 260 if (!field) return; 261 262 const value = getFragmentValue(fragmentData, field.name); 263 if (!hasRenderableValue(value)) return; 264 265 const item = document.createElement('div'); 266 item.className = `fragment-detail-event-information-${names[0].toLowerCase()}`; 267 268 const itemValue = document.createElement('dd'); 269 itemValue.className = 'fragment-detail-event-information-value'; 270 if (type === 'date') { 271 itemValue.textContent = formatEventDate(value); 272 } else if (type === 'time') { 273 const endField = fields.find(({ name: fieldName }) => endNames.some( 274 (name) => fieldName.toLowerCase() === name.toLowerCase(), 275 )); 276 const endValue = endField ? getFragmentValue(fragmentData, endField.name) : undefined; 277 const startTime = formatEventTime(value); 278 const endTime = hasRenderableValue(endValue) ? formatEventTime(endValue) : ''; 279 itemValue.textContent = endTime ? `${startTime} - ${endTime}` : startTime; 280 } else { 281 itemValue.textContent = toText(getScalarValue(value)); 282 } 283 284 item.append(itemValue); 285 information.append(item); 286 }); 287 288 return information.childElementCount ? information : null; 289} 290
291export function appendRenderedValue(container, value) { 292 if (!hasRenderableValue(value)) return; 293 294 if (Array.isArray(value)) { 295 const list = document.createElement('ul'); 296 list.className = 'fragment-detail-value-list'; 297 298 value.forEach((item) => { 299 if (!hasRenderableValue(item)) return; 300 301 const listItem = document.createElement('li'); 302 listItem.className = 'fragment-detail-value-list-item'; 303 appendRenderedValue(listItem, item); 304 list.append(listItem); 305 }); 306 307 if (list.childElementCount > 0) { 308 container.append(list); 309 } 310 311 return; 312 } 313 314 if (typeof value === 'string') { 315 if (looksLikeHtml(value)) { 316 container.insertAdjacentHTML('beforeend', value); 317 return; 318 } 319 320 appendParagraph(container, value); 321 return; 322 } 323 324 if (typeof value === 'number' || typeof value === 'boolean') { 325 appendParagraph(container, String(value)); 326 return; 327 } 328 329 if (isObject(value)) { 330 const html = toText(value.html); 331 const text = toText(value.text); 332 const url = toText(value.url || value.path || value.src); 333 334 if (html) { 335 container.insertAdjacentHTML('beforeend', html); 336 return; 337 } 338 339 if (text) { 340 appendParagraph(container, text); 341 return; 342 } 343 344 if (url) { 345 const link = document.createElement('a'); 346 link.href = url; 347 link.textContent = url; 348 container.append(link); 349 } 350 } 351} 352 353export function findPreferredField(fields, fragmentData, candidates, excluded = new Set()) { 354 const normalizedCandidates = new Set(candidates.map((candidate) => candidate.toLowerCase())); 355 356 return fields.find(({ name }) => { 357 if (excluded.has(name)) return false; 358 if (!normalizedCandidates.has(name.toLowerCase())) return false; 359 return hasRenderableValue(getFragmentValue(fragmentData, name)); 360 }) || null; 361} 362 363export function findPreferredFields(fields, fragmentData, candidates, excluded = new Set()) { 364 const normalizedCandidates = new Set(candidates.map((candidate) => candidate.toLowerCase())); 365 366 return fields.filter(({ name }) => { 367 if (excluded.has(name)) return false; 368 if (!normalizedCandidates.has(name.toLowerCase())) return false; 369 return hasRenderableValue(getFragmentValue(fragmentData, name)); 370 }); 371} 372 373export function createFieldElement(field, value) { 374 if (!hasRenderableValue(value)) return null; 375 376 const wrapper = document.createElement('div'); 377 wrapper.className = 'fragment-detail-field'; 378 wrapper.classList.add(`fragment-detail-field-${field.name.toLowerCase()}`); 379 380 const label = document.createElement('dt'); 381 label.className = 'fragment-detail-label'; 382 label.textContent = field.label; 383 384 const content = document.createElement('dd'); 385 content.className = 'fragment-detail-value'; 386 appendRenderedValue(content, value); 387 388 if (!content.childNodes.length) return null; 389 390 wrapper.append(label, content); 391 return wrapper; 392} 393 394function getGalleryAltCandidates(fieldName, altSuffixes = []) { 395 const baseFieldName = toText(fieldName); 396 if (!baseFieldName) return []; 397 398 const singularBaseFieldName = baseFieldName.endsWith('Images') 399 ? baseFieldName.replace(/Images$/, 'Image') 400 : baseFieldName; 401 const candidates = altSuffixes.flatMap((suffix) => ([ 402 `${baseFieldName}${suffix}`, 403 `${singularBaseFieldName}${suffix}`, 404 `${singularBaseFieldName}${suffix}s`, 405 ])); 406 407 if (!candidates.length) { 408 candidates.push(`${baseFieldName}Alt`, `${singularBaseFieldName}Alt`); 409 } 410 411 return [...new Set(candidates)]; 412} 413 414function getGalleryItems(fieldName, fragmentData, altSuffixes = []) { 415 const sources = collectImageSources(getFragmentValue(fragmentData, fieldName)); 416 const altTexts = getGalleryAltCandidates(fieldName, altSuffixes) 417 .flatMap((candidate) => collectAltTexts(getFragmentValue(fragmentData, candidate))); 418 419 return sources.map((source, index) => ({ 420 source, 421 alt: altTexts[index] || (sources.length === 1 ? altTexts[0] || '' : ''), 422 })); 423} 424 425export async function buildGallery(fields, fragmentData, imageAltSuffixes = []) { 426 const galleryItems = fields.flatMap(({ name }) => ( 427 getGalleryItems(name, fragmentData, imageAltSuffixes) 428 )); 429 const uniqueGalleryItems = galleryItems.reduce((items, item) => { 430 if (!item.source || items.some((entry) => entry.source === item.source)) { 431 return items; 432 } 433 434 items.push(item); 435 return items; 436 }, []); 437 438 if (!uniqueGalleryItems.length) { 439 return null; 440 } 441 442 const gallery = document.createElement('div'); 443 gallery.className = 'fragment-detail-gallery'; 444 gallery.setAttribute('aria-label', 'Image gallery'); 445 446 const list = document.createElement('ul'); 447 list.className = 'fragment-detail-gallery-list'; 448
449 uniqueGalleryItems.forEach((item, index) => { 450 if (!item.source) return; 451 452 const galleryItem = document.createElement('li'); 453 galleryItem.className = 'fragment-detail-gallery-item'; 454 455 const image = document.createElement('img'); 456 image.className = 'fragment-detail-gallery-image'; 457 image.src = item.source; 458 image.alt = item.alt || ''; 459 image.loading = index === 0 ? 'eager' : 'lazy'; 460 461 galleryItem.append(image); 462 list.append(galleryItem); 463 }); 464 465 if (!list.childElementCount) { 466 return null; 467 } 468 469 gallery.append(list); 470 return gallery; 471} 472 473export async function buildArticle(fields, fragmentData, options = {}) { 474 const { 475 excluded = new Set(), 476 buildCtaGroupFn = null, 477 pageType = 'unknown', 478 } = options; 479 480 const article = document.createElement('article'); 481 article.className = 'fragment-detail-article'; 482 483 const isEvent = pageType === 'event'; 484 const eyebrowCandidates = isEvent ? EVENT_CATEGORY_FIELDS : EYEBROW_FIELDS; 485 const eyebrowField = findPreferredField(fields, fragmentData, eyebrowCandidates, excluded); 486 const titleField = findPreferredField(fields, fragmentData, TITLE_FIELDS, excluded); 487 488 if (eyebrowField || titleField) { 489 const header = document.createElement('header'); 490 header.className = 'fragment-detail-header'; 491 492 if (eyebrowField) { 493 excluded.add(eyebrowField.name); 494 const eyebrow = document.createElement('p'); 495 eyebrow.className = 'fragment-detail-eyebrow'; 496 const eyebrowValue = getFragmentValue(fragmentData, eyebrowField.name); 497 eyebrow.textContent = isEvent 498 ? getEventCategoryDisplayValue(eyebrowValue) 499 : toText(eyebrowValue); 500 header.append(eyebrow); 501 } 502 503 if (titleField) { 504 excluded.add(titleField.name); 505 506 const title = document.createElement('h2'); 507 title.className = 'fragment-detail-title'; 508 const titleValue = getFragmentValue(fragmentData, titleField.name); 509 510 if (looksLikeHtml(titleValue)) { 511 title.innerHTML = titleValue; 512 } else { 513 title.textContent = toText(titleValue); 514 } 515 516 header.append(title); 517 518 const divider = document.createElement('span'); 519 divider.className = 'brand-divider'; 520 divider.setAttribute('aria-hidden', 'true'); 521 header.append(divider); 522 } 523 524 article.append(header); 525 } 526 527 if (isEvent) { 528 const eventInformation = buildEventInformation(fields, fragmentData); 529 if (eventInformation) { 530 article.append(eventInformation); 531 } 532 } 533 534 const bodyFields = findPreferredFields(fields, fragmentData, BODY_FIELDS, excluded); 535 const renderBodyFields = isEvent ? [] : bodyFields; 536 renderBodyFields.forEach((bodyField) => { 537 excluded.add(bodyField.name); 538 539 const body = document.createElement('div'); 540 body.className = 'fragment-detail-body'; 541 body.classList.add(`fragment-detail-body-${bodyField.name.toLowerCase()}`); 542 appendRenderedValue(body, getFragmentValue(fragmentData, bodyField.name)); 543 544 if (body.childNodes.length) { 545 article.append(body); 546 } 547 }); 548 549 const ctaGroup = buildCtaGroupFn 550 ? await buildCtaGroupFn(fields, fragmentData, excluded) 551 : null; 552 553 const details = document.createElement('dl'); 554 details.className = 'fragment-detail-fields'; 555 556 fields.forEach((field) => { 557 if (excluded.has(field.name)) return; 558 559 const fieldElement = createFieldElement(field, getFragmentValue(fragmentData, field.name)); 560 if (fieldElement) { 561 if (isEvent) { 562 fieldElement.hidden = !fieldElement.classList.contains( 563 'fragment-detail-field-detailpagedescriptiontext', 564 ); 565 fieldElement.querySelector('.fragment-detail-label')?.setAttribute('hidden', ''); 566 } 567 details.append(fieldElement); 568 } 569 }); 570 571 if (details.childElementCount > 0) {
572 article.append(details); 573 } 574 575 if (ctaGroup) { 576 article.append(ctaGroup); 577 } 578 579 return article.childNodes.length ? article : null; 580} 581 582export async function buildDetailContent(layout, fields, fragmentData, options = {}) { 583 const { 584 buildGalleryFn = null, 585 buildArticleFn = buildArticle, 586 imageAltSuffixes = [], 587 pageType = 'unknown', 588 } = options; 589 590 const wrapper = document.createElement('div'); 591 wrapper.className = 'fragment-detail'; 592 wrapper.classList.add(`fragment-detail-${layout}`); 593 if (pageType === 'event') { 594 wrapper.classList.add('fragment-detail-event'); 595 } 596 597 const excluded = new Set(); 598 599 // Exclude image alt-companion fields in all layouts so they are used as alt 600 // text on images rather than rendered as visible text on the page. 601 const imageFields = fields.filter( 602 ({ name }) => collectImageSources(getFragmentValue(fragmentData, name)).length, 603 ); 604 imageFields.forEach(({ name }) => { 605 getGalleryAltCandidates(name, imageAltSuffixes).forEach((candidate) => { 606 const matchingField = fields.find( 607 ({ name: n }) => n.toLowerCase() === candidate.toLowerCase(), 608 ); 609 if (matchingField) { 610 excluded.add(matchingField.name); 611 } 612 }); 613 }); 614 615 if (layout === DETAIL_LAYOUTS.DETAILS_WITH_IMAGE_GALLERY) { 616 imageFields.forEach(({ name }) => excluded.add(name)); 617 618 if (buildGalleryFn) { 619 const gallery = await buildGalleryFn(imageFields, fragmentData); 620 if (gallery) { 621 wrapper.append(gallery); 622 } 623 } 624 } 625 626 const article = await buildArticleFn(fields, fragmentData, { 627 excluded, 628 pageType, 629 }); 630 if (article) { 631 wrapper.append(article); 632 } 633 634 return wrapper.childNodes.length ? wrapper : null; 635}
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.