Skip to content

Commit

Permalink
Merge pull request #307 from hlxsites/plugin-system
Browse files Browse the repository at this point in the history
feat: add the plugin system to the project
  • Loading branch information
davenichols-DHLS committed Nov 1, 2023
2 parents f054aad + 25cdd9d commit 122f923
Show file tree
Hide file tree
Showing 3 changed files with 254 additions and 30 deletions.
1 change: 1 addition & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ scripts/at-lsig.js
dist/
node_modules/
scripts/lib-accessibe.js
plugins/
268 changes: 240 additions & 28 deletions scripts/lib-franklin.js
Original file line number Diff line number Diff line change
@@ -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");
Expand All @@ -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) => {
Expand All @@ -28,32 +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);
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, sampleRUM, sanitizeURL: urlSanitizers[window.hlx.RUM_MASK_URL || 'path'] };
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.hlx.rum.sanitizeURL(), 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);
Expand All @@ -71,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
Expand Down Expand Up @@ -154,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;
}, {});
}

const ICONS_CACHE = {};
/**
* Replace icons with inline SVG and prefix with codeBasePath.
Expand Down Expand Up @@ -420,6 +481,53 @@ export function buildBlock(blockName, content) {
return (blockEl);
}

/**
* 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);
}

/**
* 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
Expand All @@ -428,23 +536,9 @@ export async function loadBlock(block) {
const status = block.dataset.blockStatus;
if (status !== 'loading' && status !== 'loaded') {
block.dataset.blockStatus = 'loading';
const { blockName } = block.dataset;
const { blockName, cssPath, jsPath } = getBlockConfig(block);
try {
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([decorationComplete]);
await loadModule(blockName, jsPath, cssPath, block);
} catch (error) {
// eslint-disable-next-line no-console
console.log(`failed to load block ${blockName}`, error);
Expand Down Expand Up @@ -627,6 +721,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.
*/
Expand All @@ -635,6 +844,9 @@ export function setup() {
window.hlx.RUM_MASK_URL = 'full';
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) {
Expand Down
15 changes: 13 additions & 2 deletions scripts/scripts.js
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,9 @@ async function decorateTemplates(main) {
async function loadEager(doc) {
document.documentElement.lang = 'en';
decorateTemplateAndTheme();

await window.hlx.plugins.run('loadEager');

const main = doc.querySelector('main');
if (main) {
await decorateTemplates(main);
Expand Down Expand Up @@ -232,6 +235,8 @@ async function loadLazy(doc) {
loadCSS(`${window.hlx.codeBasePath}/styles/lazy-styles.css`);
loadFonts();

window.hlx.plugins.run('loadLazy');

sampleRUM('lazy');
sampleRUM.observe(main.querySelectorAll('div[data-block-name]'));
sampleRUM.observe(main.querySelectorAll('picture > img'));
Expand All @@ -244,8 +249,12 @@ async function loadLazy(doc) {
* without impacting the user experience.
*/
function loadDelayed() {
// 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
}

Expand Down Expand Up @@ -314,7 +323,9 @@ export function getAuthorization() {
}

async function loadPage() {
await window.hlx.plugins.load('eager');
await loadEager(document);
await window.hlx.plugins.load('lazy');
await loadLazy(document);
loadDelayed();
}
Expand Down

0 comments on commit 122f923

Please sign in to comment.