-
Notifications
You must be signed in to change notification settings - Fork 342
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
Changes from all commits
9663674
d758187
815fbfb
a8464ed
657cc47
34a1da9
ef9f1b8
8fed9e3
1762e11
7a07712
285655d
7c11fa1
a562921
e1e0c54
fa05c0f
64babaa
82466e6
2e82dfe
5bd292a
78a5475
2d9311e
d9e41e4
53894d6
7fa625c
5ac657f
96de3e4
b65a381
9802f6b
68fdf47
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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. | ||
|
@@ -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) | ||
: 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. | ||
|
@@ -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); | ||
|
@@ -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) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. | ||
*/ | ||
|
@@ -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) { | ||
|
There was a problem hiding this comment.
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
(orlib-franklin
) in thethis
object of a plugin function.There was a problem hiding this comment.
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:
decorateIcons
)