PageSourceSearch

https://www.nurtec.com/lib/scripts/lib-franklin/core-utilities.js

js nurtec.com collected 2026-09-26 05:15:16 UTC 25,643 bytes, 694 lines download raw bytes

1import { decorateIcons, getMetadata } from './lib-franklin.js';
2import { Env } from '../../env.js';
3
4// This is added by sharePoint & standards, but we validate this as a rule.
5const externalToCore = Boolean(window && window?.coreBlocks?.notCore);
6const primaryBlockNamespaces = externalToCore ? `custom` : `core`;
7
8/**
9 * Checks if a block's table structure contains a row with a specific cell name in the first column.
10 * @param {HTMLElement} block - The block element (or its first child if not a core- block).
11 * @param {string} cellName - The cell name to search for (case-insensitive).
12 * @returns {boolean} True if a matching row exists.
13 */
14export const isAvailableChildrenRow = (block, cellName) => {
15  // if "block" target is the table use that, if not assume the first child is the table
16  const hasCoreClassName = block.classList.value.split(' ').some((className) => className.startsWith('core'));
17  const { children } = hasCoreClassName ? block : block.children[0];
18  if (!children) return block;
19
20  return [...children].some((child) => {
21    const minimumCells = Boolean(child?.children.length > 1);
22    if (!minimumCells) return false;
23
24    const firstCellMatched = child?.children[0].textContent.trim().toLowerCase();
25    return Boolean(firstCellMatched === cellName);
26  });
27};
28
29/**
30 * Retrieves the second cell element from a block's table row where the first cell matches the given name.
31 * @param {HTMLElement} block - The block element (or its first child if not a core- block).
32 * @param {string} cellName - The cell name to search for (case-insensitive).
33 * @returns {HTMLElement|null} The second cell element, or null if not found.
34 */
35export const getAvailableChildrenRow = (block, cellName) => {
36  // if "block" target is the table use that, if not assume the first child is the table
37  const hasCoreClassName = block.classList.value.split(' ').some((className) => className.startsWith('core'));
38  const { children } = hasCoreClassName ? block : block.children[0];
39  if (!children) return null;
40
41  const targetChild = [...children].find((child) => {
42    const minimumCells = Boolean(child?.children.length > 1);
43    if (!minimumCells) return false;
44
45    const firstCellMatched = child?.children[0].textContent.trim().toLowerCase();
46    return Boolean(firstCellMatched === cellName);
47  });
48
49  return targetChild?.children[1] || null;
50};
51
52/**
53 * Fetches a page's plain HTML content (.plain.html), checking prefetchedPages cache first.
54 * Falls back to metadata path or fallbackPath if primary fetch fails.
55 * @param {string} name - Metadata name to check for custom path.
56 * @param {string} path - Primary path to fetch from.
57 * @param {string|null} [fallbackPath=null] - Fallback path if primary fails.
58 * @returns {Promise<string|undefined>} The HTML markup, or undefined if all fetches fail.
59 */
60export async function platformFetchPage(name, path, fallbackPath = null, prefetch = false) {
61  if (window.prefetchedPages && window.prefetchedPages[path]) {
62    return window.prefetchedPages[path];
63  }
64
65  const blockMeta = getMetadata(name);
66  const blockPath = blockMeta && blockMeta.includes('/') ? new URL(blockMeta, window.location.origin).pathname : path;
67
68  // return immeiatelly to prevent duplicate fetches while we wait for the promise to resolve
69  const fecthAndReadPage = async () => {
70    let resp = await fetch(`${blockPath}.plain.html`);
71
72    if (!resp.ok) {
73      if (fallbackPath) {
74        resp = await fetch(`${fallbackPath}.plain.html`);
75
76        if (!resp.ok) {
77          console.warn('missing HTML platformFetchPage resp on: ', name, path, resp);
78          return undefined;
79        }
80      } else {
81        return undefined;
82      }
83    }
84
85    const markup = await resp.text();
86
87    if (markup.length < 10) {
88      console.warn('missing HTML on markup length: ', name, path, resp);
89      return undefined;
90    }
91
92    return markup;
93  };
94  const pagePromise = fecthAndReadPage();
95  if (prefetch) {
96    window.prefetchedPages[path] = pagePromise;
97  }
98  return pagePromise;
99}
100
101/**
102 * Add a data attribute to the block to track hydration status
103 * @param block
104 * @param {string} step - the current hydration step (started, completed), defaults to 'started'
105 */
106export function setBlockHydrationAttribute(block, step = 'started') {
107  return block.setAttribute(`data-core-lib-hydration`, step);
108}
109
110/**
111 * Extracts a block's children as HTML string and sets hydration/loaded attributes.
112 * Sets data-core-lib-hydration="started" and data-{namespace}-loaded="block" attributes.
113 * @param {HTMLElement} block - The block element to extract HTML from.
114 * @returns {Promise<string>} Concatenated outerHTML of all block children.
115 */
116export async function platformFetchBlock(block) {
117  setBlockHydrationAttribute(block);
118
119  block.setAttribute(`data-${primaryBlockNamespaces}-loaded`, 'block');
120  const childrenArray = [...block.children];
121  // validate no malformed children
122  childrenArray.forEach((child) => {
123    if (!(child instanceof HTMLElement)) {
124      console.error('not instance of HTMLElement', child);
125    }
126  });
127
128  const childrenAsStringHtml = childrenArray.map((child) => child.outerHTML).join('');
129  return childrenAsStringHtml;
130}
131
132/**
133 * Wraps block HTML in a div with .{namespace}-{name}-inside and .block-inside classes.
134 * Adds .{namespace}-{name}-content class to child elements. Decorates icons if present.
135 * @param {string} name - Block name for class generation.
136 * @param {string} originalMarkup - HTML markup to wrap.
137 * @returns {Promise<HTMLElement>} The wrapped block element.
138 */
139export async function platformCreateMarkup(name, originalMarkup) {
140  const classPrefix = `${primaryBlockNamespaces}-${name}`;
141  const blockCreateElement = document.createElement('div');
142  blockCreateElement.innerHTML = originalMarkup;
143
144  blockCreateElement.classList.add(`${classPrefix}-inside`, `block-inside`);
145
146  if (blockCreateElement.hasChildNodes()) {
147    const childNodes = blockCreateElement.children;
148    Array.from(childNodes).forEach((child) => child.classList.add(`${classPrefix}-content`));
149  }
150
151  // hydrate icons and set specific data attribute
152  const iconSpans = blockCreateElement?.querySelectorAll('span.icon:not([data-icon-loaded])');
153  if (iconSpans.length > 0) {
154    await decorateIcons(blockCreateElement, `lib-core-utilities`);
155  }
156
157  // [...blockCreateElement.querySelectorAll('a[data-overlay-link]')].forEach((link) => link.removeAttribute('data-overlay-link'));
158
159  return blockCreateElement;
160}
161
162/**
163 * Standardize the namespace of the block and ensure proper validatation is added to standard.
164 */
165export async function platformOutputMarkup(blockElement, renderMarkup, callback = undefined, options = {}, replaceBlock = false) {
166  let block = blockElement;
167  block.innerHTML = '';
168  // TODO: replaceBlock is temp during migration
169  if (replaceBlock) {
170    const blockName = blockElement.getAttribute('data-block-name');
171    block.replaceWith(renderMarkup);
172    block = renderMarkup;
173    block.setAttribute('data-block-name', blockName);
174  } else {
175    block.append(renderMarkup);
176  }
177
178  const dataAttributeName = block.getAttribute('data-block-name');
179  const dataAttributeLoaded = block.getAttribute(`data-${primaryBlockNamespaces}-loaded`);
180
181  // if not loaded during block assignment, assume as page load in platformFetchPage
182  if (dataAttributeLoaded == null) {
183    block.setAttribute(`data-${primaryBlockNamespaces}-loaded`, 'page');
184  }
185
186  // ensure namespace of block level class
187  if (!dataAttributeName || !dataAttributeName.startsWith(primaryBlockNamespaces)) {
188    console.error('mis-matched namespace naming on: ', block, dataAttributeName);
189    return block;
190  }
191
192  // add modifier class, todo - find a better way to do this
193  const filterClass = block?.classList?.value?.split(' ').filter((className) => className.trim().length && !(className.includes(dataAttributeName) || className === 'block'));
194
195  filterClass.forEach((className) => {
196    // we need to filter out adding grid classes since new loading
197    if (!className.includes('grid-')) {
198      block.classList.add(`${dataAttributeName}-${className}`);
199    }
200  });
201
202  const { smartcapture } = options;
203  if (smartcapture) {
204    const { smartCaptureTags } = await import('./smart-capture.js');
205    smartCaptureTags(smartcapture, block);
206    block.setAttribute(`data-smartcapture-enabled-config`, 'true');
207  }
208
209  // add for our personal marker for blocks for testing later with SmartCapture team
210  block.setAttribute(`data-smartcapture-enabled`, 'true');
211
212  // final validate to check for "core-" namespace is not granted
213  if (primaryBlockNamespaces !== 'core') {
214    const queryNamespace = Boolean(block.parentElement.querySelector(`[class*="core-"]`));
215    if (queryNamespace) {
216      console.error('core declaration is not allowed on: ', block);
217      const fauxCss = `border: 1px dotted red; padding: 5px; font-size: 12px; background: #f1e5e5; text-transform: uppercase;`;
218      block.innerHTML = `<div style="${fauxCss}">core declaration is not allowed on: ${dataAttributeName}</div>`;
219    }
220  }
221
222  // img bug tag fix temporarily while working with platform
223  const imageDataTitle = block.querySelectorAll('picture > img[data-title]');
224  if (imageDataTitle.length > 0) {
225    imageDataTitle.forEach((img) => {
226      if (img.title === '' || !img?.title) {
227        img.title = img.dataset.title;
228      }
229    });
230  }
231
232  const isCallback = callback ? await callback(block) : block;
233
234  if (typeof isCallback !== 'undefined') {
235    // hydrate icons and set specific data attribute
236    const iconSpans = await isCallback?.querySelectorAll('span.icon:not([data-icon-loaded])');
237    if (iconSpans.length > 0) {
238      decorateIcons(isCallback, `lib-core-utilities`);
239    }
240
241    //
242  }
243
244  // Set the block hydration attribute to 'completed' regardless of the condition
245  setBlockHydrationAttribute(block, 'completed');
246
247  return isCallback;
248}
249
250/**
251 * Validates block classes against allowed schema classes and common utility classes.
252 * Filters out standard classes (block name, "block", color-theme-*, and common utilities).
253 * Based on blockPolicyLevel: "warning" logs errors, "error" removes classes and logs.
254 * @param {string} blockName - The block name (e.g., "card").
255 * @param {Array<string>} allowedSchemaClasses - Classes allowed by block schema.
256 * @param {Array<string>} getBlockClasses - Current classes on the block element.
257 * @param {HTMLElement} block - The block element.
258 * @param {string} blockPolicyLevel - "warning" or "error" enforcement level.
259 */
260function detectDisallowedClasses(blockName, allowedSchemaClasses, getBlockClasses, block, blockPolicyLevel) {
261  const blockNameAsClass = [`core-${blockName.toLowerCase().trim()}`];
262  const blockStandardName = ['block'];
263
264  // TODO: keep updated or add tests later
265  // we will allow a couple of the old ones just temporarily, like "brand" and "edge"
266  const commonUtilityClasses = ['tinted', 'inverted', 'no-shadow', 'brand', 'edge', 'hidden', 'visible', 'dynamic-height', 'redirect-url', 'slot-pre-footer', 'tooltip', 'block-padding-standard-v2'];
267
268  const schemaClasses = allowedSchemaClasses;
269  const permittedClasses = [...schemaClasses, ...blockNameAsClass, ...blockStandardName, ...commonUtilityClasses];
270
271  const assignedFilterClasses = getBlockClasses.filter(
272    (className) =>
273      // includes or starts with 'color-theme-'
274      !(permittedClasses.includes(className) || className.startsWith('color-theme-'))
275  );
276
277  // just return as true and ok to build block
278  if (assignedFilterClasses.length < 1) return;
279
280  // [Warning] if an unapproved variant is added to the block, we show a console warning, but still hydrate the block
281  if (blockPolicyLevel === 'warning') {
282    assignedFilterClasses.forEach((className) => {
283      console.error(`🚨 🚨 Improper class being used remove before next release '${blockName}' :: ${className} 🚨 🚨`);
284    });
285    return;
286  }
287
288  // [Error] if an unapproved variant is added to the block,
289  // We remove that class from the block and show a console error, but still hydrate the block
290  if (blockPolicyLevel === 'error') {
291    assignedFilterClasses.forEach((className) => {
292      block.classList.remove(className);
293      console.error(`🚨 🚨 Improper class used & removed '${blockName}' :: ${className} 🚨 🚨`);
294    });
295  }
296}
297
298/**
299 * Validates block classes against schema-defined allowed classes using Env configuration.
300 * Only runs if Env.enforceBlockPolicy is true and blockPolicyLevel is "warning" or "error".
301 * Calls detectDisallowedClasses() to enforce policy.
302 * @param {string} blockName - The block name.
303 * @param {HTMLElement} block - The block element.
304 * @param {Object} schema - Block schema with classes array.
305 */
306export function validateSchemaBlock(blockName, block, schema) {
307  const { blockPolicyLevel, enforceBlockPolicy } = Env;
308
309  if (blockPolicyLevel !== 'warning' && blockPolicyLevel !== 'error') return;
310
311  // configuration to allow skipping block validation of variants
312  if (enforceBlockPolicy !== true) {
313    console.error(`enforceBlockPolicy is false domain on block: ${blockName}`);
314    return;
315  }
316
317  // Get block classes and allowed schema classes
318  const blockClasses = Array.from(block?.classList || []);
319  const allowedClasses = schema?.classes || [];
320
321  // Validate that all block classes are allowed
322  detectDisallowedClasses(blockName, allowedClasses, blockClasses, block, blockPolicyLevel);
323}
324
325/**
326 * platformOutputMarkupNew is duplicate of platformOutputMarkup while building
327 *
328 * @param {*} blockElement
329 * @param {*} callback
330 * @param {*} options
331 * @returns
332 */
333export async function platformOutputMarkupNew(blockElement, callback = undefined, options = {}) {
334  const block = blockElement;
335
336  const dataAttributeName = block.getAttribute('data-block-name');
337
338  // ensure namespace of block level class
339  if (!dataAttributeName || !dataAttributeName.startsWith(primaryBlockNamespaces)) {
340    console.error('mis-matched namespace new naming on: ', block, dataAttributeName);
341    return block;
342  }
343
344  // add modifier class, todo - find a better way to do this
345  const filterClass = block?.classList?.value?.split(' ').filter((className) => className.trim().length && !(className.includes(dataAttributeName) || className === 'block'));
346
347  filterClass.forEach((className) => {
348    // we need to filter out adding grid classes since new loading
349    if (!className.includes('grid-')) {
350      block.classList.add(`${dataAttributeName}-${className}`);
351    }
352  });
353
354  const { smartcapture } = options;
355  if (smartcapture) {
356    const { smartCaptureTags } = await import('./smart-capture.js');
357    smartCaptureTags(smartcapture, block);
358    block.setAttribute(`data-smartcapture-enabled-config`, 'true');
359  }
360
361  // add for our personal marker for blocks for testing later with SmartCapture team
362  block.setAttribute(`data-smartcapture-enabled`, 'true');
363
364  // final validate to check for "core-" namespace is not granted
365  if (primaryBlockNamespaces !== 'core') {
366    const queryNamespace = Boolean(block.parentElement.querySelector(`[class*="core-"]`));
367    if (queryNamespace) {
368      console.error('core declaration is not allowed on: ', block);
369      const fauxCss = `border: 1px dotted red; padding: 5px; font-size: 12px; background: #f1e5e5; text-transform: uppercase;`;
370      block.innerHTML = `<div style="${fauxCss}">core declaration is not allowed on: ${dataAttributeName}</div>`;
371    }
372  }
373
374  // img bug tag fix temporarily while working with platform
375  const imageDataTitle = block.querySelectorAll('picture > img[data-title]');
376  if (imageDataTitle.length > 0) {
377    imageDataTitle.forEach((img) => {
378      if (img.title === '' || !img?.title) {
379        img.title = img.dataset.title;
380      }
381    });
382  }
383
384  const isCallback = callback ? await callback(block) : block;
385
386  if (typeof isCallback !== 'undefined') {
387    // hydrate icons and set specific data attribute
388    const iconSpans = await isCallback?.querySelectorAll('span.icon:not([data-icon-loaded])');
389    if (iconSpans.length > 0) {
390      await decorateIcons(isCallback, `lib-core-utilities`);
391    }
392  }
393
394  // Set the block hydration attribute to 'completed' regardless of the condition
395  setBlockHydrationAttribute(block, 'completed');
396
397  // mark new migration in tag as we are updating:
398  block.setAttribute(`data-migrated-cmo-branding`, '2.0');
399
400  return isCallback;
401}
402
403/**
404 * Universal Grid markup to add classes and counts based on selectors in blocks
405 * @param block
406 * @param childrenParent - the grid parent element
407 * @param childrenTarget - the grid child elements
408 * @returns block with updated classes
409 */
410
411export function decorateGrid(block, childrenParent, childrenTarget) {
412  const variants = [...block.classList];
413
414  // our Grid classes will be non-specific to the block, defaulting to 3 columns
415  // We take the extra step or removing the old class just in case there's extra classes which w
415ould cause a conflict
416  const columnClass = variants.find((element) => element.startsWith('column-')) ?? 'column-3';
417  const gridClass = columnClass.replace('column-', 'core-grid-');
418
419  // update existing class with new columnClass
420  block.classList.remove(columnClass);
421  block.classList.add(gridClass);
422
423  // add child count as helper but also for tests
424  if (childrenTarget < 1) {
425    console.error('missing children grid count: ', block);
426    return block;
427  }
428
429  // prevent un-built grid options
430  const approvedGridOptions = ['core-grid-2', 'core-grid-3', 'core-grid-4'];
431  if (!approvedGridOptions.includes(gridClass)) {
432    console.error('not approved grid option: ', block);
433    return block;
434  }
435
436  block.classList.add(`core-grid-children-${childrenTarget.length}`);
437
438  // add class to direct parent
439  childrenParent.classList.add(`core-grid-parent`);
440
441  return block;
442}
443
444/**
445 * Shows an error message to the user when the block setup is incorrect
446 * @param block
447 * @param message
448 */
449export function showBlockSetupError(block, message) {
450  if (window?.location?.hostname && Env.isNonProd()) {
451    const error = document.createElement('div');
452    error.classList.add('block-error');
453    error.innerHTML = `
454    <span class="icon icon-lib-mat-error-round"></span>
455    <div class="block-error-message">${message}</div>`;
456    decorateIcons(error);
457    block.append(error);
458    block.style.position = 'relative';
459  }
460
461  console.error(message);
462}
463
464/**
465 * Throttles a function to execute at most once per timeout period.
466 * Subsequent calls within the timeout are ignored until timeout completes.
467 * @param {Function} fn - The function to throttle.
468 * @param {number} timeout - Throttle period in milliseconds.
469 * @returns {Function} Throttled function.
470 */
471export function throttle(fn, timeout) {
472  let timeoutId = null;
473  return () => {
474    if (!timeoutId) {
475      timeoutId = setTimeout(() => {
476        clearTimeout(timeoutId);
477        timeoutId = null;
478        fn();
479      }, timeout);
480    }
481  };
482}
483
484/**
485 * Returns a promise that resolves when all child blocks within a container are activated or rejects after a timeout.
486 * @param {*} containerBlock
487 * @param {*} timeout
488 * @returns
489 */
490export function awaitChildBlocksReady(containerBlock, loadStatus = 'activated', timeout = 10000) {
491  if (!containerBlock || !(containerBlock instanceof Element)) {
492    return Promise.reject(new Error('containerBlock must be a valid DOM element'));
493  }
494
495  let isResolved = false;
496  let observer;
497
498  const checkAllChildBlocksActivated = () => {
499    if (isResolved) return false;
500    const childBlocks = containerBlock.querySelectorAll('.block');
501    const inactiveBlock = [...childBlocks].find((block) => block.dataset.blockStatus !== loadStatus);
502
503    if (!inactiveBlock) {
504      isResolved = true;
505      return true;
506    }
507    return false;
508  };
509
510  if (checkAllChildBlocksActivated()) {
511    return Promise.resolve();
512  }
513
514  const timeoutPromise = new Promise((_, reject) => {
515    setTimeout(() => {
516      if (!isResolved) {
517        isResolved = true;
518        if (observer) {
519          observer.disconnect();
520        }
521        reject(new Error('awaitChildBlocksReady timed out'));
522      }
523    }, timeout);
524  });
525
526  const activatedPromise = new Promise((resolve) => {
527    observer = new MutationObserver((mutations) => {
528      const shouldCheck = mutations.some((mutation) => mutation.type === 'attributes' && mutation.target.dataset.blockStatus === 'activated');
529
530      if (shouldCheck && checkAllChildBlocksActivated()) {
531        resolve();
532        observer.disconnect();
533      }
534    });
535
536    observer.observe(containerBlock, {
537      attributes: true,
538      attributeFilter: ['data-block-status'],
539      childList: true,
540      subtree: true,
541    });
542  });
543
544  return Promise.race([activatedPromise, timeoutPromise]);
545}
546
547/**
548 * Traps the tab focus inside block element to prevent the user from tabbing outside of the block.
549 *
550 * @param {*} blockElement
551 * @param {*} optionalTarget
552 * @returns {Promise<function>}
553 */
554export async function trapTabAccessibilityFocus(blockElement, optionalTarget = ['button', 'a[href]']) {
555  // eslint-disable-next-line no-promise-executor-return
556  const delayFocus = () => new Promise((resolve) => setTimeout(resolve, 500));
557  await delayFocus();
558
559  // focus on the actual block initially
560  blockElement.focus();
561
562  // we allow for an array of custom elements to be passed in or we default to buttons and links
563  const focusableElements = blockElement.querySelectorAll(optionalTarget);
564  if (focusableElements.length === 0) {
565    console.error('No focusable elements found in the block element.');
566    return;
567  }
568
569  const trap = (e) => {
570    if (e.key === 'Tab') {
571      const firstElement = focusableElements[0];
572      const lastElement = focusableElements[focusableElements.length - 1];
573      if (e.shiftKey) {
574        if (document.activeElement === firstElement) {
575          e.preventDefault();
576          lastElement.focus();
577        }
578      } else if (document.activeElement === lastElement) {
579        e.preventDefault();
580        firstElement.focus();
581      }
582    }
583  };
584
585  blockElement.addEventListener('keydown', trap);
586
587  // removes the event listener as async cleanup
588  // eslint-disable-next-line consistent-return
589  return () => {
590    blockElement.removeEventListener('keydown', trap);
591  };
592}
593
594/**
595 * Creates a promise that resolves after a specified delay.
596 * @param {number} time - Delay in milliseconds.
597 * @returns {Promise<void>}
598 */
599export async function delay(time) {
600  return new Promise((resolve) => {
601    setTimeout(resolve, time);
602  });
603}
604
605/**
606 * Adds a color theme class to a block element within HTML string.
607 * Parses HTML, finds block by class, adds theme class, returns modified HTML.
608 * @param {string} blockHTML - HTML string containing the block.
609 * @param {string} blockClass - The block's class name to target.
610 * @param {string} collectionColorTheme - Color theme class to add.
611 * @returns {string} Modified HTML string with theme class added.
612 */
613export function addThemingToBlock(blockHTML, blockClass, collectionColorTheme, withSurface = true) {
614  const template = document.createElement('template');
615  template.innerHTML = blockHTML;
616  const blockElement = template.content.querySelector(`.${blockClass}`);
617  if (blockElement) {
618    blockElement.classList.add(`${collectionColorTheme}`);
619    if (!withSurface) {
620      blockElement.classList.add(`no-surface`);
621    }
622  }
623  return template.innerHTML;
624}
625
626/**
627 * Parses metadata from <head> meta tags
628 * @param {Document} doc - The parsed HTML document
629 * @returns {object} Metadata key-value pairs
630 */
631function parseMetaTagsFromHead(doc) {
632  if (!doc) return {};
633
634  const metadata = { ...(doc.title && { title: doc.title }) };
635
636  // Get all meta tags with name or property attributes
637  doc.querySelectorAll('head meta[name], head meta[property]').forEach((tag) => {
638    const key = tag.getAttribute('name') || tag.getAttribute('property');
639    const content = tag.getAttribute('content');
640    if (key && content) metadata[key] = content;
641  });
642
643  return metadata;
644}
645
646/**
647 * Fetches a page and extracts metadata from meta tags in head
648 * @param {string} pagePath - Path to the page (e.g., '/global/404')
649 * @returns {Promise<object>} Object with success flag and metadata object
650 */
651export async function fetchPageWithMetadata(pagePath) {
652  try {
653    const response = await fetch(pagePath);
654    if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
655
656    const html = await response.text();
657    const doc = new DOMParser().parseFromString(html, 'text/html');
658
659    return { success: true, metadata: parseMetaTagsFromHead(doc) };
660  } catch (error) {
661    console.error('Error fetching page with metadata:', error);
662    return { success: false, metadata: {} };
663  }
664}
665
666/**
667 * Applies metadata to page head (updates document.title and meta tags)
668 * @param {object} metadata - Metadata key-value pairs
669 */
670export function applyMetadataToHead(metadata) {
671  if (!metadata) return;
672
673  // Update title
674  if (metadata.title) document.title = metadata.title;
675
676  // Update meta tags
677  Object.entries(metadata).forEach(([key, value]) => {
678    if (key !== 'title' && value?.trim()) {
679      const attr = key.includes(':') ? 'property' : 'name';
680      const attrValue = key.includes(':') ? key : key.toLowerCase();
681      const selector = `meta[${attr}="${attrValue}"]`;
682
683      const existingMeta = document.head.querySelector(selector);
684      if (existingMeta) {
685        existingMeta.setAttribute('content', value);
686      } else {
687        const meta = document.createElement('meta');
688        meta.setAttribute(attr, attrValue);
689        meta.setAttribute('content', value);
690        document.head.appendChild(meta);
691      }
692    }
693  });
694}

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.