scripts/lib-franklin.js (516 lines of code) (raw):

/* * Copyright 2022 Adobe. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software distributed under * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ /** * log RUM if part of the sample. * @param {string} checkpoint identifies the checkpoint in funnel * @param {Object} data additional data for RUM sample */ export function sampleRUM(checkpoint, data = {}) { sampleRUM.defer = sampleRUM.defer || []; const defer = (fnname) => { sampleRUM[fnname] = sampleRUM[fnname] || ((...args) => sampleRUM.defer.push({ fnname, args })); }; sampleRUM.drain = sampleRUM.drain || ((dfnname, fn) => { sampleRUM[dfnname] = fn; sampleRUM.defer .filter(({ fnname }) => dfnname === fnname) .forEach(({ fnname, args }) => sampleRUM[fnname](...args)); }); 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 random = Math.random(); const isSelected = (random * weight < 1); 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, sampleRUM, sanitizeURL: urlSanitizers[window.hlx.RUM_MASK_URL || 'path'] }; } const { weight, id } = window.hlx.rum; if (window.hlx && window.hlx.rum && window.hlx.rum.isSelected) { 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.hlx.rum.sanitizeURL(), checkpoint, ...data }); const url = `https://rum.hlx.page/.rum/${weight}`; // eslint-disable-next-line no-unused-expressions navigator.sendBeacon(url, body); // eslint-disable-next-line no-console console.debug(`ping:${checkpoint}`, pdata); }; sampleRUM.cases = sampleRUM.cases || { cwv: () => sampleRUM.cwv(data) || true, lazy: () => { // use classic script to avoid CORS issues const script = document.createElement('script'); script.src = 'https://rum.hlx.page/.rum/@adobe/helix-rum-enhancer@^1/src/index.js'; document.head.appendChild(script); return true; }, }; sendPing(data); if (sampleRUM.cases[checkpoint]) { sampleRUM.cases[checkpoint](); } } if (sampleRUM.always[checkpoint]) { sampleRUM.always[checkpoint](data); } } catch (error) { // something went wrong } } /** * Loads a CSS file. * @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(); } }); } /** * 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(); } }); } /** * Retrieves the content of metadata tags. * @param {string} name The metadata name (or property) * @returns {string} The metadata value(s) */ export function getMetadata(name) { const attr = name && name.includes(':') ? 'property' : 'name'; const meta = [...document.head.querySelectorAll(`meta[${attr}="${name}"]`)].map((m) => m.content).join(', '); return meta || ''; } /** * Sanitizes a string for use as class name. * @param {string} name The unsanitized string * @returns {string} The class name */ export function toClassName(name) { return typeof name === 'string' ? name.toLowerCase().replace(/[^0-9a-z]/gi, '-').replace(/-+/g, '-').replace(/^-|-$/g, '') : ''; } /** * Sanitizes a string for use as a js property name. * @param {string} name The unsanitized string * @returns {string} The camelCased name */ export function toCamelCase(name) { return toClassName(name).replace(/-([a-z])/g, (g) => g[1].toUpperCase()); } const ICONS_CACHE = {}; /** * Replace icons with inline SVG and prefix with codeBasePath. * @param {Element} [element] Element containing icons */ export async function decorateIcons(element) { // Prepare the inline sprite let svgSprite = document.getElementById('franklin-svg-sprite'); if (!svgSprite) { const div = document.createElement('div'); div.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" id="franklin-svg-sprite" style="display: none"></svg>'; svgSprite = div.firstElementChild; document.body.append(div.firstElementChild); } // Download all new icons const icons = [...element.querySelectorAll('span.icon')]; await Promise.all(icons.map(async (span) => { const iconName = Array.from(span.classList).find((c) => c.startsWith('icon-')).substring(5); if (!ICONS_CACHE[iconName]) { ICONS_CACHE[iconName] = true; try { const response = await fetch(`${window.hlx.codeBasePath}/icons/${iconName}.svg`); if (!response.ok) { ICONS_CACHE[iconName] = false; return; } // Styled icons don't play nice with the sprite approach because of shadow dom isolation // and same for internal references const svg = await response.text(); if (svg.match(/(<style | class=|url\(#| xlink:href="#)/)) { ICONS_CACHE[iconName] = { styled: true, html: svg // rescope ids and references to avoid clashes across icons; .replaceAll(/ id="([^"]+)"/g, (_, id) => ` id="${iconName}-${id}"`) .replaceAll(/="url\(#([^)]+)\)"/g, (_, id) => `="url(#${iconName}-${id})"`) .replaceAll(/ xlink:href="#([^"]+)"/g, (_, id) => ` xlink:href="#${iconName}-${id}"`), }; } else { ICONS_CACHE[iconName] = { html: svg .replace('<svg', `<symbol id="icons-sprite-${iconName}"`) .replace(/ width=".*?"/, '') .replace(/ height=".*?"/, '') .replace('</svg>', '</symbol>'), }; } } catch (error) { ICONS_CACHE[iconName] = false; // eslint-disable-next-line no-console console.error(error); } } })); const symbols = Object .keys(ICONS_CACHE).filter((k) => !svgSprite.querySelector(`#icons-sprite-${k}`)) .map((k) => ICONS_CACHE[k]) .filter((v) => !v.styled) .map((v) => v.html) .join('\n'); svgSprite.innerHTML += symbols; icons.forEach((span) => { const iconName = Array.from(span.classList).find((c) => c.startsWith('icon-')).substring(5); const parent = span.firstElementChild?.tagName === 'A' ? span.firstElementChild : span; // Styled icons need to be inlined as-is, while unstyled ones can leverage the sprite if (ICONS_CACHE[iconName].styled) { parent.innerHTML = ICONS_CACHE[iconName].html; } else { parent.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg"><use href="#icons-sprite-${iconName}"/></svg>`; } }); } /** * Gets placeholders object. * @param {string} [prefix] Location of placeholders * @returns {object} Window placeholders object */ export async function fetchPlaceholders(prefix = 'default') { window.placeholders = window.placeholders || {}; const loaded = window.placeholders[`${prefix}-loaded`]; if (!loaded) { window.placeholders[`${prefix}-loaded`] = new Promise((resolve, reject) => { fetch(`${prefix === 'default' ? '' : prefix}/placeholders.json`) .then((resp) => { if (resp.ok) { return resp.json(); } throw new Error(`${resp.status}: ${resp.statusText}`); }).then((json) => { const placeholders = {}; json.data .filter((placeholder) => placeholder.Key) .forEach((placeholder) => { placeholders[toCamelCase(placeholder.Key)] = placeholder.Text; }); window.placeholders[prefix] = placeholders; resolve(); }).catch((error) => { // error loading placeholders window.placeholders[prefix] = {}; reject(error); }); }); } await window.placeholders[`${prefix}-loaded`]; return window.placeholders[prefix]; } /** * Decorates a block. * @param {Element} block The block element */ export function decorateBlock(block) { const shortBlockName = block.classList[0]; if (shortBlockName) { block.classList.add('block'); block.dataset.blockName = shortBlockName; block.dataset.blockStatus = 'initialized'; const blockWrapper = block.parentElement; blockWrapper.classList.add(`${shortBlockName}-wrapper`); const section = block.closest('.section'); if (section) section.classList.add(`${shortBlockName}-container`); } } /** * Extracts the config from a block. * @param {Element} block The block element * @returns {object} The block config */ export function readBlockConfig(block) { const config = {}; block.querySelectorAll(':scope > div').forEach((row) => { if (row.children) { const cols = [...row.children]; if (cols[1]) { const col = cols[1]; const name = toClassName(cols[0].textContent); let value = ''; if (col.querySelector('a')) { const as = [...col.querySelectorAll('a')]; if (as.length === 1) { value = as[0].href; } else { value = as.map((a) => a.href); } } else if (col.querySelector('img')) { const imgs = [...col.querySelectorAll('img')]; if (imgs.length === 1) { value = imgs[0].src; } else { value = imgs.map((img) => img.src); } } else if (col.querySelector('p')) { const ps = [...col.querySelectorAll('p')]; if (ps.length === 1) { value = ps[0].textContent; } else { value = ps.map((p) => p.textContent); } } else value = row.children[1].textContent; config[name] = value; } } }); return config; } /** * Decorates all sections in a container element. * @param {Element} main The container element */ export function decorateSections(main) { main.querySelectorAll(':scope > div').forEach((section) => { const wrappers = []; let defaultContent = false; [...section.children].forEach((e) => { if (e.tagName === 'DIV' || !defaultContent) { const wrapper = document.createElement('div'); wrappers.push(wrapper); defaultContent = e.tagName !== 'DIV'; if (defaultContent) wrapper.classList.add('default-content-wrapper'); } wrappers[wrappers.length - 1].append(e); }); wrappers.forEach((wrapper) => section.append(wrapper)); section.classList.add('section'); section.dataset.sectionStatus = 'initialized'; section.style.display = 'none'; /* process section metadata */ const sectionMeta = section.querySelector('div.section-metadata'); if (sectionMeta) { const meta = readBlockConfig(sectionMeta); Object.keys(meta).forEach((key) => { if (key === 'style') { const styles = meta.style.split(',').map((style) => toClassName(style.trim())); styles.forEach((style) => section.classList.add(style)); } else { section.dataset[toCamelCase(key)] = meta[key]; } }); sectionMeta.parentNode.remove(); } }); } /** * Updates all section status in a container element. * @param {Element} main The container element */ export function updateSectionsStatus(main) { const sections = [...main.querySelectorAll(':scope > div.section')]; for (let i = 0; i < sections.length; i += 1) { const section = sections[i]; const status = section.dataset.sectionStatus; if (status !== 'loaded') { const loadingBlock = section.querySelector('.block[data-block-status="initialized"], .block[data-block-status="loading"]'); if (loadingBlock) { section.dataset.sectionStatus = 'loading'; break; } else { section.dataset.sectionStatus = 'loaded'; section.style.display = null; } } } } /** * Decorates all blocks in a container element. * @param {Element} main The container element */ export function decorateBlocks(main) { main .querySelectorAll('div.section > div > div') .forEach(decorateBlock); } /** * Builds a block DOM Element from a two dimensional array, string, or object * @param {string} blockName name of the block * @param {*} content two dimensional array or string or object of content */ export function buildBlock(blockName, content) { const table = Array.isArray(content) ? content : [[content]]; const blockEl = document.createElement('div'); // build image block nested div structure blockEl.classList.add(blockName); table.forEach((row) => { const rowEl = document.createElement('div'); row.forEach((col) => { const colEl = document.createElement('div'); const vals = col.elems ? col.elems : [col]; vals.forEach((val) => { if (val) { if (typeof val === 'string') { colEl.innerHTML += val; } else { colEl.appendChild(val); } } }); rowEl.appendChild(colEl); }); blockEl.appendChild(rowEl); }); 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 block. * @param {Element} block The block element */ export async function loadBlock(block) { const status = block.dataset.blockStatus; if (status !== 'loading' && status !== 'loaded') { block.dataset.blockStatus = 'loading'; const { blockName, cssPath, jsPath } = getBlockConfig(block); try { const cssLoaded = loadCSS(cssPath); const decorationComplete = new Promise((resolve) => { (async () => { try { const mod = await import(jsPath); 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]); } catch (error) { // eslint-disable-next-line no-console console.log(`failed to load block ${blockName}`, error); } block.dataset.blockStatus = 'loaded'; } } /** * Loads JS and CSS for all blocks in a container element. * @param {Element} main The container element */ export async function loadBlocks(main) { updateSectionsStatus(main); const blocks = [...main.querySelectorAll('div.block')]; for (let i = 0; i < blocks.length; i += 1) { // eslint-disable-next-line no-await-in-loop await loadBlock(blocks[i]); updateSectionsStatus(main); } } /** * Returns a picture element with webp and fallbacks * @param {string} src The image URL * @param {string} [alt] The image alternative text * @param {boolean} [eager] Set loading attribute to eager * @param {Array} [breakpoints] Breakpoints and corresponding params (eg. width) * @returns {Element} The picture element */ export function createOptimizedPicture(src, alt = '', eager = false, breakpoints = [{ media: '(min-width: 600px)', width: '2000' }, { width: '750' }]) { const url = new URL(src, window.location.href); const picture = document.createElement('picture'); const { pathname } = url; const ext = pathname.substring(pathname.lastIndexOf('.') + 1); // webp breakpoints.forEach((br) => { const source = document.createElement('source'); if (br.media) source.setAttribute('media', br.media); source.setAttribute('type', 'image/webp'); source.setAttribute('srcset', `${pathname}?width=${br.width}&format=webply&optimize=medium`); picture.appendChild(source); }); // fallback breakpoints.forEach((br, i) => { if (i < breakpoints.length - 1) { const source = document.createElement('source'); if (br.media) source.setAttribute('media', br.media); source.setAttribute('srcset', `${pathname}?width=${br.width}&format=${ext}&optimize=medium`); picture.appendChild(source); } else { const img = document.createElement('img'); img.setAttribute('loading', eager ? 'eager' : 'lazy'); img.setAttribute('alt', alt); picture.appendChild(img); img.setAttribute('src', `${pathname}?width=${br.width}&format=${ext}&optimize=medium`); } }); return picture; } /** * Normalizes all headings within a container element. * @param {Element} el The container element * @param {string} allowedHeadings The list of allowed headings (h1 ... h6) */ export function normalizeHeadings(el, allowedHeadings) { const allowed = allowedHeadings.map((h) => h.toLowerCase()); el.querySelectorAll('h1, h2, h3, h4, h5, h6').forEach((tag) => { const h = tag.tagName.toLowerCase(); if (allowed.indexOf(h) === -1) { // current heading is not in the allowed list -> try first to "promote" the heading let level = parseInt(h.charAt(1), 10) - 1; while (allowed.indexOf(`h${level}`) === -1 && level > 0) { level -= 1; } if (level === 0) { // did not find a match -> try to "downgrade" the heading while (allowed.indexOf(`h${level}`) === -1 && level < 7) { level += 1; } } if (level !== 7) { tag.outerHTML = `<h${level} id="${tag.id}">${tag.textContent}</h${level}>`; } } }); } /** * Set template (page structure) and theme (page styles). */ export function decorateTemplateAndTheme() { const addClasses = (element, classes) => { classes.split(',').forEach((c) => { element.classList.add(toClassName(c.trim())); }); }; const template = getMetadata('template'); if (template) addClasses(document.body, template); const theme = getMetadata('theme'); if (theme) addClasses(document.body, theme); } /** * Decorates paragraphs containing a single link as buttons. * @param {Element} element container element */ export function decorateButtons(element) { element.querySelectorAll('a').forEach((a) => { a.title = a.title || a.textContent; if (a.href !== a.textContent) { const up = a.parentElement; const twoup = a.parentElement.parentElement; if (!a.querySelector('img')) { if (up.childNodes.length === 1 && (up.tagName === 'P' || up.tagName === 'DIV')) { a.className = 'button primary'; // default up.classList.add('button-container'); } if (up.childNodes.length === 1 && up.tagName === 'STRONG' && twoup.childNodes.length === 1 && twoup.tagName === 'P') { a.className = 'button primary'; twoup.classList.add('button-container'); } if (up.childNodes.length === 1 && up.tagName === 'EM' && twoup.childNodes.length === 1 && twoup.tagName === 'P') { a.className = 'button secondary'; twoup.classList.add('button-container'); } } } }); } /** * Load LCP block and/or wait for LCP in default content. */ export async function waitForLCP(lcpBlocks) { const block = document.querySelector('.block'); const hasLCPBlock = (block && lcpBlocks.includes(block.dataset.blockName)); if (hasLCPBlock) await loadBlock(block); document.body.style.display = null; const lcpCandidate = document.querySelector('main img'); await new Promise((resolve) => { if (lcpCandidate && !lcpCandidate.complete) { lcpCandidate.setAttribute('loading', 'eager'); lcpCandidate.addEventListener('load', resolve); lcpCandidate.addEventListener('error', resolve); } else { resolve(); } }); } /** * Loads a block named 'header' into header * @param {Element} header header element * @returns {Promise} */ export function loadHeader(header) { const headerBlock = buildBlock('header', ''); header.append(headerBlock); decorateBlock(headerBlock); return loadBlock(headerBlock); } /** * Loads a block named 'footer' into footer * @param footer footer element * @returns {Promise} */ export function loadFooter(footer) { const footerBlock = buildBlock('footer', ''); footer.append(footerBlock); decorateBlock(footerBlock); return loadBlock(footerBlock); } /** * Setup block utils. */ export function setup() { window.hlx = window.hlx || {}; window.hlx.RUM_MASK_URL = 'full'; window.hlx.codeBasePath = ''; window.hlx.lighthouse = new URLSearchParams(window.location.search).get('lighthouse') === 'on'; window.hlx.patchBlockConfig = []; const scriptEl = document.querySelector('script[src$="/scripts/scripts.js"]'); if (scriptEl) { try { [window.hlx.codeBasePath] = new URL(scriptEl.src).pathname.split('/scripts/scripts.js'); } catch (error) { // eslint-disable-next-line no-console console.log(error); } } } /** * Wraps images followed by links within a matching <a> tag. * @param {Element} container The container element */ export function wrapImgsInLinks(container) { const pictures = container.querySelectorAll('picture'); pictures.forEach((pic) => { const link = pic.nextElementSibling; if (link && link.tagName === 'A' && link.href) { link.innerHTML = pic.outerHTML; pic.replaceWith(link); } }); } /** * Auto initializiation. */ function init() { setup(); sampleRUM('top'); window.addEventListener('load', () => sampleRUM('load')); window.addEventListener('unhandledrejection', (event) => { sampleRUM('error', { source: event.reason.sourceURL, target: event.reason.line }); }); window.addEventListener('error', (event) => { sampleRUM('error', { source: event.filename, target: event.lineno }); }); } init();