Skip to content

feat: add minimal plugin system #23

New issue

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

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

Already on GitHub? # to your account

Closed
wants to merge 12 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 2 additions & 1 deletion rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ const banner = `/*
* 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 */`;

const bundles = [
{
Expand Down
41 changes: 23 additions & 18 deletions src/block-loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* governing permissions and limitations under the License.
*/

import { loadCSS } from './dom-utils.js';
import { loadModule } from './dom-utils.js';

/**
* Updates all section status in a container element.
Expand Down Expand Up @@ -65,6 +65,26 @@ 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 block.
* @param {Element} block The block element
Expand All @@ -73,24 +93,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(`${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);
Expand Down
45 changes: 45 additions & 0 deletions src/dom-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,34 @@ export 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
*/
export 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)
Expand All @@ -68,6 +96,23 @@ export function getMetadata(name, doc = document) {
return meta || '';
}

/**
* Gets all the metadata elements that are in the given scope.
* @param {String} scope The scope/prefix for the metadata
* @param {Document} doc Document object to query for metadata. Defaults to the window's document
* @returns an array of HTMLElement nodes that match the given scope
*/
export function getAllMetadata(scope, doc = document) {
return [...doc.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;
}, {});
}

/**
* Returns a picture element with webp and fallbacks
* @param {string} src The image URL
Expand Down
90 changes: 90 additions & 0 deletions src/plugins-registry.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/*
* Copyright 2023 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/

import { loadModule } from './dom-utils.js';

export default 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',
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Plugins would load in the eager phase by default. should we set this to lazy so we keep the best performance by default, and require explicit opt-in to eager for those plugins that would need immediate instrumentation?

...(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());
}
}
8 changes: 7 additions & 1 deletion src/setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
*/

import { sampleRUM } from '@adobe/helix-rum-js';
import PluginsRegistry from './plugins-registry.js';
import TemplatesRegistry from './templates-registry.js';

/**
* Setup block utils.
Expand All @@ -20,13 +22,17 @@ 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$="/scripts/scripts.js"]');
/* c8 ignore next 1 */
if (scriptEl) {
try {
[window.hlx.codeBasePath] = new URL(scriptEl.src).pathname.split('/scripts/scripts.js');
} catch (error) {
/* c8 ignore next 3 */
/* c8 ignore next 2 */
// eslint-disable-next-line no-console
console.log(error);
}
Expand Down
37 changes: 37 additions & 0 deletions src/templates-registry.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/*
* Copyright 2023 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/

import { getMetadata } from './dom-utils.js';
import PluginsRegistry from './plugins-registry.js';
import { toClassName } from './utils.js';

export default 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); }
}
50 changes: 50 additions & 0 deletions test/block-loader/loadBlock.customconfig.test.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<!DOCTYPE html>
<html>
<body>
<main>
<div class="section">
<div class="wrapper">
<div class="ablock" data-block-name="asyncblock"></div>
</div>
</div>
</main>

<script type="module">
/* eslint-env mocha */
import { runTests } from '@web/test-runner-mocha';
import { expect } from '@esm-bundle/chai';
import { setup } from '../../src/setup.js';
import { loadBlock } from '../../src/block-loader.js';

setup();

runTests(() => {
it('loadBlock - async block', async () => {
window.hlx.codeBasePath = '/test/fixtures';
// Modify the block name
window.hlx.patchBlockConfig.push((cfg) => ({
...cfg,
blockName: `${cfg.blockName}-alt`,
}));
// Modify the css/js path using the new & original block name
window.hlx.patchBlockConfig.push((cfg, original) => ({
...cfg,
cssPath: `/blocks-alt/${original.blockName}/${cfg.blockName}.css`,
jsPath: `/blocks-alt/${original.blockName}/${cfg.blockName}.js`,
}));

const block = document.querySelector('.ablock');

await loadBlock(block);

expect(block.classList.contains('ablock')).to.be.true;
expect(block.dataset.blockName).to.equal('asyncblock');
expect(block.dataset.blockStatus).to.equal('loaded');

const css = document.querySelector('link');
expect(css.href).to.contains('/blocks-alt/asyncblock/asyncblock-alt.css');
});
});
</script>
</body>
</html>
51 changes: 51 additions & 0 deletions test/dom-utils/getAllMetadata.test.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<!DOCTYPE html>
<html>
<head>
<meta property="foo:bar" content="baz">
<meta name="foo-qux" content="corge">
</head>
<body>
<script type="module">
/* eslint-env mocha */
import { runTests } from '@web/test-runner-mocha';
import { expect } from '@esm-bundle/chai';
import { getAllMetadata } from '../../src/dom-utils.js';

runTests(() => {
it('get scoped properties', () => {
expect(getAllMetadata('foo')).to.eql({
bar: 'baz',
qux: 'corge',
});
});

it('get properties for unknown scope', () => {
expect(getAllMetadata('bar')).to.eql({});
});

// Test a custom document
const testDoc = document.implementation.createHTMLDocument();
const titleMeta = testDoc.createElement('meta');
titleMeta.setAttribute('property', 'grault:bar');
titleMeta.setAttribute('content', 'baz');
testDoc.head.appendChild(titleMeta);

const descriptionMeta = testDoc.createElement('meta');
descriptionMeta.setAttribute('name', 'grault-qux');
descriptionMeta.setAttribute('content', 'corge');
testDoc.head.appendChild(descriptionMeta);

it('get scoped properties from custom document', () => {
expect(getAllMetadata('grault', testDoc)).to.eql({
bar: 'baz',
qux: 'corge',
});
});

it('get properties for unknown scope from custom document', () => {
expect(getAllMetadata('bar', testDoc)).to.eql({});
});
});
</script>
</body>
</html>
Loading