From 723df253ec65f980da45ca69d2e7002e2f156544 Mon Sep 17 00:00:00 2001 From: Julien Ramboz Date: Wed, 11 Oct 2023 16:14:34 +0200 Subject: [PATCH 1/2] chore: update fstab --- fstab.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fstab.yaml b/fstab.yaml index 40ef9de12c..c54f824631 100644 --- a/fstab.yaml +++ b/fstab.yaml @@ -1,2 +1,2 @@ mountpoints: - /: https://drive.google.com/drive/u/0/folders/1MGzOt7ubUh3gu7zhZIPb7R7dyRzG371j \ No newline at end of file + /: https://drive.google.com/drive/u/0/folders/1MGzOt7ubUh3gu7zhZIPb7R7dyRzG371j From 86d0ec5a6f03383b603f169d53cb97e4b2c4548a Mon Sep 17 00:00:00 2001 From: Julien Ramboz Date: Tue, 31 Oct 2023 15:43:39 +0100 Subject: [PATCH 2/2] feat: add the plugin system to the project --- scripts/aem.js | 356 ++++++++++++++++++++++++++++++++------------- scripts/scripts.js | 21 ++- 2 files changed, 273 insertions(+), 104 deletions(-) diff --git a/scripts/aem.js b/scripts/aem.js index af8f44a9e3..1e53171e76 100644 --- a/scripts/aem.js +++ b/scripts/aem.js @@ -10,6 +10,7 @@ * governing permissions and limitations under the License. */ +/* eslint-disable max-classes-per-file */ /* eslint-env browser */ /** @@ -128,45 +129,6 @@ function sampleRUM(checkpoint, data = {}) { } } -/** - * Setup block utils. - */ -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'; - - 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); - } - } -} - -/** - * 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 }); - }); -} - /** * Sanitizes a string for use as class name. * @param {string} name The unsanitized string @@ -191,50 +153,6 @@ function toCamelCase(name) { return toClassName(name).replace(/-([a-z])/g, (g) => g[1].toUpperCase()); } -/** - * Extracts the config from a block. - * @param {Element} block The block element - * @returns {object} The block config - */ -// eslint-disable-next-line import/prefer-default-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; -} - /** * Loads a CSS file. * @param {string} href URL to the CSS file @@ -279,6 +197,33 @@ async function loadScript(src, attrs) { }); } +/** + * Loads JS and CSS for a module and executes it's default export. + * @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(jsPath, cssPath, ...args) { + const cssLoaded = cssPath ? loadCSS(cssPath) : Promise.resolve(); + const decorationComplete = jsPath + ? new Promise((resolve, reject) => { + (async () => { + let mod; + try { + mod = await import(jsPath); + if (mod.default) { + await mod.default.apply(null, args); + } + } catch (error) { + reject(error); + } + resolve(mod); + })(); + }) + : Promise.resolve(); + return Promise.all([cssLoaded, decorationComplete]).then(([, api]) => api); +} + /** * Retrieves the content of metadata tags. * @param {string} name The metadata name (or property) @@ -355,6 +300,213 @@ function decorateTemplateAndTheme() { if (theme) addClasses(document.body, theme); } +class PluginsRegistry { + #plugins; + + static 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 }; + } + + constructor() { + this.#plugins = new Map(); + } + + // Register a new plugin + add(id, config) { + const { id: pluginId, config: pluginConfig } = PluginsRegistry.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, context) { + [...this.#plugins.entries()] + .filter( + ([, plugin]) => plugin.condition && !plugin.condition(document, plugin.options, context), + ) + .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, context)) + && 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( + !plugin.url.endsWith('.js') + ? `${plugin.url}/${plugin.url.split('/').pop()}.js` + : plugin.url, + !plugin.url.endsWith('.js') + ? `${plugin.url}/${plugin.url.split('/').pop()}.css` + : null, + document, + plugin.options, + context, + )) || {}; + this.#plugins.set(key, { ...plugin, ...pluginApi }); + } catch (err) { + // eslint-disable-next-line no-console + console.error('Could not load specified plugin', key); + this.#plugins.delete(key); + } + }), + ); + } + + // Run a specific phase in the plugin + async run(phase, context) { + return [...this.#plugins.values()].reduce( + ( + promise, + p, // Using reduce to execute plugins sequencially + ) => (p[phase] && (!p.condition || p.condition(document, p.options, context)) + ? promise.then(() => p[phase](document, p.options, context)) + : 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) => this.add(i)); + return; + } + const { id: templateId, config: templateConfig } = PluginsRegistry.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. + */ +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 = []; + window.hlx.plugins = new PluginsRegistry(); + window.hlx.templates = new TemplatesRegistry(); + + 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); + } + } +} + +/** + * 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 }); + }); +} + +/** + * Extracts the config from a block. + * @param {Element} block The block element + * @returns {object} The block config + */ +// eslint-disable-next-line import/prefer-default-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 paragraphs containing a single link as buttons. * @param {Element} element container element @@ -552,6 +704,23 @@ 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 block. * @param {Element} block The block element @@ -560,26 +729,9 @@ 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( - `${window.hlx.codeBasePath}/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(jsPath, cssPath, block); } catch (error) { // eslint-disable-next-line no-console console.log(`failed to load block ${blockName}`, error); diff --git a/scripts/scripts.js b/scripts/scripts.js index 0211c1dd34..0850e0111c 100644 --- a/scripts/scripts.js +++ b/scripts/scripts.js @@ -15,6 +15,12 @@ import { const LCP_BLOCKS = []; // add your LCP blocks to the list +// Add you templates below +// window.hlx.templates.add('/templates/my-template'); + +// Add you plugins below +// window.hlx.plugins.add('/plugins/my-plugin.js'); + /** * Builds hero block and prepends to main in a new section. * @param {Element} main The container element @@ -76,6 +82,9 @@ export function decorateMain(main) { async function loadEager(doc) { document.documentElement.lang = 'en'; decorateTemplateAndTheme(); + + await window.hlx.plugins.run('loadEager'); + const main = doc.querySelector('main'); if (main) { decorateMain(main); @@ -111,6 +120,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')); @@ -121,13 +132,19 @@ 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 } async function loadPage() { + await window.hlx.plugins.load('eager'); await loadEager(document); + await window.hlx.plugins.load('lazy'); await loadLazy(document); loadDelayed(); }