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,
- };