From bbacda9bd14019d411bdec45fede7a297b282c93 Mon Sep 17 00:00:00 2001 From: Julien Ramboz Date: Mon, 30 Oct 2023 15:48:30 +0100 Subject: [PATCH] feat: add the plugin system to the project --- solutions/scripts/lib-franklin.js | 201 +++++++++++++++++++++++++++--- solutions/scripts/scripts.js | 19 +-- 2 files changed, 195 insertions(+), 25 deletions(-) diff --git a/solutions/scripts/lib-franklin.js b/solutions/scripts/lib-franklin.js index 9915aff03..52487bf11 100644 --- a/solutions/scripts/lib-franklin.js +++ b/solutions/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"); @@ -160,6 +161,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. @@ -470,6 +487,53 @@ 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 @@ -478,24 +542,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 cssLoaded = loadCSS(`${window.hlx.codeBasePath}/blocks/${blockName}/${blockName}.css`); - 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); @@ -729,6 +778,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. */ @@ -737,6 +901,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$="/solutions/scripts/scripts.js"]'); if (scriptEl) { diff --git a/solutions/scripts/scripts.js b/solutions/scripts/scripts.js index d15947908..b6279a54b 100644 --- a/solutions/scripts/scripts.js +++ b/solutions/scripts/scripts.js @@ -12,7 +12,6 @@ import { loadBlocks, loadCSS, getMetadata, - toClassName, } from './lib-franklin.js'; import { @@ -30,6 +29,11 @@ export const DEFAULT_COUNTRY = 'au'; export const METADATA_ANAYTICS_TAGS = 'analytics-tags'; +window.hlx.plugins.add('rum-conversion', { + load: 'lazy', + url: '../plugins/rum-conversion/src/index.js', +}); + /** * Creates a meta tag with the given name and value and appends it to the head. * @param {String} name The name of the meta tag @@ -476,11 +480,6 @@ async function loadLazy(doc) { sampleRUM('lazy'); sampleRUM.observe(main.querySelectorAll('div[data-block-name]')); sampleRUM.observe(main.querySelectorAll('picture > img')); - - const context = { getMetadata, toClassName }; - // eslint-disable-next-line import/no-relative-packages - const { initConversionTracking } = await import('../plugins/rum-conversion/src/index.js'); - await initConversionTracking.call(context, document); } /** @@ -489,15 +488,19 @@ async function loadLazy(doc) { */ function loadDelayed() { window.setTimeout(() => { + window.hlx.plugins.load('delayed'); + window.hlx.plugins.run('loadDelayed'); + // load anything that can be postponed to the latest here // eslint-disable-next-line import/no-cycle - import('./delayed.js'); + return import('./delayed.js'); }, 3000); - // load anything that can be postponed to the latest here } async function loadPage() { pushPageLoadToDataLayer(); + await window.hlx.plugins.load('eager'); await loadEager(document); + await window.hlx.plugins.load('lazy'); await loadLazy(document); loadDelayed(); }