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.