Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: a minimal plugin and template system #254

Closed
wants to merge 29 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
9663674
chore: initial commit
ramboz Sep 5, 2023
d758187
feat: minimal plugin and template system
ramboz Sep 6, 2023
815fbfb
feat: minimal plugin and template system
ramboz Sep 6, 2023
a8464ed
feat: minimal plugin and template system
ramboz Sep 6, 2023
657cc47
feat: minimal plugin and template system
ramboz Sep 6, 2023
34a1da9
feat: minimal plugin and template system
ramboz Sep 6, 2023
ef9f1b8
feat: minimal plugin and template system
ramboz Sep 6, 2023
8fed9e3
feat: minimal plugin and template system
ramboz Sep 6, 2023
1762e11
feat: minimal plugin and template system
ramboz Sep 6, 2023
7a07712
chore: extract plugins and templates as individual classes
ramboz Sep 14, 2023
285655d
feat: minimal plugin and template system
ramboz Sep 14, 2023
7c11fa1
feat: minimal plugin and template system
ramboz Sep 14, 2023
a562921
fix: remove plugins that don't have a truthy condition
ramboz Sep 14, 2023
e1e0c54
feat: export the execution context
ramboz Sep 14, 2023
fa05c0f
feat: add phased plugin loading
ramboz Sep 15, 2023
64babaa
feat: pass proper context to init and default functions
ramboz Sep 15, 2023
82466e6
feat: allow loading whole modules with both js and css files
ramboz Sep 16, 2023
2e82dfe
feat: allow loading whole modules with both js and css files
ramboz Sep 16, 2023
5bd292a
feat: allow loading whole modules with both js and css files
ramboz Sep 16, 2023
78a5475
fix: remove redundant init function support
ramboz Sep 16, 2023
2d9311e
feat: add capability to pass down options to plugins to configure them
ramboz Sep 18, 2023
d9e41e4
feat: support setting the context on regular plugin functions
ramboz Sep 18, 2023
53894d6
fix: add better error handling
ramboz Sep 25, 2023
7fa625c
chore: cleanup PR
ramboz Sep 25, 2023
5ac657f
chore: cleanup PR
ramboz Sep 26, 2023
96de3e4
chore: cleanup PR
ramboz Sep 26, 2023
b65a381
refactor: minor code optimizations
ramboz Sep 26, 2023
9802f6b
doc: update jsdoc
ramboz Sep 26, 2023
68fdf47
doc: update jsdoc
ramboz Sep 26, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
193 changes: 177 additions & 16 deletions scripts/lib-franklin.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
/* eslint-disable max-classes-per-file */

/**
* log RUM if part of the sample.
Expand Down Expand Up @@ -431,6 +432,53 @@ export function buildBlock(blockName, content) {
return (blockEl);
}

/**
* Executes a function with the given context and arguments.
* An arrow function will have the context as last parameter, while a regular function will also
* have the `this` variable set to the same context.
* @param {Function} fn the function to execute
* @param {Object[]} args the function arugments
* @param {Object} context the execution context to use
* @returns the result of the function execution
*/
function runFunctionWithContext(fn, args, context) {
return fn.toString().startsWith('function')
? fn.call(context, ...args, context)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is it worth to use .call?
It feels a bit strange to have methods which are defined in aem-lib (or lib-franklin) in the this object of a plugin function.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The idea behind it was:

  • simplify imports and given immediate access to all "core" functions
  • allow for "overriding" core functions by modifying the context if needed, as opposed to imports which are read-only (think icons decoration via svg spriting for instance that could replace the regular decorateIcons)
  • simplify unit testing, since you don't have to inject mocked dependencies anymore

: fn(...args, context);
}

