apostrophe
Version:
The Apostrophe Content Management System.
308 lines (277 loc) • 11.2 kB
JavaScript
const _ = require('lodash');
const fs = require('fs');
const npmResolve = require('resolve');
const path = require('path');
const glob = require('./glob.js');
const resolveFrom = require('resolve-from');
const regExpQuote = require('regexp-quote');
const importFresh = moduleName => import(`${moduleName}?${Date.now()}`);
module.exports = async function(options) {
const self = require('./moog')(options);
if (!self.options.root) {
throw 'The root option is required. Pass the node variable "module" as root. This allows moog to require modules on your behalf.';
}
self.root = self.options.root;
self.bundled = {};
self.improvements = {};
if (self.options.bundles) {
for (const bundleName of self.options.bundles) {
const bundlePath = getNpmPath(self.root.filename, bundleName);
if (!bundlePath) {
throw 'The configured bundle ' + bundleName + ' was not found in npm.';
}
const { default: imported } = await importFresh(bundlePath);
if (!imported.bundle) {
throw 'The configured bundle ' + bundleName + ' does not export a bundle property.';
}
const modules = imported.bundle.modules;
if (!modules) {
throw 'The configured bundle ' + bundleName + ' does not have a "modules" property within its "bundle" property.';
}
Object.values(modules)
.forEach(name => {
self.bundled[name] = path.normalize(path.dirname(bundlePath) + '/' + imported.bundle.directory + '/' + name + '/index.js');
});
}
}
const superDefine = self.define;
self.define = async function(type, definition, extending) {
let result;
// For the define-many-at-once case let the base class do the work
if ((typeof type) === 'object') {
return superDefine(type);
}
let projectLevelDefinition;
let npmDefinition;
let originalType;
let projectLevelPath = self.options.localModules + '/' + type + '/index.js';
if (options.nestedModuleSubdirs) {
if (!self._indexes) {
// Fetching a list of index.js files on the first call and then
// searching it each time for one that refers to the right type name
// shaves as much as 60 seconds off the startup time in a large project,
// compared to using the glob cache feature
self._indexes = glob(self.options.localModules + '/**/index.js', { follow: true });
}
const matches = self._indexes.filter(function(index) {
// Double-check that we're not confusing "@apostrophecms/asset" with
// "asset" by making sure "type" is not preceded by an npm namespace
// folder. If type is itself namespaced this never comes up (because npm
// namespaces don't nest). The risk is that a legitimate project level
// module that happens to end with the same name as a namespaced module
// will be rejected as a duplicate when nestedModuleSubdirs is present
return index.endsWith('/' + type + '/index.js') && !index.match(new RegExp(`/@[^/]+/${regExpQuote(type)}/index\\.js$`));
});
if (matches.length > 1) {
throw new Error('The module ' + type + ' appears in multiple locations:\n' + matches.join('\n'));
}
projectLevelPath = matches[0] ? path.normalize(matches[0]) : projectLevelPath;
}
if (fs.existsSync(projectLevelPath)) {
const {
default: defaultProjectLevelDefinition
} = await importFresh(
resolveFrom(path.dirname(self.root.filename), projectLevelPath)
);
projectLevelDefinition = defaultProjectLevelDefinition;
if (Object.keys(projectLevelDefinition).length === 0) {
/* eslint-disable-next-line no-console */
console.warn(`⚠️ The file ${projectLevelPath}\ndoes not export anything, did you misspell or forget module.exports?\n`);
}
}
let relativeTo;
if (extending) {
relativeTo = extending.__meta.filename;
} else {
relativeTo = self.root.filename;
}
const npmPath = getNpmPath(relativeTo, type);
if (npmPath) {
const { default: defaultNpmDefinition } = await importFresh(npmPath);
npmDefinition = defaultNpmDefinition;
npmDefinition.__meta = {
npm: true,
bundled: _.has(self.bundled, type),
dirname: path.dirname(npmPath),
filename: npmPath,
name: type
};
if (npmDefinition.improve) {
// Remember which types were actually improvements of other types for
// the benefit of applications that would otherwise instantiate them all
self.improvements[type] = true;
// Improve an existing type with an implicit subclass,
// rather than defining one under a new name
originalType = type;
type = npmDefinition.improve;
// If necessary, start by autoloading the original type
if (!(await self.isDefined(type, { autoload: false }))) {
await self.define(type);
}
} else if (npmDefinition.replace) {
// Replace an existing type with the one defined by
// this npm module
delete self.definitions[npmDefinition.replace];
type = npmDefinition.replace;
}
}
if (!(definition || projectLevelDefinition || npmDefinition)) {
if (extending) {
throw new Error(`The module ${type} is not defined. Referenced in ${extending.__meta.name}.`);
} else {
throw new Error(`The module ${type} is not defined.`);
}
}
if (!definition) {
definition = {};
}
projectLevelDefinition = projectLevelDefinition || {};
projectLevelDefinition.__meta = {
dirname: path.dirname(projectLevelPath),
filename: projectLevelPath
};
_.defaults(definition, projectLevelDefinition);
// app.js wins if a section is declared twice. For most sections
// there is no further merge algorithm between app.js and index.js.
// However if an option exists in index.js and not in app.js, that
// should be honored.
if (definition.options && projectLevelDefinition.options) {
_.defaults(definition.options, projectLevelDefinition.options);
}
// Insert the npm definition as a defined type, then let the
// base class define the local definition normally. This results
// in an implicit base class, allowing local template overrides
// even if there is no other local code
if (npmDefinition) {
result = await superDefine(type, npmDefinition);
if (npmDefinition.improve) {
// Restore the name of the improving module as otherwise our asset
// chains have multiple references to my-foo which is ambiguous
result.__meta.name = originalType;
}
}
result = await superDefine(type, definition);
if (npmDefinition && npmDefinition.improve) {
// Restore the name of the improving module as otherwise our asset chains
// have multiple references to my-foo which is ambiguous
result.__meta.name = self.originalToMy(originalType);
}
// Mark "my" modules as such
result.__meta.my = self.isMy(result.__meta.name);
return result;
};
function getNpmPath(parentPath, type) {
parentPath = path.resolve(parentPath);
if (_.has(self.bundled, type)) {
return self.bundled[type];
}
// Even if the package exists in node_modules it might just be a
// sub-dependency due to npm/yarn flattening, which means we could be
// confused by an unrelated npm module with the same name as an Apostrophe
// module unless we verify it is a real project-level dependency. However
// if no package.json at all exists at project level we do search up the
// tree until we find one to accommodate patterns like `src/app.js`
if (!self.validPackages) {
const initialFolder = path.dirname(self.root.filename);
let folder = initialFolder;
while (true) {
const file = path.resolve(folder, 'package.json');
if (fs.existsSync(file)) {
const info = JSON.parse(fs.readFileSync(file, 'utf8'));
self.validPackages = new Set([
...getDependencies(info),
...getWorkspacesDependencies(folder)(info)
]);
break;
} else {
folder = path.dirname(folder);
if (!folder.length) {
throw new Error(`package.json was not found in ${initialFolder} or any of its parent folders.`);
}
}
}
self.npmRoot = folder;
}
if (!self.validPackages.has(type) && !symlinked(type)) {
return null;
}
try {
return npmResolve.sync(type, { basedir: path.dirname(parentPath) });
} catch (e) {
// Not found via npm. This does not mean it doesn't
// exist as a project-level thing
return null;
}
}
function symlinked(type) {
self.symlinksCache ||= new Map();
if (self.symlinksCache.has(type)) {
return self.symlinksCache.get(type);
}
const symlink = `${self.npmRoot}/node_modules/${type}`;
let link;
try {
link = fs.lstatSync(symlink).isSymbolicLink();
} catch (e) {
if (e.code === 'ENOENT') {
// Just doesn't exist
link = false;
} else {
throw e;
}
}
self.symlinksCache.set(type, link);
return link;
}
function getDependencies({
dependencies = {},
devDependencies = {}
} = {}) {
return [
...Object.keys(dependencies || {}),
...Object.keys(devDependencies || {})
];
}
function getWorkspacesDependencies(folder) {
return ({ workspaces = [] } = {}) => {
if (workspaces.length === 0) {
return [];
}
// Ternary is required because glob expects at least 2 entries
// when using curly braces
const pattern = workspaces.length === 1 ? workspaces[0] : `{${workspaces.join(',')}}`;
const packagePath = path.resolve(folder, pattern, 'package.json');
const workspacePackages = glob(packagePath, { follow: true });
return workspacePackages.flatMap(packagePath => {
const info = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
return getDependencies(info);
});
};
}
self.isImprovement = function(name) {
return _.has(self.improvements, name);
};
self.applyImprovementsBeforeProjectLevel = () => {
for (const [ name, definition ] of Object.entries(self.definitions)) {
// At this stage the complete definition of a type is a linked list of
// `extend` properties starting from what should be project level, unless
// there are improvements. Shuffle project level to be the first in the
// linked list
if (definition.__meta.name !== self.originalToMy(name)) {
let candidate = definition;
let predecessor = null;
while (candidate.extend && ((typeof candidate.extend) === 'object')) {
predecessor = candidate;
candidate = candidate.extend;
if (candidate.__meta.name === self.originalToMy(name)) {
predecessor.extend = candidate.extend;
candidate.extend = definition;
self.definitions[name] = candidate;
break;
}
}
}
}
};
return self;
};