diff --git a/CHANGELOG.md b/CHANGELOG.md index c180073ef6..5bcde15e42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,12 @@ ## UNRELEASED -### Fixes +### Adds +* Modules can now have a `before: "module-name"` property in their configuration to run (initialization) before another module. + +### Fixes + * Modifies the `AposAreaMenu.vue` component to set the `disabled` attribute to `true` if the max number of widgets have been added in an area with `expanded: true`. ## 4.8.0 (2024-10-03) diff --git a/index.js b/index.js index c73031b515..e99ce6f9e6 100644 --- a/index.js +++ b/index.js @@ -582,9 +582,90 @@ async function apostrophe(options, telemetry, rootSpan) { return synth; } + // Reorder modules based on their `before` property. + function sortModules(moduleNames) { + const definitions = self.options.modules; + // The module names that have a `before` property + const beforeModules = []; + // The metadata quick access of all modules + const modules = {}; + // Recursion guard + const recursionGuard = {}; + // The sorted modules result + const sorted = []; + + // The base module sort metadata + for (const name of moduleNames) { + if (definitions[name].before) { + beforeModules.push(name); + } + modules[name] = { + before: definitions[name].before, + beforeSelf: [] + }; + } + + // Loop through the modules that have a `before` property, + // validate and fill the initial `beforeSelf` metadata (first pass). + for (const name of beforeModules) { + const m = modules[name]; + const before = m.before; + if (m.before === name) { + throw new Error(`Module "${name}" has a 'before' property that references itself.`); + } + if (!modules[before]) { + throw new Error(`Module "${name}" has a 'before' property that references a non-existent module: "${before}".`); + } + // Add the current module name to the target's beforeSelf. + modules[before].beforeSelf.push(name); + } + + // Loop through the modules that have a `before` properties + // now that we have the initial metadata (second pass). + // This takes care of edge cases like `before` that points to another module + // that has a `before` property itself, circular `before` references, etc. + // in a very predictable way. + for (const name of beforeModules) { + const m = modules[name]; + const target = modules[m.before]; + if (!target) { + continue; + } + // Add all the modules that want to be before this one to the target's beforeSelf. + // Do this recursively for every module from the beforeSelf array that has own `beforeSelf` members. + addBeforeSelfRecursive(name, m.beforeSelf, target.beforeSelf); + } + + // Fill in the sorted array, first wins when uniquefy-ing. + for (const name of moduleNames) { + sorted.push(...modules[name].beforeSelf, name); + } + + // A unique array of sorted module names. + return [ ...new Set(sorted) ]; + + function addBeforeSelfRecursive(moduleName, beforeSelf, target) { + if (beforeSelf.length === 0) { + return; + } + if (recursionGuard[moduleName]) { + return; + } + recursionGuard[moduleName] = true; + + beforeSelf.forEach((name) => { + if (recursionGuard[name]) { + return; + } + target.unshift(name); + addBeforeSelfRecursive(name, modules[name].beforeSelf, target); + }); + } + } + async function instantiateModules() { self.modules = {}; - for (const item of modulesToBeInstantiated()) { + for (const item of sortModules(modulesToBeInstantiated())) { // module registers itself in self.modules const module = await self.synth.create(item, { apos: self }); await module.emit('moduleReady'); diff --git a/lib/moog.js b/lib/moog.js index 0582303c95..710bdf3c39 100644 --- a/lib/moog.js +++ b/lib/moog.js @@ -141,6 +141,7 @@ module.exports = function(options) { 'beforeSuperClass', 'init', 'afterAllSections', + 'before', 'extend', 'improve', 'methods', diff --git a/test/modules-order.js b/test/modules-order.js new file mode 100644 index 0000000000..61f2c636f0 --- /dev/null +++ b/test/modules-order.js @@ -0,0 +1,132 @@ +const assert = require('node:assert/strict'); +const t = require('../test-lib/test.js'); + +describe('Modules Order', function() { + + let apos; + + this.timeout(t.timeout); + + after(async function () { + await t.destroy(apos); + apos = null; + }); + + it('should sort modules based on their "before" property', async function() { + apos = await t.create({ + root: module, + modules: { + // Before third, but also before first and schema (recursion) + strange: { + before: 'third', + init() { } + }, + // before schema + first: { + before: '@apostrophecms/schema', + init() { } + }, + // before schema, but after first (keep order) + second: { + before: '@apostrophecms/schema', + init() { } + }, + // before first, but also before schema (recursion) + third: { + before: 'first', + init() { } + }, + // before @apostrophecms/image (`before` in a module definition) + 'test-before': {}, + usual: { + init() { } + } + } + }); + + const expected = [ + 'strange', + 'third', + 'first', + 'second', + '@apostrophecms/schema', + 'test-before', + '@apostrophecms/image', + 'usual' + ]; + const actual = Object.keys(apos.modules).filter(m => expected.includes(m)); + assert.deepEqual(actual, expected, 'modules are not sorted as expected'); + + // We don't mess with the module owned context. + assert.strictEqual(apos.modules.first.before, undefined); + + // Ensure we have the same number of modules when not reordering + const expectedModuleCount = apos.modules.length; + await t.destroy(apos); + + apos = await t.create({ + root: module, + modules: { + strange: { + init() { } + }, + first: { + init() { } + }, + second: { + init() { } + }, + third: { + init() { } + }, + 'test-before': { + before: null + }, + usual: { + init() { } + } + } + }); + assert.strictEqual(apos.modules.length, expectedModuleCount, 'module count is not the same'); + + { + // ...and the natural order should be preserved + const expected = [ + '@apostrophecms/schema', + '@apostrophecms/image', + 'strange', + 'first', + 'second', + 'third', + 'test-before', + 'usual' + ]; + const actual = Object.keys(apos.modules).filter(m => expected.includes(m)); + assert.deepEqual(actual, expected, 'modules are not matching the natural order'); + } + }); + + it('should handle circular "before" references', async function () { + await t.destroy(apos); + apos = await t.create({ + root: module, + modules: { + first: { + before: 'second', + init() { } + }, + second: { + before: 'first', + init() { } + } + } + }); + + const expected = [ + 'second', + 'first' + ]; + const actual = Object.keys(apos.modules).filter(m => expected.includes(m)); + assert.deepEqual(actual, expected, 'modules are not sorted as expected'); + }); +}); diff --git a/test/modules/test-before/index.js b/test/modules/test-before/index.js new file mode 100644 index 0000000000..dfd86c7b7e --- /dev/null +++ b/test/modules/test-before/index.js @@ -0,0 +1,4 @@ +module.exports = { + before: '@apostrophecms/image', + init() {} +};