Skip to content

Commit a1b1563

Browse files
authored
feat: add the plugin system to the project (#398)
The PR adds the plugin system proposed in adobe/aem-boilerplate#254 and updates the rum conversion instrumentation to leverage it. Test URLs: - Before: https://main--bitdefender--hlxsites.hlx.page/solutions/ - After: https://plugin-system--bitdefender--hlxsites.hlx.page/solutions/
1 parent 9bafcd9 commit a1b1563

File tree

2 files changed

+195
-25
lines changed

2 files changed

+195
-25
lines changed

solutions/scripts/lib-franklin.js

Lines changed: 184 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
/* eslint-disable max-classes-per-file */
12
/*
23
* Copyright 2022 Adobe. All rights reserved.
34
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
@@ -160,6 +161,22 @@ export function toCamelCase(name) {
160161
return toClassName(name).replace(/-([a-z])/g, (g) => g[1].toUpperCase());
161162
}
162163

164+
/**
165+
* Gets all the metadata elements that are in the given scope.
166+
* @param {String} scope The scope/prefix for the metadata
167+
* @returns an array of HTMLElement nodes that match the given scope
168+
*/
169+
export function getAllMetadata(scope) {
170+
return [...document.head.querySelectorAll(`meta[property^="${scope}:"],meta[name^="${scope}-"]`)]
171+
.reduce((res, meta) => {
172+
const id = toClassName(meta.name
173+
? meta.name.substring(scope.length + 1)
174+
: meta.getAttribute('property').split(':')[1]);
175+
res[id] = meta.getAttribute('content');
176+
return res;
177+
}, {});
178+
}
179+
163180
const ICONS_CACHE = {};
164181
/**
165182
* Replace icons with inline SVG and prefix with codeBasePath.
@@ -470,6 +487,53 @@ export function buildBlock(blockName, content) {
470487
return (blockEl);
471488
}
472489

490+
/**
491+
* Gets the configuration for the given block, and also passes
492+
* the config through all custom patching helpers added to the project.
493+
*
494+
* @param {Element} block The block element
495+
* @returns {Object} The block config (blockName, cssPath and jsPath)
496+
*/
497+
function getBlockConfig(block) {
498+
const { blockName } = block.dataset;
499+
const cssPath = `${window.hlx.codeBasePath}/blocks/${blockName}/${blockName}.css`;
500+
const jsPath = `${window.hlx.codeBasePath}/blocks/${blockName}/${blockName}.js`;
501+
const original = { blockName, cssPath, jsPath };
502+
return (window.hlx.patchBlockConfig || [])
503+
.filter((fn) => typeof fn === 'function')
504+
.reduce((config, fn) => fn(config, original), { blockName, cssPath, jsPath });
505+
}
506+
507+
/**
508+
* Loads JS and CSS for a module and executes it's default export.
509+
* @param {string} name The module name
510+
* @param {string} jsPath The JS file to load
511+
* @param {string} [cssPath] An optional CSS file to load
512+
* @param {object[]} [args] Parameters to be passed to the default export when it is called
513+
*/
514+
async function loadModule(name, jsPath, cssPath, ...args) {
515+
const cssLoaded = cssPath ? loadCSS(cssPath) : Promise.resolve();
516+
const decorationComplete = jsPath
517+
? new Promise((resolve) => {
518+
(async () => {
519+
let mod;
520+
try {
521+
mod = await import(jsPath);
522+
if (mod.default) {
523+
await mod.default.apply(null, args);
524+
}
525+
} catch (error) {
526+
// eslint-disable-next-line no-console
527+
console.log(`failed to load module for ${name}`, error);
528+
}
529+
resolve(mod);
530+
})();
531+
})
532+
: Promise.resolve();
533+
return Promise.all([cssLoaded, decorationComplete])
534+
.then(([, api]) => api);
535+
}
536+
473537
/**
474538
* Loads JS and CSS for a block.
475539
* @param {Element} block The block element
@@ -478,24 +542,9 @@ export async function loadBlock(block) {
478542
const status = block.dataset.blockStatus;
479543
if (status !== 'loading' && status !== 'loaded') {
480544
block.dataset.blockStatus = 'loading';
481-
const { blockName } = block.dataset;
545+
const { blockName, cssPath, jsPath } = getBlockConfig(block);
482546
try {
483-
const cssLoaded = loadCSS(`${window.hlx.codeBasePath}/blocks/${blockName}/${blockName}.css`);
484-
const decorationComplete = new Promise((resolve) => {
485-
(async () => {
486-
try {
487-
const mod = await import(`../blocks/${blockName}/${blockName}.js`);
488-
if (mod.default) {
489-
await mod.default(block);
490-
}
491-
} catch (error) {
492-
// eslint-disable-next-line no-console
493-
console.log(`failed to load module for ${blockName}`, error);
494-
}
495-
resolve();
496-
})();
497-
});
498-
await Promise.all([cssLoaded, decorationComplete]);
547+
await loadModule(blockName, jsPath, cssPath, block);
499548
} catch (error) {
500549
// eslint-disable-next-line no-console
501550
console.log(`failed to load block ${blockName}`, error);
@@ -729,6 +778,121 @@ export function loadFooter(footer) {
729778
return loadBlock(footerBlock);
730779
}
731780

781+
function parsePluginParams(id, config) {
782+
const pluginId = !config
783+
? id.split('/').splice(id.endsWith('/') ? -2 : -1, 1)[0].replace(/\.js/, '')
784+
: id;
785+
const pluginConfig = {
786+
load: 'eager',
787+
...(typeof config === 'string' || !config
788+
? { url: (config || id).replace(/\/$/, '') }
789+
: config),
790+
};
791+
pluginConfig.options ||= {};
792+
return { id: pluginId, config: pluginConfig };
793+
}
794+
795+
// Define an execution context for plugins
796+
export const executionContext = {
797+
createOptimizedPicture,
798+
getAllMetadata,
799+
getMetadata,
800+
decorateBlock,
801+
decorateButtons,
802+
decorateIcons,
803+
loadBlock,
804+
loadCSS,
805+
loadScript,
806+
sampleRUM,
807+
toCamelCase,
808+
toClassName,
809+
};
810+
811+
class PluginsRegistry {
812+
#plugins;
813+
814+
constructor() {
815+
this.#plugins = new Map();
816+
}
817+
818+
// Register a new plugin
819+
add(id, config) {
820+
const { id: pluginId, config: pluginConfig } = parsePluginParams(id, config);
821+
this.#plugins.set(pluginId, pluginConfig);
822+
}
823+
824+
// Get the plugin
825+
get(id) { return this.#plugins.get(id); }
826+
827+
// Check if the plugin exists
828+
includes(id) { return !!this.#plugins.has(id); }
829+
830+
// Load all plugins that are referenced by URL, and updated their configuration with the
831+
// actual API they expose
832+
async load(phase) {
833+
[...this.#plugins.entries()]
834+
.filter(([, plugin]) => plugin.condition
835+
&& !plugin.condition(document, plugin.options, executionContext))
836+
.map(([id]) => this.#plugins.delete(id));
837+
return Promise.all([...this.#plugins.entries()]
838+
// Filter plugins that don't match the execution conditions
839+
.filter(([, plugin]) => (
840+
(!plugin.condition || plugin.condition(document, plugin.options, executionContext))
841+
&& phase === plugin.load && plugin.url
842+
))
843+
.map(async ([key, plugin]) => {
844+
try {
845+
// If the plugin has a default export, it will be executed immediately
846+
const pluginApi = (await loadModule(
847+
key,
848+
!plugin.url.endsWith('.js') ? `${plugin.url}/${key}.js` : plugin.url,
849+
!plugin.url.endsWith('.js') ? `${plugin.url}/${key}.css` : null,
850+
document,
851+
plugin.options,
852+
executionContext,
853+
)) || {};
854+
this.#plugins.set(key, { ...plugin, ...pluginApi });
855+
} catch (err) {
856+
// eslint-disable-next-line no-console
857+
console.error('Could not load specified plugin', key);
858+
}
859+
}));
860+
}
861+
862+
// Run a specific phase in the plugin
863+
async run(phase) {
864+
return [...this.#plugins.values()]
865+
.reduce((promise, plugin) => ( // Using reduce to execute plugins sequencially
866+
plugin[phase] && (!plugin.condition
867+
|| plugin.condition(document, plugin.options, executionContext))
868+
? promise.then(() => plugin[phase](document, plugin.options, executionContext))
869+
: promise
870+
), Promise.resolve());
871+
}
872+
}
873+
874+
class TemplatesRegistry {
875+
// Register a new template
876+
// eslint-disable-next-line class-methods-use-this
877+
add(id, url) {
878+
if (Array.isArray(id)) {
879+
id.forEach((i) => window.hlx.templates.add(i));
880+
return;
881+
}
882+
const { id: templateId, config: templateConfig } = parsePluginParams(id, url);
883+
templateConfig.condition = () => toClassName(getMetadata('template')) === templateId;
884+
window.hlx.plugins.add(templateId, templateConfig);
885+
}
886+
887+
// Get the template
888+
// eslint-disable-next-line class-methods-use-this
889+
get(id) { return window.hlx.plugins.get(id); }
890+
891+
// Check if the template exists
892+
// eslint-disable-next-line class-methods-use-this
893+
includes(id) { return window.hlx.plugins.includes(id); }
894+
}
895+
732896
/**
733897
* Setup block utils.
734898
*/
@@ -737,6 +901,9 @@ export function setup() {
737901
window.hlx.RUM_MASK_URL = 'full';
738902
window.hlx.codeBasePath = '';
739903
window.hlx.lighthouse = new URLSearchParams(window.location.search).get('lighthouse') === 'on';
904+
window.hlx.patchBlockConfig = [];
905+
window.hlx.plugins = new PluginsRegistry();
906+
window.hlx.templates = new TemplatesRegistry();
740907

741908
const scriptEl = document.querySelector('script[src$="/solutions/scripts/scripts.js"]');
742909
if (scriptEl) {

solutions/scripts/scripts.js

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import {
1212
loadBlocks,
1313
loadCSS,
1414
getMetadata,
15-
toClassName,
1615
} from './lib-franklin.js';
1716

1817
import {
@@ -30,6 +29,11 @@ export const DEFAULT_COUNTRY = 'au';
3029

3130
export const METADATA_ANAYTICS_TAGS = 'analytics-tags';
3231

32+
window.hlx.plugins.add('rum-conversion', {
33+
load: 'lazy',
34+
url: '../plugins/rum-conversion/src/index.js',
35+
});
36+
3337
/**
3438
* Creates a meta tag with the given name and value and appends it to the head.
3539
* @param {String} name The name of the meta tag
@@ -476,11 +480,6 @@ async function loadLazy(doc) {
476480
sampleRUM('lazy');
477481
sampleRUM.observe(main.querySelectorAll('div[data-block-name]'));
478482
sampleRUM.observe(main.querySelectorAll('picture > img'));
479-
480-
const context = { getMetadata, toClassName };
481-
// eslint-disable-next-line import/no-relative-packages
482-
const { initConversionTracking } = await import('../plugins/rum-conversion/src/index.js');
483-
await initConversionTracking.call(context, document);
484483
}
485484

486485
/**
@@ -489,15 +488,19 @@ async function loadLazy(doc) {
489488
*/
490489
function loadDelayed() {
491490
window.setTimeout(() => {
491+
window.hlx.plugins.load('delayed');
492+
window.hlx.plugins.run('loadDelayed');
493+
// load anything that can be postponed to the latest here
492494
// eslint-disable-next-line import/no-cycle
493-
import('./delayed.js');
495+
return import('./delayed.js');
494496
}, 3000);
495-
// load anything that can be postponed to the latest here
496497
}
497498

498499
async function loadPage() {
499500
pushPageLoadToDataLayer();
501+
await window.hlx.plugins.load('eager');
500502
await loadEager(document);
503+
await window.hlx.plugins.load('lazy');
501504
await loadLazy(document);
502505
loadDelayed();
503506
}

0 commit comments

Comments
 (0)