PageSourceSearch

https://www.lakepowell.com/blocks/fragment/detailBlockLayout/detail-page-renderer.js

js lakepowell.com collected 2026-10-02 03:45:40 UTC 19,287 bytes, 635 lines download raw bytes

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.