src/services/targets/targets.js
const path = require('path');
const fs = require('fs-extra');
const ObjectUtils = require('wootils/shared/objectUtils');
const { AppConfiguration } = require('wootils/node/appConfiguration');
const { provider } = require('jimple');
/**
* This service is in charge of loading and managing the project targets information.
*/
class Targets {
/**
* @param {DotEnvUtils} dotEnvUtils To read files with environment
* variables for the targets and
* inject them.
* @param {Events} events Used to reduce a target information
* after loading it.
* @param {EnvironmentUtils} environmentUtils To send to the configuration
* service used by the browser targets.
* @param {Object} packageInfo The project's `package.json`,
* necessary to get the project's name
* and use it as the name of the
* default target.
* @param {PathUtils} pathUtils Used to build the targets paths.
* @param {ProjectConfigurationSettings} projectConfiguration To read the targets and their
* templates.
* @param {RootRequire} rootRequire To send to the configuration
* service used by the browser targets.
* @param {Utils} utils To replace plaholders on the targets
* paths.
*/
constructor(
dotEnvUtils,
events,
environmentUtils,
packageInfo,
pathUtils,
projectConfiguration,
rootRequire,
utils
) {
/**
* A local reference for the `dotEnvUtils` service.
* @type {DotEnvUtils}
*/
this.dotEnvUtils = dotEnvUtils;
/**
* A local reference for the `events` service.
* @type {Events}
*/
this.events = events;
/**
* A local reference for the `environmentUtils` service.
* @type {EnvironmentUtils}
*/
this.environmentUtils = environmentUtils;
/**
* The information of the project's `package.json`.
* @type {Object}
*/
this.packageInfo = packageInfo;
/**
* 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 `rootRequire` function service.
* @type {RootRequire}
*/
this.rootRequire = rootRequire;
/**
* A local reference for the `utils` service.
* @type {Utils}
*/
this.utils = utils;
/**
* A dictionary that will be filled with the targets information.
* @type {Object}
*/
this.targets = {};
/**
* A simple regular expression to validate a target type.
* @type {RegExp}
*/
this.typesValidationRegex = /^(?:node|browser)$/i;
/**
* The default type a target will be if it doesn't have a `type` property.
* @type {string}
*/
this.defaultType = 'node';
this.loadTargets();
}
/**
* Loads and build the target information.
* This method emits the reducer event `target-load` with the information of a loaded target and
* expects an object with a target information on return.
* @throws {Error} If a target has a type but it doesn't match a supported type
* (`node` or `browser`).
* @throws {Error} If a target requires bundling but there's no build engine installed.
*/
loadTargets() {
const {
targets,
paths: { source, build },
targetsTemplates,
} = this.projectConfiguration;
// Loop all the targets on the project configuration...
Object.keys(targets).forEach((name) => {
const target = targets[name];
// Normalize the information from the target definition.
const info = this._normalizeTargetDefinition(name, target);
// Get the type template.
const template = targetsTemplates[info.type];
/**
* Create the new target information by merging the template, the target information from
* the configuration and the information defined by this method.
*/
const newTarget = ObjectUtils.merge(template, target, {
name,
type: info.type,
paths: {
source: '',
build: '',
},
folders: {
source: '',
build: '',
},
is: info.is,
});
// Validate if the target requires bundling and the `engine` setting is invalid.
this._validateTargetEngine(newTarget);
// Check if there are missing entries and fill them with the default value.
newTarget.entry = this._normalizeTargetEntry(newTarget.entry);
// Check if there are missing entries and merge them with the default value.
newTarget.output = this._normalizeTargetOutput(newTarget.output);
/**
* Keep the original output settings without the placeholders so internal services or
* plugins can use them.
*/
newTarget.originalOutput = ObjectUtils.copy(newTarget.output);
// Replace placeholders on the output settings
newTarget.output = this._replaceTargetOutputPlaceholders(newTarget);
/**
* To avoid merge issues with arrays (they get merge "by index"), if the target already
* had a defined list of files for the dotEnv feature, overwrite whatever is on the
* template.
*/
if (target.dotEnv && target.dotEnv.files && target.dotEnv.files.length) {
newTarget.dotEnv.files = target.dotEnv.files;
}
// If the target has an `html` setting...
if (newTarget.html) {
// Check if there are missing settings that should be replaced with a fallback.
newTarget.html = this._normalizeTargetHTML(newTarget.html);
}
/**
* If the target doesn't have the `typeScript` option enabled but one of the entry files
* extension is `.ts`, turn on the option; and if the extension is `.tsx`, set the
* framework to React.
*/
if (!newTarget.typeScript) {
const hasATSFile = Object.keys(newTarget.entry).some((entryEnv) => {
let found = false;
const entryFile = newTarget.entry[entryEnv];
if (entryFile) {
found = entryFile.match(/\.tsx?$/i);
if (
found &&
entryFile.match(/\.tsx$/i) &&
typeof newTarget.framework === 'undefined'
) {
newTarget.framework = 'react';
}
}
return found;
});
if (hasATSFile) {
newTarget.typeScript = true;
}
}
// Check if the target should be transpiled (You can't use types without transpilation).
if (!newTarget.transpile && (newTarget.flow || newTarget.typeScript)) {
newTarget.transpile = true;
}
// Generate the target paths and folders.
newTarget.folders.source = newTarget.hasFolder ?
path.join(source, info.sourceFolderName) :
source;
newTarget.paths.source = this.pathUtils.join(newTarget.folders.source);
newTarget.folders.build = path.join(build, info.buildFolderName);
newTarget.paths.build = this.pathUtils.join(newTarget.folders.build);
// Reduce the target information and save it on the service dictionary.
this.targets[name] = this.events.reduce('target-load', newTarget);
});
}
/**
* Get all the registered targets information on a dictionary that uses their names as keys.
* @return {Object}
*/
getTargets() {
return this.targets;
}
/**
* Validate whether a target exists or not.
* @param {string} name The target name.
* @return {boolean}
*/
targetExists(name) {
return !!this.getTargets()[name];
}
/**
* Get a target information by its name.
* @param {string} name The target name.
* @return {Target}
* @throws {Error} If there's no target with the given name.
*/
getTarget(name) {
const target = this.getTargets()[name];
if (!target) {
throw new Error(`The required target doesn't exist: ${name}`);
}
return target;
}
/**
* Returns the target with the name of project (specified on the `package.json`) and if there's
* no target with that name, then the first one, using a list of the targets name on alphabetical
* order.
* @param {string} [type=''] A specific target type, `node` or `browser`.
* @return {Target}
* @throws {Error} If the project has no targets
* @throws {Error} If the project has no targets of the specified type.
* @throws {Error} If a specified target type is invalid.
*/
getDefaultTarget(type = '') {
const allTargets = this.getTargets();
let targets = {};
if (type && !['node', 'browser'].includes(type)) {
throw new Error(`Invalid target type: ${type}`);
} else if (type) {
Object.keys(allTargets).forEach((targetName) => {
const target = allTargets[targetName];
if (target.type === type) {
targets[targetName] = target;
}
});
} else {
targets = allTargets;
}
const names = Object.keys(targets).sort();
let target;
if (names.length) {
const { name: projectName } = this.packageInfo;
target = targets[projectName] || targets[names[0]];
} else if (type) {
throw new Error(`The project doesn't have any targets of the required type: ${type}`);
} else {
throw new Error('The project doesn\'t have any targets');
}
return target;
}
/**
* Find a target by a given filepath.
* @param {string} file The path of the file that should match with a target path.
* @return {Target}
* @throws {Error} If no target is found.
*/
findTargetForFile(file) {
const targets = this.getTargets();
const targetName = Object.keys(targets)
.find((name) => file.includes(targets[name].paths.source));
if (!targetName) {
throw new Error(`A target couldn't be find for the following file: ${file}`);
}
return targets[targetName];
}
/**
* Gets an _'App Configuration'_ for a browser target. This is a utility projext provides for
* browser targets as they can't load configuration files dynamically, so on the building process,
* projext uses this service to load the configuration and then injects it on the target bundle.
* @param {Target} target The target information.
* @return {Object}
* @property {Object} configuration The target _'App Configuration'_.
* @property {Array} files The list of files loaded in order to create the
* configuration.
* @throws {Error} If the given target is not a browser target.
*/
getBrowserTargetConfiguration(target) {
if (target.is.node) {
throw new Error('Only browser targets can generate configuration on the building process');
}
// Get the configuration settings from the target information.
const {
name,
configuration: {
enabled,
default: defaultConfiguration,
path: configurationsPath,
hasFolder,
environmentVariable,
loadFromEnvironment,
filenameFormat,
},
} = target;
const result = {
configuration: {},
files: [],
};
// If the configuration feature is enabled...
if (enabled) {
// Define the path where the configuration files are located.
let configsPath = configurationsPath;
if (hasFolder) {
configsPath += `${name}/`;
}
// Prepare the filename format the `AppConfiguration` class uses.
const filenameNewFormat = filenameFormat
.replace(/\[target-name\]/ig, name)
.replace(/\[configuration-name\]/ig, '[name]');
/**
* The idea of `files` and this small wrapper around `rootRequire` is for the method to be
* able to identify all the external files that were involved on the configuration creation.
* Then the method can return the list, so the build engine can also watch for those files
* and reload the target not only when the source changes, but when the config changes too.
*/
const files = [];
const rootRequireAndSave = (filepath) => {
files.push(filepath);
// Delete the file cache entry so it can be rebuilt in case env vars were updated.
delete require.cache[this.pathUtils.join(filepath)];
return this.rootRequire(filepath);
};
let defaultConfig = {};
// If the feature options include a default configuration...
if (defaultConfiguration) {
// ...use it.
defaultConfig = defaultConfiguration;
} else {
// ...otherwise, load it from a configuration file.
const defaultConfigPath = `${configsPath}${name}.config.js`;
defaultConfig = rootRequireAndSave(defaultConfigPath);
}
/**
* Create a new instance of `AppConfiguration` in order to handle the environment and the
* merging of the configurations.
*/
const appConfiguration = new AppConfiguration(
this.environmentUtils,
rootRequireAndSave,
name,
defaultConfig,
{
environmentVariable,
path: configsPath,
filenameFormat: filenameNewFormat,
}
);
// If the feature supports loading a configuration using an environment variable...
if (loadFromEnvironment) {
// ...Tell the instance of `AppConfiguration` to look for it.
appConfiguration.loadFromEnvironment();
}
// Finally, set to return the configuration generated by the service.
result.configuration = appConfiguration.getConfig();
result.files = files;
}
return result;
}
/**
* Loads the environment file(s) for a target and, if specified, inject their variables.
* This method uses the `target-environment-variables` reducer event, which receives the
* dictionary with the variables for the target, the target information and the build type; it
* expects an updated dictionary of variables in return.
* @param {Target} target The target information.
* @param {string} [buildType='development'] The type of bundle projext is generating or the
* environment a Node target is being executed for.
* @param {boolean} [inject=true] Whether or not to inject the variables after
* loading them.
* @return {Object} A dictionary with the target variables that were injected in the environment.
*/
loadTargetDotEnvFile(target, buildType = 'development', inject = true) {
let result;
if (target.dotEnv.enabled && target.dotEnv.files.length) {
const files = target.dotEnv.files.map((file) => (
file
.replace(/\[target-name\]/ig, target.name)
.replace(/\[build-type\]/ig, buildType)
));
const parsed = this.dotEnvUtils.load(files, target.dotEnv.extend);
if (parsed.loaded) {
result = this.events.reduce(
'target-environment-variables',
parsed.variables,
target,
buildType
);
if (inject) {
this.dotEnvUtils.inject(result);
}
}
}
return result || {};
}
/**
* Gets a list with the information for the files the target needs to copy during the
* bundling process.
* This method uses the `target-copy-files` reducer event, which receives the list of files to
* copy, the target information and the build type; it expects an updated list on return.
* The reducer event can be used to inject a {@link TargetExtraFileTransform} function.
* @param {Target} target The target information.
* @param {string} [buildType='development'] The type of bundle projext is generating.
* @return {Array} A list of {@link TargetExtraFile}s.
* @throws {Error} If the target type is `node` but bundling is disabled. There's no need to copy
* files on a target that doesn't require bundling.
* @throws {Error} If one of the files to copy doesn't exist.
*/
getFilesToCopy(target, buildType = 'development') {
// Validate the target settings
if (target.is.node && !target.bundle) {
throw new Error('Only targets that require bundling can copy files');
}
// Get the target paths.
const {
paths: {
build,
source,
},
} = target;
// Format the list.
let newList = target.copy.map((item) => {
// Define an item structure.
const newItem = {
from: '',
to: '',
};
/**
* If the item is a string, use its name and copy it to the target distribution directory
* root; but if the target is an object, just prefix its paths with the target directories.
*/
if (typeof item === 'string') {
const filename = path.basename(item);
newItem.from = path.join(source, item);
newItem.to = path.join(build, filename);
} else {
newItem.from = path.join(source, item.from);
newItem.to = path.join(build, item.to);
}
return newItem;
});
// Reduce the list.
newList = this.events.reduce('target-copy-files', newList, target, buildType);
const invalid = newList.find((item) => !fs.pathExistsSync(item.from));
if (invalid) {
throw new Error(`The file to copy doesn't exist: ${invalid.from}`);
}
return newList;
}
/**
* Validates a type specified on a target definition.
* @param {String} name The name of the target. To generate the error message if needed.
* @param {Object} definition The definition of the target on the project configuration. This
* is like an incomplete {@link Target}.
* @throws {Error} If a target has a type but it doesn't match
* {@link Targets#typesValidationRegex}.
* @access protected
* @ignore
*/
_validateTargetDefinitionType(name, definition) {
if (definition.type && !this.typesValidationRegex.test(definition.type)) {
throw new Error(`Target ${name} has an invalid type: ${definition.type}`);
}
}
/**
* Normalizes the information of a target definition in order for the service to create an
* actual {@link Target} from it.
* @param {String} name The name of the target. To generate the error message if needed.
* @param {Object} definition The definition of the target on the project configuration. This
* is like an incomplete {@link Target}.
* @return {Object} Basic information generated from the definition.
* @property {String} sourceFolderName The name of the folder where the target source
* is located.
* @property {String} buildFolderName The name of the folder (inside the distribution
* directory) where the target will be built.
* @property {String} type The target type (`node` or `browser`).
* @property {TargetTypeCheck} is To check whether the target type is `node` or
* `browser`.
* @access protected
* @ignore
*/
_normalizeTargetDefinition(name, definition) {
this._validateTargetDefinitionType(name, definition);
// Define the target folders.
const sourceFolderName = definition.folder || name;
const buildFolderName = definition.createFolder ? sourceFolderName : '';
// Define the target type.
const type = definition.type || this.defaultType;
const isNode = type === 'node';
return {
sourceFolderName,
buildFolderName,
type,
is: {
node: isNode,
browser: !isNode,
},
};
}
/**
* Validates if a target requires bundling but there's no build engine installed. The targets'
* `engine` setting comes from the {@link ProjectConfiguration} templates, which are updated
* by projext when it detects a build engine installed; so if the setting is empty, it means
* that projext didn't find anything.
* @param {Target} target The target information.
* @throws {Error} If the target requires bundling but there's no build engine installed.
* @access protected
* @ignore
*/
_validateTargetEngine(target) {
if (!target.engine && (target.is.browser || target.bundle)) {
throw new Error(
`The target '${target.name}' requires bundling, but there's ` +
'no build engine plugin installed'
);
}
}
/**
* Checks if there are missing entries that need to be replaced with the default fallback, and in
* case there are, a new set of entries will be generated and returned.
* @param {ProjectConfigurationTargetTemplateEntry} currentEntry
* The entries defined on the target after merging it with its type template.
* @return {ProjectConfigurationTargetTemplateEntry}
* @ignore
* @protected
*/
_normalizeTargetEntry(currentEntry) {
return this._normalizeSettingsWithDefault(currentEntry);
}
/**
* Checks if there are missing output settings that need to be merged with the ones on the
* default fallback, and in case there are, a new set of output settings will be generated and
* returned.
* @param {ProjectConfigurationTargetTemplateOutput} currentOutput
* The output settings defined on the target after merging it with its type template.
* @return {ProjectConfigurationTargetTemplateOutput}
* @ignore
* @protected
*/
_normalizeTargetOutput(currentOutput) {
const newOutput = Object.assign({}, currentOutput);
const { default: defaultOutput } = newOutput;
delete newOutput.default;
if (defaultOutput) {
Object.keys(newOutput).forEach((name) => {
const value = newOutput[name];
if (value === null) {
newOutput[name] = Object.assign({}, defaultOutput);
} else {
newOutput[name] = ObjectUtils.merge(defaultOutput, value);
Object.keys(newOutput[name]).forEach((propName) => {
if (!newOutput[name][propName] && defaultOutput[propName]) {
newOutput[name][propName] = defaultOutput[propName];
}
});
}
});
}
return newOutput;
}
/**
* Replace the common placeholders from a target output paths.
* @param {Target} target The target information.
* @return {
* ProjectConfigurationNodeTargetTemplateOutput|ProjectConfigurationBoTargetTemplateOutput
* }
* @ignore
* @protected
*/
_replaceTargetOutputPlaceholders(target) {
const placeholders = {
'target-name': target.name,
hash: Date.now(),
};
const newOutput = Object.assign({}, target.output);
Object.keys(newOutput).forEach((name) => {
const value = newOutput[name];
Object.keys(value).forEach((propName) => {
const propValue = newOutput[name][propName];
newOutput[name][propName] = typeof propValue === 'string' ?
this.utils.replacePlaceholders(
propValue,
placeholders
) :
propValue;
});
});
return newOutput;
}
/**
* Checks if there are missing HTML settings that need to be replaced with the default fallback,
* and in case there are, a new set of settings will be generated and returned.
* @param {ProjectConfigurationBrowserTargetTemplateHTMLSettings} currentHTML
* The HTML settings defined on the target after merging it with its type template.
* @return {ProjectConfigurationBrowserTargetTemplateHTMLSettings}
* @ignore
* @protected
*/
_normalizeTargetHTML(currentHTML) {
return this._normalizeSettingsWithDefault(currentHTML);
}
/**
* Given a dictionary of settings that contains a `default` key, this method will check each of
* the other keys and if its find any `null` value, it will replace that key value with the one
* on the `default` key.
* @param {Object} currentSettings The dictionary to "complete".
* @property {*} default The default value that will be assigned to any other key with `null`
* value.
* @return {Object}
* @ignore
* @protected
*/
_normalizeSettingsWithDefault(currentSettings) {
const newSettings = Object.assign({}, currentSettings);
const { default: defaultValue } = newSettings;
delete newSettings.default;
if (defaultValue !== null) {
Object.keys(newSettings).forEach((name) => {
if (newSettings[name] === null) {
newSettings[name] = defaultValue;
}
});
}
return newSettings;
}
}
/**
* The service provider that once registered on the app container will set an instance of
* `Targets` as the `targets` service.
* @example
* // Register it on the container
* container.register(targets);
* // Getting access to the service instance
* const targets = container.get('targets');
* @type {Provider}
*/
const targets = provider((app) => {
app.set('targets', () => new Targets(
app.get('dotEnvUtils'),
app.get('events'),
app.get('environmentUtils'),
app.get('packageInfo'),
app.get('pathUtils'),
app.get('projectConfiguration').getConfig(),
app.get('rootRequire'),
app.get('utils')
));
});
module.exports = {
Targets,
targets,
};