/**
* Loads the specified module with its JS and CSS files and returns the JS API if applicable.
* @param {String} name The module name
* @param {String} cssPath A path to the CSS file to load, or null
* @param {String} jsPath A path to the JS file to load, or null
* @param {...any} args Arguments to use to call the default export on the JS file
* @returns a promsie that the module was loaded, and that returns the JS API is any
*/
async function loadModule(name, cssPath, jsPath, ...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) {
// eslint-disable-next-line no-use-before-define
await runFunctionWithContext(mod.default, args, executionContext);
}
} 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.
Expand Down Expand Up @@ -461,22 +509,7 @@ export async function loadBlock(block) {
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]);
await loadModule(blockName, cssPath, jsPath, block);
} catch (error) {
// eslint-disable-next-line no-console
console.log(`failed to load block ${blockName}`, error);
Expand Down Expand Up @@ -659,6 +692,132 @@ export function loadFooter(footer) {
return loadBlock(footerBlock);
}

// Define an execution context for plugins
export const executionContext = {
createOptimizedPicture,
getMetadata,
decorateBlock,
decorateButtons,
decorateIcons,
loadBlock,
loadCSS,
loadScript,
sampleRUM,
toCamelCase,
toClassName,
};

/**
* Parses the plugin id and config paramters and returns a proper config
*
* @param {String} id A string that idenfies the plugin, or a path to it
* @param {String|Object} [config] A string representing the path to the plugin, or a config object
* @returns an object returning the the plugin id and its config
*/
function parsePluginParams(id, config) {
const pluginId = !config
? id.split('/').splice(id.endsWith('/') ? -2 : -1, 1)[0].replace(/\.js/, '')
: id;
const pluginConfig = typeof config === 'string' || !config
? { load: 'eager', url: (config || id).replace(/\/$/, '') }
: { load: 'eager', ...config };
pluginConfig.options ||= {};
return { id: toClassName(pluginId), config: pluginConfig };
}

class PluginsRegistry {
#plugins;

constructor() {
this.#plugins = new Map();
}

// Register a new plugin
add(id, config) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking that plugins could register themselves, instead of being explicitly registered in the scripts.js as suggested in the docs. What do you think?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For a plugin to register itself, it means we need to load the plugin unconditionally and then let it decide itself if it triggers or not. I think this goes against the performance-first paradigm.

If you have a hefty preview overlay plugin for instance, and the plugin registers itself, that means you have to load a large JS file just to see the plugin not activate when the conditions aren't met (i.e. not preview, and likely lazy loaded)

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 update their configuration with the
// actual API they expose
async load(phase) {
[...this.#plugins.entries()]
.filter(([, plugin]) => plugin.condition
&& !runFunctionWithContext(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
|| runFunctionWithContext(plugin.condition, [document, plugin.options], executionContext))
&& phase === plugin.load && plugin.url
))
.map(async ([key, plugin]) => {
try {
const isJsUrl = plugin.url.endsWith('.js');
// If the plugin has a default export, it will be executed immediately
const pluginApi = (await loadModule(
key,
!isJsUrl ? `${plugin.url}/${key}.css` : null,
!isJsUrl ? `${plugin.url}/${key}.js` : plugin.url,
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 method in the plugin
// Methods follow the loadEager/loadLazy/loadDelayed phases
async run(phase) {
return [...this.#plugins.values()]
.reduce((promise, plugin) => ( // Using reduce to execute plugins sequencially
plugin[phase] && (!plugin.condition
|| runFunctionWithContext(plugin.condition, [document, plugin.options], executionContext))
? promise.then(() => runFunctionWithContext(
plugin[phase],
[document, plugin.options],
executionContext,
))
: promise
), Promise.resolve())
.catch((err) => {
// Gracefully catch possible errors in the plugins to avoid bubbling up issues
// eslint-disable-next-line no-console
console.error('Error in plugin', err);
});
}
}

class TemplatesRegistry {
// Register a new template
// eslint-disable-next-line class-methods-use-this
add(id, url) {
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 @@ -668,6 +827,8 @@ export function setup() {
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
18 changes: 17 additions & 1 deletion scripts/scripts.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ import {

const LCP_BLOCKS = []; // add your LCP blocks to the list

/* Templates */

// window.hlx.templates.add('my-template', '/templates/my-template.js');

/* Plugins */

// window.hlx.plugins.add('my-plugin', '/plugins/my-plugin.js');

/**
* Builds hero block and prepends to main in a new section.
* @param {Element} main The container element
Expand Down Expand Up @@ -77,6 +85,7 @@ async function loadEager(doc) {
document.documentElement.lang = 'en';
decorateTemplateAndTheme();
const main = doc.querySelector('main');
await window.hlx.plugins.run('loadEager');
if (main) {
decorateMain(main);
document.body.classList.add('appear');
Expand Down Expand Up @@ -114,6 +123,7 @@ async function loadLazy(doc) {
sampleRUM('lazy');
sampleRUM.observe(main.querySelectorAll('div[data-block-name]'));
sampleRUM.observe(main.querySelectorAll('picture > img'));
window.hlx.plugins.run('loadLazy');
}

/**
Expand All @@ -122,12 +132,18 @@ async function loadLazy(doc) {
*/
function loadDelayed() {
// eslint-disable-next-line import/no-cycle
window.setTimeout(() => import('./delayed.js'), 3000);
window.setTimeout(() => {
window.hlx.plugins.load('delayed');
import('./delayed.js');
window.hlx.plugins.run('loadDelayed');
}, 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();
}
Expand Down