diff --git a/blocks/breadcrumbs/breadcrumbs-create.js b/blocks/breadcrumbs/breadcrumbs-create.js index ddbbc64c9..c56e36bdc 100644 --- a/blocks/breadcrumbs/breadcrumbs-create.js +++ b/blocks/breadcrumbs/breadcrumbs-create.js @@ -160,9 +160,7 @@ function getName(pageIndex, path, part, current) { } export default async function createBreadcrumbs(container) { - const breadCrumbsCSS = new Promise((resolve) => { - loadCSS('/blocks/breadcrumbs/breadcrumbs.css', (e) => resolve(e)); - }); + const breadCrumbsCSS = loadCSS('/blocks/breadcrumbs/breadcrumbs.css'); const path = window.location.pathname; const pathSplit = skipParts(path.split('/')); diff --git a/blocks/card/card.js b/blocks/card/card.js index 77525d91f..9b2395378 100644 --- a/blocks/card/card.js +++ b/blocks/card/card.js @@ -163,15 +163,11 @@ class Card { } async loadCSSFiles() { - let defaultCSSPromise; + let defaultCSSPromise = Promise.resolve(); if (Array.isArray(this.cssFiles) && this.cssFiles.length > 0) { - defaultCSSPromise = new Promise((resolve) => { - this.cssFiles.forEach((cssFile) => { - loadCSS(cssFile, (e) => resolve(e)); - }); - }); + defaultCSSPromise = Promise.all(this.cssFiles.map(loadCSS)); } - this.cssFiles && (await defaultCSSPromise); + return defaultCSSPromise; } } diff --git a/blocks/carousel/carousel.js b/blocks/carousel/carousel.js index 7ffb9bba3..22d2b36dd 100644 --- a/blocks/carousel/carousel.js +++ b/blocks/carousel/carousel.js @@ -397,11 +397,7 @@ class Carousel { let defaultCSSPromise; if (Array.isArray(this.cssFiles) && this.cssFiles.length > 0) { // add default carousel classes to apply default CSS - defaultCSSPromise = new Promise((resolve) => { - this.cssFiles.forEach((cssFile) => { - loadCSS(cssFile, (e) => resolve(e)); - }); - }); + defaultCSSPromise = Promise.all(this.cssFiles.map(loadCSS)); this.block.parentElement.classList.add('carousel-wrapper'); this.block.classList.add('carousel'); } diff --git a/scripts/lib-franklin.js b/scripts/lib-franklin.js index abc9d827a..570eea306 100644 --- a/scripts/lib-franklin.js +++ b/scripts/lib-franklin.js @@ -1,3 +1,4 @@ +/* eslint-disable max-classes-per-file */ /* * Copyright 2022 Adobe. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); @@ -18,8 +19,7 @@ export function sampleRUM(checkpoint, data = {}) { sampleRUM.defer = sampleRUM.defer || []; const defer = (fnname) => { - sampleRUM[fnname] = sampleRUM[fnname] - || ((...args) => sampleRUM.defer.push({ fnname, args })); + sampleRUM[fnname] = sampleRUM[fnname] || ((...args) => sampleRUM.defer.push({ fnname, args })); }; sampleRUM.drain = sampleRUM.drain || ((dfnname, fn) => { @@ -28,27 +28,72 @@ export function sampleRUM(checkpoint, data = {}) { .filter(({ fnname }) => dfnname === fnname) .forEach(({ fnname, args }) => sampleRUM[fnname](...args)); }); - sampleRUM.on = (chkpnt, fn) => { sampleRUM.cases[chkpnt] = fn; }; + sampleRUM.always = sampleRUM.always || []; + sampleRUM.always.on = (chkpnt, fn) => { + sampleRUM.always[chkpnt] = fn; + }; + sampleRUM.on = (chkpnt, fn) => { + sampleRUM.cases[chkpnt] = fn; + }; defer('observe'); defer('cwv'); try { window.hlx = window.hlx || {}; if (!window.hlx.rum) { const usp = new URLSearchParams(window.location.search); - const weight = (usp.get('rum') === 'on') ? 1 : 100; // with parameter, weight is 1. Defaults to 100. - // eslint-disable-next-line no-bitwise - const hashCode = (s) => s.split('').reduce((a, b) => (((a << 5) - a) + b.charCodeAt(0)) | 0, 0); - const id = `${hashCode(window.location.href)}-${new Date().getTime()}-${Math.random().toString(16).substr(2, 14)}`; + const weight = usp.get('rum') === 'on' ? 1 : 100; // with parameter, weight is 1. Defaults to 100. + const id = Array.from({ length: 75 }, (_, i) => String.fromCharCode(48 + i)) + .filter((a) => /\d|[A-Z]/i.test(a)) + .filter(() => Math.random() * 75 > 70) + .join(''); const random = Math.random(); - const isSelected = (random * weight < 1); - // eslint-disable-next-line object-curly-newline - window.hlx.rum = { weight, id, random, isSelected, sampleRUM }; + const isSelected = random * weight < 1; + const firstReadTime = Date.now(); + const urlSanitizers = { + full: () => window.location.href, + origin: () => window.location.origin, + path: () => window.location.href.replace(/\?.*$/, ''), + }; + // eslint-disable-next-line object-curly-newline, max-len + window.hlx.rum = { + weight, + id, + random, + isSelected, + firstReadTime, + sampleRUM, + sanitizeURL: urlSanitizers[window.hlx.RUM_MASK_URL || 'path'], + }; } - const { weight, id } = window.hlx.rum; + const { weight, id, firstReadTime } = window.hlx.rum; if (window.hlx && window.hlx.rum && window.hlx.rum.isSelected) { + const knownProperties = [ + 'weight', + 'id', + 'referer', + 'checkpoint', + 't', + 'source', + 'target', + 'cwv', + 'CLS', + 'FID', + 'LCP', + 'INP', + ]; const sendPing = (pdata = data) => { // eslint-disable-next-line object-curly-newline, max-len, no-use-before-define - const body = JSON.stringify({ weight, id, referer: window.location.href, generation: window.hlx.RUM_GENERATION, checkpoint, ...data }); + const body = JSON.stringify( + { + weight, + id, + referer: window.hlx.rum.sanitizeURL(), + checkpoint, + t: Date.now() - firstReadTime, + ...data, + }, + knownProperties, + ); const url = `https://rum.hlx.page/.rum/${weight}`; // eslint-disable-next-line no-unused-expressions navigator.sendBeacon(url, body); @@ -66,7 +111,12 @@ export function sampleRUM(checkpoint, data = {}) { }, }; sendPing(data); - if (sampleRUM.cases[checkpoint]) { sampleRUM.cases[checkpoint](); } + if (sampleRUM.cases[checkpoint]) { + sampleRUM.cases[checkpoint](); + } + } + if (sampleRUM.always[checkpoint]) { + sampleRUM.always[checkpoint](data); } } catch (error) { // something went wrong @@ -75,21 +125,47 @@ export function sampleRUM(checkpoint, data = {}) { /** * Loads a CSS file. - * @param {string} href The path to the CSS file - */ -export function loadCSS(href, callback) { - if (!document.querySelector(`head > link[href="${href}"]`)) { - const link = document.createElement('link'); - link.setAttribute('rel', 'stylesheet'); - link.setAttribute('href', href); - if (typeof callback === 'function') { - link.onload = (e) => callback(e.type); - link.onerror = (e) => callback(e.type); + * @param {string} href URL to the CSS file + */ +export async function loadCSS(href) { + return new Promise((resolve, reject) => { + if (!document.querySelector(`head > link[href="${href}"]`)) { + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = href; + link.onload = resolve; + link.onerror = reject; + document.head.append(link); + } else { + resolve(); } - document.head.appendChild(link); - } else if (typeof callback === 'function') { - callback('noop'); - } + }); +} + +/** + * Loads a non module JS file. + * @param {string} src URL to the JS file + * @param {Object} attrs additional optional attributes + */ + +export async function loadScript(src, attrs) { + return new Promise((resolve, reject) => { + if (!document.querySelector(`head > script[src="${src}"]`)) { + const script = document.createElement('script'); + script.src = src; + if (attrs) { + // eslint-disable-next-line no-restricted-syntax, guard-for-in + for (const attr in attrs) { + script.setAttribute(attr, attrs[attr]); + } + } + script.onload = resolve; + script.onerror = reject; + document.head.append(script); + } else { + resolve(); + } + }); } /** @@ -123,6 +199,22 @@ export function toCamelCase(name) { return toClassName(name).replace(/-([a-z])/g, (g) => g[1].toUpperCase()); } +/** + * Gets all the metadata elements that are in the given scope. + * @param {String} scope The scope/prefix for the metadata + * @returns an array of HTMLElement nodes that match the given scope + */ +export function getAllMetadata(scope) { + return [...document.head.querySelectorAll(`meta[property^="${scope}:"],meta[name^="${scope}-"]`)] + .reduce((res, meta) => { + const id = toClassName(meta.name + ? meta.name.substring(scope.length + 1) + : meta.getAttribute('property').split(':')[1]); + res[id] = meta.getAttribute('content'); + return res; + }, {}); +} + /* * Sanitizes the onelink class names to show/hide regional data. * @param {string} name The unsanitized name @@ -439,39 +531,69 @@ export function buildBlock(blockName, content) { return (blockEl); } +/** + * Gets the configuration for the given block, and also passes + * the config through all custom patching helpers added to the project. + * + * @param {Element} block The block element + * @returns {Object} The block config (blockName, cssPath and jsPath) + */ +function getBlockConfig(block) { + const { blockName } = block.dataset; + const cssPath = `${window.hlx.codeBasePath}/blocks/${blockName}/${blockName}.css`; + const jsPath = `${window.hlx.codeBasePath}/blocks/${blockName}/${blockName}.js`; + const original = { blockName, cssPath, jsPath }; + return (window.hlx.patchBlockConfig || []) + .filter((fn) => typeof fn === 'function') + .reduce((config, fn) => fn(config, original), { blockName, cssPath, jsPath }); +} + +/** + * Loads JS and CSS for a module and executes it's default export. + * @param {string} name The module name + * @param {string} jsPath The JS file to load + * @param {string} [cssPath] An optional CSS file to load + * @param {object[]} [args] Parameters to be passed to the default export when it is called + */ +async function loadModule(name, jsPath, cssPath, ...args) { + const cssLoaded = cssPath ? loadCSS(cssPath) : Promise.resolve(); + const decorationComplete = jsPath + ? new Promise((resolve) => { + (async () => { + let mod; + try { + mod = await import(jsPath); + if (mod.default) { + await mod.default.apply(null, args); + } + } catch (error) { + // eslint-disable-next-line no-console + console.log(`failed to load module for ${name}`, error); + } + resolve(mod); + })(); + }) + : Promise.resolve(); + return Promise.all([cssLoaded, decorationComplete]) + .then(([, api]) => api); +} + /** * Loads JS and CSS for a block. * @param {Element} block The block element */ export async function loadBlock(block) { - const status = block.getAttribute('data-block-status'); + const status = block.dataset.blockStatus; if (status !== 'loading' && status !== 'loaded') { - block.setAttribute('data-block-status', 'loading'); - const blockName = block.getAttribute('data-block-name'); + block.dataset.blockStatus = 'loading'; + const { blockName, cssPath, jsPath } = getBlockConfig(block); try { - const cssLoaded = new Promise((resolve) => { - loadCSS(`${window.hlx.codeBasePath}/blocks/${blockName}/${blockName}.css`, resolve); - }); - const decorationComplete = new Promise((resolve) => { - (async () => { - try { - const mod = await import(`../blocks/${blockName}/${blockName}.js`); - if (mod.default) { - await mod.default(block); - } - } catch (error) { - // eslint-disable-next-line no-console - console.log(`failed to load module for ${blockName}`, error); - } - resolve(); - })(); - }); - await Promise.all([cssLoaded, decorationComplete]); + await loadModule(blockName, jsPath, cssPath, block); } catch (error) { // eslint-disable-next-line no-console console.log(`failed to load block ${blockName}`, error); } - block.setAttribute('data-block-status', 'loaded'); + block.dataset.blockStatus = 'loaded'; } } @@ -663,6 +785,121 @@ export function loadFooter(footer) { return loadBlock(footerBlock); } +function parsePluginParams(id, config) { + const pluginId = !config + ? id.split('/').splice(id.endsWith('/') ? -2 : -1, 1)[0].replace(/\.js/, '') + : id; + const pluginConfig = { + load: 'eager', + ...(typeof config === 'string' || !config + ? { url: (config || id).replace(/\/$/, '') } + : config), + }; + pluginConfig.options ||= {}; + return { id: pluginId, config: pluginConfig }; +} + +// Define an execution context for plugins +export const executionContext = { + createOptimizedPicture, + getAllMetadata, + getMetadata, + decorateBlock, + decorateButtons, + decorateIcons, + loadBlock, + loadCSS, + loadScript, + sampleRUM, + toCamelCase, + toClassName, +}; + +class PluginsRegistry { + #plugins; + + constructor() { + this.#plugins = new Map(); + } + + // Register a new plugin + add(id, config) { + const { id: pluginId, config: pluginConfig } = parsePluginParams(id, config); + this.#plugins.set(pluginId, pluginConfig); + } + + // Get the plugin + get(id) { return this.#plugins.get(id); } + + // Check if the plugin exists + includes(id) { return !!this.#plugins.has(id); } + + // Load all plugins that are referenced by URL, and updated their configuration with the + // actual API they expose + async load(phase) { + [...this.#plugins.entries()] + .filter(([, plugin]) => plugin.condition + && !plugin.condition(document, plugin.options, executionContext)) + .map(([id]) => this.#plugins.delete(id)); + return Promise.all([...this.#plugins.entries()] + // Filter plugins that don't match the execution conditions + .filter(([, plugin]) => ( + (!plugin.condition || plugin.condition(document, plugin.options, executionContext)) + && phase === plugin.load && plugin.url + )) + .map(async ([key, plugin]) => { + try { + // If the plugin has a default export, it will be executed immediately + const pluginApi = (await loadModule( + key, + !plugin.url.endsWith('.js') ? `${plugin.url}/${key}.js` : plugin.url, + !plugin.url.endsWith('.js') ? `${plugin.url}/${key}.css` : null, + document, + plugin.options, + executionContext, + )) || {}; + this.#plugins.set(key, { ...plugin, ...pluginApi }); + } catch (err) { + // eslint-disable-next-line no-console + console.error('Could not load specified plugin', key); + } + })); + } + + // Run a specific phase in the plugin + async run(phase) { + return [...this.#plugins.values()] + .reduce((promise, plugin) => ( // Using reduce to execute plugins sequencially + plugin[phase] && (!plugin.condition + || plugin.condition(document, plugin.options, executionContext)) + ? promise.then(() => plugin[phase](document, plugin.options, executionContext)) + : promise + ), Promise.resolve()); + } +} + +class TemplatesRegistry { + // Register a new template + // eslint-disable-next-line class-methods-use-this + add(id, url) { + if (Array.isArray(id)) { + id.forEach((i) => window.hlx.templates.add(i)); + return; + } + const { id: templateId, config: templateConfig } = parsePluginParams(id, url); + templateConfig.condition = () => toClassName(getMetadata('template')) === templateId; + window.hlx.plugins.add(templateId, templateConfig); + } + + // Get the template + // eslint-disable-next-line class-methods-use-this + get(id) { return window.hlx.plugins.get(id); } + + // Check if the template exists + // eslint-disable-next-line class-methods-use-this + includes(id) { return window.hlx.plugins.includes(id); } +} + /** * setup block utils */ @@ -670,6 +907,9 @@ export function setup() { window.hlx = window.hlx || {}; window.hlx.codeBasePath = ''; window.hlx.lighthouse = new URLSearchParams(window.location.search).get('lighthouse') === 'on'; + window.hlx.patchBlockConfig = []; + window.hlx.plugins = new PluginsRegistry(); + window.hlx.templates = new TemplatesRegistry(); const scriptEl = document.querySelector('script[src$="/scripts/scripts.js"]'); if (scriptEl) { diff --git a/scripts/list.js b/scripts/list.js index b654f3f72..097fcc87b 100644 --- a/scripts/list.js +++ b/scripts/list.js @@ -309,9 +309,7 @@ export async function createList( options, root, ) { - const listCSSPromise = new Promise((resolve) => { - loadCSS('../styles/list.css', (e) => resolve(e)); - }); + const listCSSPromise = loadCSS('../styles/list.css'); if (options.data) { const container = div({ class: 'list' }, diff --git a/scripts/scripts.js b/scripts/scripts.js index ae9b58104..6981a6a0a 100644 --- a/scripts/scripts.js +++ b/scripts/scripts.js @@ -35,6 +35,7 @@ const TEMPLATE_LIST = [ 'newsroom', 'landing-page', ]; +window.hlx.templates.add(TEMPLATE_LIST.map((tpl) => `/templates/${tpl}`)); const LCP_BLOCKS = ['hero', 'hero-advanced', 'featured-highlights']; // add your LCP blocks to the list const SUPPORT_CHANNELS = ['DISTRIBUTOR', 'INTEGRATOR', 'SALES', 'TECH']; @@ -416,27 +417,6 @@ function decorateLinkedPictures(container) { }); } -/** - * Run template specific decoration code. - * @param {Element} main The container element - */ -async function decorateTemplates(main) { - try { - const template = toClassName(getMetadata('template')); - const templates = TEMPLATE_LIST; - if (templates.includes(template)) { - const mod = await import(`../templates/${template}/${template}.js`); - loadCSS(`${window.hlx.codeBasePath}/templates/${template}/${template}.css`); - if (mod.default) { - await mod.default(main); - } - } - } catch (error) { - // eslint-disable-next-line no-console - console.error('Auto Blocking failed', error); - } -} - function addPageSchema() { if (document.querySelector('head > script[type="application/ld+json"]')) return; @@ -785,7 +765,7 @@ async function loadEager(doc) { decorateTemplateAndTheme(); const main = doc.querySelector('main'); if (main) { - await decorateTemplates(main); + await window.hlx.plugins.run('loadEager'); await decorateMain(main); createBreadcrumbsSpace(main); await waitForLCP(LCP_BLOCKS); @@ -963,13 +943,16 @@ async function loadLazy(doc) { loadBreadcrumbs(main); loadCSS(`${window.hlx.codeBasePath}/styles/lazy-styles.css`); - loadCSS(`${window.hlx.codeBasePath}/styles/fonts.css`, () => { + loadCSS(`${window.hlx.codeBasePath}/styles/fonts.css`).then(() => { try { if (!window.location.hostname.includes('localhost')) sessionStorage.setItem('fonts-loaded', 'true'); } catch (e) { // do nothing } }); + + window.hlx.plugins.run('loadLazy'); + sampleRUM('lazy'); sampleRUM.observe(main.querySelectorAll('div[data-block-name]')); sampleRUM.observe(main.querySelectorAll('picture > img')); @@ -982,8 +965,12 @@ async function loadLazy(doc) { */ function loadDelayed() { if (!window.location.pathname.startsWith('/cp-request')) { - // eslint-disable-next-line import/no-cycle - window.setTimeout(() => import('./delayed.js'), 3000); + window.setTimeout(() => { + window.hlx.plugins.load('delayed'); + window.hlx.plugins.run('loadDelayed'); + // eslint-disable-next-line import/no-cycle + return import('./delayed.js'); + }, 3000); } // load anything that can be postponed to the latest here } @@ -1105,7 +1092,9 @@ export function detectAnchor(block) { } async function loadPage() { + await window.hlx.plugins.load('eager'); await loadEager(document); + await window.hlx.plugins.load('lazy'); await loadLazy(document); loadDelayed(); } diff --git a/templates/compare-items/compare-banner.js b/templates/compare-items/compare-banner.js index cefc8d201..937d6d961 100644 --- a/templates/compare-items/compare-banner.js +++ b/templates/compare-items/compare-banner.js @@ -165,16 +165,11 @@ class CompareBanner { } async loadCSSFiles() { - let defaultCSSPromise; + let defaultCSSPromise = Promise.resolve(); if (Array.isArray(this.cssFiles) && this.cssFiles.length > 0) { - defaultCSSPromise = new Promise((resolve) => { - this.cssFiles.forEach((cssFile) => { - loadCSS(cssFile, (e) => resolve(e)); - }); - }); + defaultCSSPromise = Promise.all(this.cssFiles.map(loadCSS)); } - // eslint-disable-next-line no-unused-expressions - this.cssFiles && (await defaultCSSPromise); + return defaultCSSPromise; } } diff --git a/templates/compare-items/compare-modal.js b/templates/compare-items/compare-modal.js index aa711a8c7..eec6d9285 100644 --- a/templates/compare-items/compare-modal.js +++ b/templates/compare-items/compare-modal.js @@ -480,15 +480,11 @@ class CompareModal { } async loadCSSFiles() { - let defaultCSSPromise; + let defaultCSSPromise = Promise.resolve(); if (Array.isArray(this.cssFiles) && this.cssFiles.length > 0) { - defaultCSSPromise = new Promise((resolve) => { - this.cssFiles.forEach((cssFile) => { - loadCSS(cssFile, (e) => resolve(e)); - }); - }); + defaultCSSPromise = Promise.all(this.cssFiles.map(loadCSS)); } - this.cssFiles && (await defaultCSSPromise); + return defaultCSSPromise; } } diff --git a/test/scripts/block-utils.test.js b/test/scripts/block-utils.test.js index 6b77902cc..a577e6211 100644 --- a/test/scripts/block-utils.test.js +++ b/test/scripts/block-utils.test.js @@ -34,23 +34,18 @@ describe('Utils methods', () => { it('Loads CSS', async () => { // loads a css file and calls callback - const load = await new Promise((resolve) => { - blockUtils.loadCSS('/test/scripts/test.css', (e) => resolve(e)); - }); - expect(load).to.equal('load'); + await blockUtils.loadCSS('/test/scripts/test.css'); + expect(document.querySelectorAll('link[href="/test/scripts/test.css"]').length).to.equal(1); expect(getComputedStyle(document.body).color).to.equal('rgb(255, 0, 0)'); // does nothing if css already loaded - const noop = await new Promise((resolve) => { - blockUtils.loadCSS('/test/scripts/test.css', (e) => resolve(e)); - }); - expect(noop).to.equal('noop'); + await blockUtils.loadCSS('/test/scripts/test.css'); + expect(document.querySelectorAll('link[href="/test/scripts/test.css"]').length).to.equal(1); // calls callback in case of error - const error = await new Promise((resolve) => { - blockUtils.loadCSS('/test/scripts/nope.css', (e) => resolve(e)); - }); - expect(error).to.equal('error'); + expect(async () => { + await blockUtils.loadCSS('/test/scripts/nope.css'); + }).to.throw; }); it('Collects RUM data', async () => { @@ -91,6 +86,10 @@ describe('Utils methods', () => { }); describe('Sections and blocks', () => { + before(async () => { + window.hlx.codeBasePath = ''; + }); + it('Decorates sections', async () => { blockUtils.decorateSections(document.querySelector('main')); expect(document.querySelectorAll('main .section').length).to.equal(2);