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();