src/services/building/buildCopier.js
const fs = require('fs-extra');
const path = require('path');
const { provider } = require('jimple');
/**
* Copies the project files and/or the files of a target that doesn't require bundling.
*/
class BuildCopier {
/**
* Class constructor.
* @param {Copier.copy} copier The function that copies files and
* directories.
* @param {Logger} appLogger Used to inform the user when files
* are being copied.
* @param {Events} events To trigger events reducer that may
* alter the items being copied.
* @param {PathUtils} pathUtils Necessary to build the paths.
* @param {ProjectConfigurationSettings} projectConfiguration To read the project information and
* get paths.
* @param {Targets} targets To get the information of targets
* from `includeTargets` and copy their
* files too.
*/
constructor(copier, appLogger, events, pathUtils, projectConfiguration, targets) {
/**
* A local reference for the `copier` service function.
* @type {Copier.copy}
*/
this.copier = copier;
/**
* A local reference for the `appLogger` service.
* @type {Logger}
*/
this.appLogger = appLogger;
/**
* A local reference for the `events` service.
* @type {Events}
*/
this.events = events;
/**
* A local reference for the `pathUtils` service.
* @type {PathUtils}
*/
this.pathUtils = pathUtils;
/**
* All the project settings.
* @type {ProjectConfigurationSettings}
*/
this.projectConfiguration = projectConfiguration;
/**
* A local reference for the `targets` service.
* @type {Targets}
*/
this.targets = targets;
}
/**
* If `copy.enabled` is `true` on the project configuration, this method will copy the list of
* items on the configuration `copy.items` key.
* This method emits the event reducer `project-files-to-copy` with the list of items to copy and
* expects an `Array` on return.
* @return {Promise<undefined,Error>}
*/
copyFiles() {
let result;
const {
copy,
version: {
revision,
},
paths: {
build,
privateModules,
},
} = this.projectConfiguration;
// If the feature is enabled...
if (copy.enabled) {
// ..prepare a list of the items
let items = [];
// Prepare a list of Node modules that may be copied.
const copiedModules = {};
// If there are items to copy on the project configuration...
if (Array.isArray(copy.items)) {
// ...loop the items.
copy.items.forEach((item) => {
// If the item is a Node module...
if (typeof item === 'string' && item.startsWith('node_modules')) {
// ...generate a new path for the module inside a private folder.
const newModulePath = item.replace(/^(node_modules\/)/, `${privateModules}/`);
// Save the name of the module linked to the new path.
copiedModules[item.split('/').pop()] = newModulePath;
// Push the module and its new path to the list of items to copy.
items.push({
[item]: newModulePath,
});
} else {
// ...otherwise, just push it to the list of items to copy.
items.push(item);
}
});
// if the revision functionality is enabled and the file exists...
if (
revision.enabled &&
revision.copy &&
fs.pathExistsSync(this.pathUtils.join(revision.filename))
) {
// ...add it to the items to copy.
items.push(revision.filename);
}
// Reduce the list of items to copy and give the chance to any plugin to add new ones.
items = this.events.reduce('project-files-to-copy', items);
// If there are still items to copy...
if (items.length) {
// ...grab a reference to the path of the project.
const thispath = this.pathUtils.path;
// Copy all the items on the project path onto the distribution directory.
result = this.copier(
thispath,
this.pathUtils.join(build),
items
)
.then((results) => {
this.appLogger.success('The following items have been successfully copied:');
// Remove the absolute path and the first `/`
const prefix = thispath.length + 1;
// Log a message for each item informing it was copied.
results.forEach((item) => {
const from = item.from.substr(prefix);
const to = item.to.substr(prefix);
this.appLogger.info(`${from} -> ${to}`);
});
/**
* If there any Node module was copied, call the method that updates the copied
* `package.json` of the project and modules in order to use relative paths instead of
* versions of the npm/yarn registry.
*/
return Object.keys(copiedModules).length ?
this.addPrivateModules(this.pathUtils.join(build, 'package.json'), copiedModules) :
{};
})
.catch((error) => {
this.appLogger.error('There was an error while copying the files');
return Promise.reject(error);
});
} else {
result = Promise.resolve();
}
} else {
result = Promise.reject(new Error('The \'copy.items\' setting is not an array'));
}
} else {
result = Promise.resolve();
}
return result;
}
/**
* After the project files are copied, this module updates the copied package.json with local
* references for any given module name.
* @param {string} packagePath The path to the main `package.json`.
* @param {Object} modules A dictionary with the name of modules as keys and
* local paths as values.
* @param {boolean} [updateModulesToo=true] If `true`, it will also update the `package.json` of
* each of the modules with references each others local
* paths.
* @return Promise<undefined,Error>
*/
addPrivateModules(packagePath, modules, updateModulesToo = true) {
// Read the main `package.json`
return fs.readJson(packagePath)
.then((packageContents) => {
// Create a new reference to avoid linting issues.
const newPackage = Object.assign({}, packageContents);
// Loop the different types of dependencies...
['dependencies', 'devDependencies']
.forEach((type) => {
// Loop the dictionary of modules...
Object.keys(modules).forEach((dependencyName) => {
// If the module is present...
if (newPackage[type] && newPackage[type][dependencyName]) {
// ...change the version to the local path.
newPackage[type][dependencyName] = `./${modules[dependencyName]}`;
}
});
});
// Remove any "private property" npm adds on the `package.json`
Object.keys(newPackage).forEach((property) => {
if (property.startsWith('_')) {
delete newPackage[property];
}
});
// Write the updated file.
return fs.writeJson(packagePath, newPackage);
})
.then(() => {
let result = {};
// If it needs to also update the methods between each other...
if (updateModulesToo) {
// Get the location of the private folder where modules are copied.
const { paths: { privateModules } } = this.projectConfiguration;
// Generate a path to it.
const directory = path.join(path.dirname(packagePath), privateModules);
const packages = [];
const modulesWithPathToRoot = {};
// Loop all the modules...
Object.keys(modules).forEach((dependencyName) => {
// Get its private path.
const privatePath = modules[dependencyName];
/**
* Updates it by adding 2 levels up from its location so they will be relative to where
* the `package.json` is: one to `node_modules`, and a second one to the "root"
*/
modulesWithPathToRoot[dependencyName] = `../../${privatePath}`;
/**
* Push the module `package.json` path to the list of `package.json`s that will be
* updated.
*/
packages.push(path.join(directory, dependencyName, 'package.json'));
});
/**
* Loop all the `package.json`s and call this same method to update their references, but
* with the flag to update modules disabled as it's already doing it.
*/
result = Promise.all(packages.map((modulePackage) => this.addPrivateModules(
modulePackage,
modulesWithPathToRoot,
false
)));
}
return result;
});
}
/**
* Copy the files of an specific target.
* @param {Target} target The target information.
* @return {Promise<undefined,Error>}
*/
copyTargetFiles(target) {
// Define the variable to return.
let result;
// Get the information of all the targets on the `includeTargets` list.
const includedTargets = target.includeTargets.map((name) => this.targets.getTarget(name));
// Try to find one that requires bundling.
const bundledTarget = includedTargets.find((info) => info.bundle);
if (bundledTarget) {
// If there's one that requires bundling, set to return a rejected promise.
const errorMessage = `The target ${bundledTarget.name} requires bundling so it can't be ` +
`included by ${target.name}`;
result = Promise.reject(new Error(errorMessage));
} else {
/**
* If there are no included targets or none that requires bundling, continue...
* Make sure the build directory exists.
*/
result = fs.ensureDir(target.paths.build)
// Get all the items on the source directory.
.then(() => fs.readdir(target.paths.source))
// Copy everything.
.then((items) => this.copier(
target.paths.source,
target.paths.build,
items
))
.then(() => {
this.appLogger.success(
`The files for '${target.name}' have been successfully copied (${target.paths.build})`
);
let nextStep;
// If there are targets to include...
if (includedTargets.length) {
// ...chain their promises.
nextStep = Promise.all(includedTargets.map((info) => this.copyTargetFiles(info)));
}
return nextStep;
})
.catch((error) => {
this.appLogger.error(`The files for '${target.name}' couldn't be copied`);
return Promise.reject(error);
});
}
return result;
}
}
/**
* The service provider that once registered on the app container will set an instance of
* `BuildCopier` as the `buildCopier` service.
* @example
* // Register it on the container
* container.register(buildCopier);
* // Getting access to the service instance
* const buildCopier = container.get('buildCopier');
* @type {Provider}
*/
const buildCopier = provider((app) => {
app.set('buildCopier', () => new BuildCopier(
app.get('copier'),
app.get('appLogger'),
app.get('events'),
app.get('pathUtils'),
app.get('projectConfiguration').getConfig(),
app.get('targets')
));
});
module.exports = {
BuildCopier,
buildCopier,
};