Skip to content

Commit

Permalink
feat: add minimal plugin system
Browse files Browse the repository at this point in the history
  • Loading branch information
ramboz committed Oct 26, 2023
1 parent a11180a commit dbcb25c
Show file tree
Hide file tree
Showing 6 changed files with 250 additions and 18 deletions.
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(blockName, jsPath, cssPath, block);
} catch (error) {
// eslint-disable-next-line no-console
console.log(`failed to load block ${blockName}`, error);
Expand Down
47 changes: 47 additions & 0 deletions src/dom-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,36 @@ export async function loadScript(src, attrs) {
});
}

/**
* Loads JS and CSS for a module and executes it's default export.
* @param {string} name The module name
* @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(name, jsPath, cssPath, ...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) {
await mod.default.apply(null, args);
}
} 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);
}

/**
* Retrieves the content of metadata tags.
* @param {string} name The metadata name (or property)
Expand All @@ -68,6 +98,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
87 changes: 87 additions & 0 deletions src/plugins-registry.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/*
* 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 = typeof config === 'string' || !config
? { url: (config || id).replace(/\/$/, '') }
: { load: 'eager', ...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(
key,
!plugin.url.endsWith('.js') ? `${plugin.url}/${key}.js` : plugin.url,
!plugin.url.endsWith('.js') ? `${plugin.url}/${key}.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);
}
}));
}

// Run a specific phase in the plugin
async run(phase, context) {
return [...this.#plugins.values()]
.reduce((promise, plugin) => ( // Using reduce to execute plugins sequencially
plugin[phase] && (!plugin.condition
|| plugin.condition(document, plugin.options, context))
? promise.then(() => plugin[phase](document, plugin.options, context))
: promise
), Promise.resolve());
}
}
5 changes: 5 additions & 0 deletions 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,6 +22,9 @@ 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"]');
if (scriptEl) {
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); }
}
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>

0 comments on commit dbcb25c

Please sign in to comment.