Home Manual Reference Source

src/services/targets/targets.js

  1. const path = require('path');
  2. const fs = require('fs-extra');
  3. const ObjectUtils = require('wootils/shared/objectUtils');
  4. const { AppConfiguration } = require('wootils/node/appConfiguration');
  5. const { provider } = require('jimple');
  6. /**
  7. * This service is in charge of loading and managing the project targets information.
  8. */
  9. class Targets {
  10. /**
  11. * @param {DotEnvUtils} dotEnvUtils To read files with environment
  12. * variables for the targets and
  13. * inject them.
  14. * @param {Events} events Used to reduce a target information
  15. * after loading it.
  16. * @param {EnvironmentUtils} environmentUtils To send to the configuration
  17. * service used by the browser targets.
  18. * @param {Object} packageInfo The project's `package.json`,
  19. * necessary to get the project's name
  20. * and use it as the name of the
  21. * default target.
  22. * @param {PathUtils} pathUtils Used to build the targets paths.
  23. * @param {ProjectConfigurationSettings} projectConfiguration To read the targets and their
  24. * templates.
  25. * @param {RootRequire} rootRequire To send to the configuration
  26. * service used by the browser targets.
  27. * @param {Utils} utils To replace plaholders on the targets
  28. * paths.
  29. */
  30. constructor(
  31. dotEnvUtils,
  32. events,
  33. environmentUtils,
  34. packageInfo,
  35. pathUtils,
  36. projectConfiguration,
  37. rootRequire,
  38. utils
  39. ) {
  40. /**
  41. * A local reference for the `dotEnvUtils` service.
  42. * @type {DotEnvUtils}
  43. */
  44. this.dotEnvUtils = dotEnvUtils;
  45. /**
  46. * A local reference for the `events` service.
  47. * @type {Events}
  48. */
  49. this.events = events;
  50. /**
  51. * A local reference for the `environmentUtils` service.
  52. * @type {EnvironmentUtils}
  53. */
  54. this.environmentUtils = environmentUtils;
  55. /**
  56. * The information of the project's `package.json`.
  57. * @type {Object}
  58. */
  59. this.packageInfo = packageInfo;
  60. /**
  61. * A local reference for the `pathUtils` service.
  62. * @type {PathUtils}
  63. */
  64. this.pathUtils = pathUtils;
  65. /**
  66. * All the project settings.
  67. * @type {ProjectConfigurationSettings}
  68. */
  69. this.projectConfiguration = projectConfiguration;
  70. /**
  71. * A local reference for the `rootRequire` function service.
  72. * @type {RootRequire}
  73. */
  74. this.rootRequire = rootRequire;
  75. /**
  76. * A local reference for the `utils` service.
  77. * @type {Utils}
  78. */
  79. this.utils = utils;
  80. /**
  81. * A dictionary that will be filled with the targets information.
  82. * @type {Object}
  83. */
  84. this.targets = {};
  85. /**
  86. * A simple regular expression to validate a target type.
  87. * @type {RegExp}
  88. */
  89. this.typesValidationRegex = /^(?:node|browser)$/i;
  90. /**
  91. * The default type a target will be if it doesn't have a `type` property.
  92. * @type {string}
  93. */
  94. this.defaultType = 'node';
  95. this.loadTargets();
  96. }
  97. /**
  98. * Loads and build the target information.
  99. * This method emits the reducer event `target-load` with the information of a loaded target and
  100. * expects an object with a target information on return.
  101. * @throws {Error} If a target has a type but it doesn't match a supported type
  102. * (`node` or `browser`).
  103. * @throws {Error} If a target requires bundling but there's no build engine installed.
  104. */
  105. loadTargets() {
  106. const {
  107. targets,
  108. paths: { source, build },
  109. targetsTemplates,
  110. } = this.projectConfiguration;
  111. // Loop all the targets on the project configuration...
  112. Object.keys(targets).forEach((name) => {
  113. const target = targets[name];
  114. // Normalize the information from the target definition.
  115. const info = this._normalizeTargetDefinition(name, target);
  116. // Get the type template.
  117. const template = targetsTemplates[info.type];
  118. /**
  119. * Create the new target information by merging the template, the target information from
  120. * the configuration and the information defined by this method.
  121. */
  122. const newTarget = ObjectUtils.merge(template, target, {
  123. name,
  124. type: info.type,
  125. paths: {
  126. source: '',
  127. build: '',
  128. },
  129. folders: {
  130. source: '',
  131. build: '',
  132. },
  133. is: info.is,
  134. });
  135. // Validate if the target requires bundling and the `engine` setting is invalid.
  136. this._validateTargetEngine(newTarget);
  137. // Check if there are missing entries and fill them with the default value.
  138. newTarget.entry = this._normalizeTargetEntry(newTarget.entry);
  139. // Check if there are missing entries and merge them with the default value.
  140. newTarget.output = this._normalizeTargetOutput(newTarget.output);
  141. /**
  142. * Keep the original output settings without the placeholders so internal services or
  143. * plugins can use them.
  144. */
  145. newTarget.originalOutput = ObjectUtils.copy(newTarget.output);
  146. // Replace placeholders on the output settings
  147. newTarget.output = this._replaceTargetOutputPlaceholders(newTarget);
  148.  
  149. /**
  150. * To avoid merge issues with arrays (they get merge "by index"), if the target already
  151. * had a defined list of files for the dotEnv feature, overwrite whatever is on the
  152. * template.
  153. */
  154. if (target.dotEnv && target.dotEnv.files && target.dotEnv.files.length) {
  155. newTarget.dotEnv.files = target.dotEnv.files;
  156. }
  157.  
  158. // If the target has an `html` setting...
  159. if (newTarget.html) {
  160. // Check if there are missing settings that should be replaced with a fallback.
  161. newTarget.html = this._normalizeTargetHTML(newTarget.html);
  162. }
  163. /**
  164. * If the target doesn't have the `typeScript` option enabled but one of the entry files
  165. * extension is `.ts`, turn on the option; and if the extension is `.tsx`, set the
  166. * framework to React.
  167. */
  168. if (!newTarget.typeScript) {
  169. const hasATSFile = Object.keys(newTarget.entry).some((entryEnv) => {
  170. let found = false;
  171. const entryFile = newTarget.entry[entryEnv];
  172. if (entryFile) {
  173. found = entryFile.match(/\.tsx?$/i);
  174. if (
  175. found &&
  176. entryFile.match(/\.tsx$/i) &&
  177. typeof newTarget.framework === 'undefined'
  178. ) {
  179. newTarget.framework = 'react';
  180. }
  181. }
  182.  
  183. return found;
  184. });
  185.  
  186. if (hasATSFile) {
  187. newTarget.typeScript = true;
  188. }
  189. }
  190.  
  191. // Check if the target should be transpiled (You can't use types without transpilation).
  192. if (!newTarget.transpile && (newTarget.flow || newTarget.typeScript)) {
  193. newTarget.transpile = true;
  194. }
  195.  
  196. // Generate the target paths and folders.
  197. newTarget.folders.source = newTarget.hasFolder ?
  198. path.join(source, info.sourceFolderName) :
  199. source;
  200. newTarget.paths.source = this.pathUtils.join(newTarget.folders.source);
  201.  
  202. newTarget.folders.build = path.join(build, info.buildFolderName);
  203. newTarget.paths.build = this.pathUtils.join(newTarget.folders.build);
  204. // Reduce the target information and save it on the service dictionary.
  205. this.targets[name] = this.events.reduce('target-load', newTarget);
  206. });
  207. }
  208. /**
  209. * Get all the registered targets information on a dictionary that uses their names as keys.
  210. * @return {Object}
  211. */
  212. getTargets() {
  213. return this.targets;
  214. }
  215. /**
  216. * Validate whether a target exists or not.
  217. * @param {string} name The target name.
  218. * @return {boolean}
  219. */
  220. targetExists(name) {
  221. return !!this.getTargets()[name];
  222. }
  223. /**
  224. * Get a target information by its name.
  225. * @param {string} name The target name.
  226. * @return {Target}
  227. * @throws {Error} If there's no target with the given name.
  228. */
  229. getTarget(name) {
  230. const target = this.getTargets()[name];
  231. if (!target) {
  232. throw new Error(`The required target doesn't exist: ${name}`);
  233. }
  234.  
  235. return target;
  236. }
  237. /**
  238. * Returns the target with the name of project (specified on the `package.json`) and if there's
  239. * no target with that name, then the first one, using a list of the targets name on alphabetical
  240. * order.
  241. * @param {string} [type=''] A specific target type, `node` or `browser`.
  242. * @return {Target}
  243. * @throws {Error} If the project has no targets
  244. * @throws {Error} If the project has no targets of the specified type.
  245. * @throws {Error} If a specified target type is invalid.
  246. */
  247. getDefaultTarget(type = '') {
  248. const allTargets = this.getTargets();
  249. let targets = {};
  250. if (type && !['node', 'browser'].includes(type)) {
  251. throw new Error(`Invalid target type: ${type}`);
  252. } else if (type) {
  253. Object.keys(allTargets).forEach((targetName) => {
  254. const target = allTargets[targetName];
  255. if (target.type === type) {
  256. targets[targetName] = target;
  257. }
  258. });
  259. } else {
  260. targets = allTargets;
  261. }
  262.  
  263. const names = Object.keys(targets).sort();
  264. let target;
  265. if (names.length) {
  266. const { name: projectName } = this.packageInfo;
  267. target = targets[projectName] || targets[names[0]];
  268. } else if (type) {
  269. throw new Error(`The project doesn't have any targets of the required type: ${type}`);
  270. } else {
  271. throw new Error('The project doesn\'t have any targets');
  272. }
  273.  
  274. return target;
  275. }
  276. /**
  277. * Find a target by a given filepath.
  278. * @param {string} file The path of the file that should match with a target path.
  279. * @return {Target}
  280. * @throws {Error} If no target is found.
  281. */
  282. findTargetForFile(file) {
  283. const targets = this.getTargets();
  284. const targetName = Object.keys(targets)
  285. .find((name) => file.includes(targets[name].paths.source));
  286.  
  287. if (!targetName) {
  288. throw new Error(`A target couldn't be find for the following file: ${file}`);
  289. }
  290.  
  291. return targets[targetName];
  292. }
  293. /**
  294. * Gets an _'App Configuration'_ for a browser target. This is a utility projext provides for
  295. * browser targets as they can't load configuration files dynamically, so on the building process,
  296. * projext uses this service to load the configuration and then injects it on the target bundle.
  297. * @param {Target} target The target information.
  298. * @return {Object}
  299. * @property {Object} configuration The target _'App Configuration'_.
  300. * @property {Array} files The list of files loaded in order to create the
  301. * configuration.
  302. * @throws {Error} If the given target is not a browser target.
  303. */
  304. getBrowserTargetConfiguration(target) {
  305. if (target.is.node) {
  306. throw new Error('Only browser targets can generate configuration on the building process');
  307. }
  308. // Get the configuration settings from the target information.
  309. const {
  310. name,
  311. configuration: {
  312. enabled,
  313. default: defaultConfiguration,
  314. path: configurationsPath,
  315. hasFolder,
  316. environmentVariable,
  317. loadFromEnvironment,
  318. filenameFormat,
  319. },
  320. } = target;
  321. const result = {
  322. configuration: {},
  323. files: [],
  324. };
  325. // If the configuration feature is enabled...
  326. if (enabled) {
  327. // Define the path where the configuration files are located.
  328. let configsPath = configurationsPath;
  329. if (hasFolder) {
  330. configsPath += `${name}/`;
  331. }
  332. // Prepare the filename format the `AppConfiguration` class uses.
  333. const filenameNewFormat = filenameFormat
  334. .replace(/\[target-name\]/ig, name)
  335. .replace(/\[configuration-name\]/ig, '[name]');
  336.  
  337. /**
  338. * The idea of `files` and this small wrapper around `rootRequire` is for the method to be
  339. * able to identify all the external files that were involved on the configuration creation.
  340. * Then the method can return the list, so the build engine can also watch for those files
  341. * and reload the target not only when the source changes, but when the config changes too.
  342. */
  343. const files = [];
  344. const rootRequireAndSave = (filepath) => {
  345. files.push(filepath);
  346. // Delete the file cache entry so it can be rebuilt in case env vars were updated.
  347. delete require.cache[this.pathUtils.join(filepath)];
  348. return this.rootRequire(filepath);
  349. };
  350.  
  351. let defaultConfig = {};
  352. // If the feature options include a default configuration...
  353. if (defaultConfiguration) {
  354. // ...use it.
  355. defaultConfig = defaultConfiguration;
  356. } else {
  357. // ...otherwise, load it from a configuration file.
  358. const defaultConfigPath = `${configsPath}${name}.config.js`;
  359. defaultConfig = rootRequireAndSave(defaultConfigPath);
  360. }
  361.  
  362. /**
  363. * Create a new instance of `AppConfiguration` in order to handle the environment and the
  364. * merging of the configurations.
  365. */
  366. const appConfiguration = new AppConfiguration(
  367. this.environmentUtils,
  368. rootRequireAndSave,
  369. name,
  370. defaultConfig,
  371. {
  372. environmentVariable,
  373. path: configsPath,
  374. filenameFormat: filenameNewFormat,
  375. }
  376. );
  377. // If the feature supports loading a configuration using an environment variable...
  378. if (loadFromEnvironment) {
  379. // ...Tell the instance of `AppConfiguration` to look for it.
  380. appConfiguration.loadFromEnvironment();
  381. }
  382. // Finally, set to return the configuration generated by the service.
  383. result.configuration = appConfiguration.getConfig();
  384. result.files = files;
  385. }
  386.  
  387. return result;
  388. }
  389. /**
  390. * Loads the environment file(s) for a target and, if specified, inject their variables.
  391. * This method uses the `target-environment-variables` reducer event, which receives the
  392. * dictionary with the variables for the target, the target information and the build type; it
  393. * expects an updated dictionary of variables in return.
  394. * @param {Target} target The target information.
  395. * @param {string} [buildType='development'] The type of bundle projext is generating or the
  396. * environment a Node target is being executed for.
  397. * @param {boolean} [inject=true] Whether or not to inject the variables after
  398. * loading them.
  399. * @return {Object} A dictionary with the target variables that were injected in the environment.
  400. */
  401. loadTargetDotEnvFile(target, buildType = 'development', inject = true) {
  402. let result;
  403. if (target.dotEnv.enabled && target.dotEnv.files.length) {
  404. const files = target.dotEnv.files.map((file) => (
  405. file
  406. .replace(/\[target-name\]/ig, target.name)
  407. .replace(/\[build-type\]/ig, buildType)
  408. ));
  409. const parsed = this.dotEnvUtils.load(files, target.dotEnv.extend);
  410. if (parsed.loaded) {
  411. result = this.events.reduce(
  412. 'target-environment-variables',
  413. parsed.variables,
  414. target,
  415. buildType
  416. );
  417.  
  418. if (inject) {
  419. this.dotEnvUtils.inject(result);
  420. }
  421. }
  422. }
  423.  
  424. return result || {};
  425. }
  426. /**
  427. * Gets a list with the information for the files the target needs to copy during the
  428. * bundling process.
  429. * This method uses the `target-copy-files` reducer event, which receives the list of files to
  430. * copy, the target information and the build type; it expects an updated list on return.
  431. * The reducer event can be used to inject a {@link TargetExtraFileTransform} function.
  432. * @param {Target} target The target information.
  433. * @param {string} [buildType='development'] The type of bundle projext is generating.
  434. * @return {Array} A list of {@link TargetExtraFile}s.
  435. * @throws {Error} If the target type is `node` but bundling is disabled. There's no need to copy
  436. * files on a target that doesn't require bundling.
  437. * @throws {Error} If one of the files to copy doesn't exist.
  438. */
  439. getFilesToCopy(target, buildType = 'development') {
  440. // Validate the target settings
  441. if (target.is.node && !target.bundle) {
  442. throw new Error('Only targets that require bundling can copy files');
  443. }
  444. // Get the target paths.
  445. const {
  446. paths: {
  447. build,
  448. source,
  449. },
  450. } = target;
  451. // Format the list.
  452. let newList = target.copy.map((item) => {
  453. // Define an item structure.
  454. const newItem = {
  455. from: '',
  456. to: '',
  457. };
  458. /**
  459. * If the item is a string, use its name and copy it to the target distribution directory
  460. * root; but if the target is an object, just prefix its paths with the target directories.
  461. */
  462. if (typeof item === 'string') {
  463. const filename = path.basename(item);
  464. newItem.from = path.join(source, item);
  465. newItem.to = path.join(build, filename);
  466. } else {
  467. newItem.from = path.join(source, item.from);
  468. newItem.to = path.join(build, item.to);
  469. }
  470.  
  471. return newItem;
  472. });
  473.  
  474. // Reduce the list.
  475. newList = this.events.reduce('target-copy-files', newList, target, buildType);
  476.  
  477. const invalid = newList.find((item) => !fs.pathExistsSync(item.from));
  478. if (invalid) {
  479. throw new Error(`The file to copy doesn't exist: ${invalid.from}`);
  480. }
  481.  
  482. return newList;
  483. }
  484. /**
  485. * Validates a type specified on a target definition.
  486. * @param {String} name The name of the target. To generate the error message if needed.
  487. * @param {Object} definition The definition of the target on the project configuration. This
  488. * is like an incomplete {@link Target}.
  489. * @throws {Error} If a target has a type but it doesn't match
  490. * {@link Targets#typesValidationRegex}.
  491. * @access protected
  492. * @ignore
  493. */
  494. _validateTargetDefinitionType(name, definition) {
  495. if (definition.type && !this.typesValidationRegex.test(definition.type)) {
  496. throw new Error(`Target ${name} has an invalid type: ${definition.type}`);
  497. }
  498. }
  499. /**
  500. * Normalizes the information of a target definition in order for the service to create an
  501. * actual {@link Target} from it.
  502. * @param {String} name The name of the target. To generate the error message if needed.
  503. * @param {Object} definition The definition of the target on the project configuration. This
  504. * is like an incomplete {@link Target}.
  505. * @return {Object} Basic information generated from the definition.
  506. * @property {String} sourceFolderName The name of the folder where the target source
  507. * is located.
  508. * @property {String} buildFolderName The name of the folder (inside the distribution
  509. * directory) where the target will be built.
  510. * @property {String} type The target type (`node` or `browser`).
  511. * @property {TargetTypeCheck} is To check whether the target type is `node` or
  512. * `browser`.
  513. * @access protected
  514. * @ignore
  515. */
  516. _normalizeTargetDefinition(name, definition) {
  517. this._validateTargetDefinitionType(name, definition);
  518. // Define the target folders.
  519. const sourceFolderName = definition.folder || name;
  520. const buildFolderName = definition.createFolder ? sourceFolderName : '';
  521. // Define the target type.
  522. const type = definition.type || this.defaultType;
  523. const isNode = type === 'node';
  524. return {
  525. sourceFolderName,
  526. buildFolderName,
  527. type,
  528. is: {
  529. node: isNode,
  530. browser: !isNode,
  531. },
  532. };
  533. }
  534. /**
  535. * Validates if a target requires bundling but there's no build engine installed. The targets'
  536. * `engine` setting comes from the {@link ProjectConfiguration} templates, which are updated
  537. * by projext when it detects a build engine installed; so if the setting is empty, it means
  538. * that projext didn't find anything.
  539. * @param {Target} target The target information.
  540. * @throws {Error} If the target requires bundling but there's no build engine installed.
  541. * @access protected
  542. * @ignore
  543. */
  544. _validateTargetEngine(target) {
  545. if (!target.engine && (target.is.browser || target.bundle)) {
  546. throw new Error(
  547. `The target '${target.name}' requires bundling, but there's ` +
  548. 'no build engine plugin installed'
  549. );
  550. }
  551. }
  552. /**
  553. * Checks if there are missing entries that need to be replaced with the default fallback, and in
  554. * case there are, a new set of entries will be generated and returned.
  555. * @param {ProjectConfigurationTargetTemplateEntry} currentEntry
  556. * The entries defined on the target after merging it with its type template.
  557. * @return {ProjectConfigurationTargetTemplateEntry}
  558. * @ignore
  559. * @protected
  560. */
  561. _normalizeTargetEntry(currentEntry) {
  562. return this._normalizeSettingsWithDefault(currentEntry);
  563. }
  564. /**
  565. * Checks if there are missing output settings that need to be merged with the ones on the
  566. * default fallback, and in case there are, a new set of output settings will be generated and
  567. * returned.
  568. * @param {ProjectConfigurationTargetTemplateOutput} currentOutput
  569. * The output settings defined on the target after merging it with its type template.
  570. * @return {ProjectConfigurationTargetTemplateOutput}
  571. * @ignore
  572. * @protected
  573. */
  574. _normalizeTargetOutput(currentOutput) {
  575. const newOutput = Object.assign({}, currentOutput);
  576. const { default: defaultOutput } = newOutput;
  577. delete newOutput.default;
  578. if (defaultOutput) {
  579. Object.keys(newOutput).forEach((name) => {
  580. const value = newOutput[name];
  581. if (value === null) {
  582. newOutput[name] = Object.assign({}, defaultOutput);
  583. } else {
  584. newOutput[name] = ObjectUtils.merge(defaultOutput, value);
  585. Object.keys(newOutput[name]).forEach((propName) => {
  586. if (!newOutput[name][propName] && defaultOutput[propName]) {
  587. newOutput[name][propName] = defaultOutput[propName];
  588. }
  589. });
  590. }
  591. });
  592. }
  593.  
  594. return newOutput;
  595. }
  596. /**
  597. * Replace the common placeholders from a target output paths.
  598. * @param {Target} target The target information.
  599. * @return {
  600. * ProjectConfigurationNodeTargetTemplateOutput|ProjectConfigurationBoTargetTemplateOutput
  601. * }
  602. * @ignore
  603. * @protected
  604. */
  605. _replaceTargetOutputPlaceholders(target) {
  606. const placeholders = {
  607. 'target-name': target.name,
  608. hash: Date.now(),
  609. };
  610.  
  611. const newOutput = Object.assign({}, target.output);
  612. Object.keys(newOutput).forEach((name) => {
  613. const value = newOutput[name];
  614. Object.keys(value).forEach((propName) => {
  615. const propValue = newOutput[name][propName];
  616. newOutput[name][propName] = typeof propValue === 'string' ?
  617. this.utils.replacePlaceholders(
  618. propValue,
  619. placeholders
  620. ) :
  621. propValue;
  622. });
  623. });
  624.  
  625. return newOutput;
  626. }
  627. /**
  628. * Checks if there are missing HTML settings that need to be replaced with the default fallback,
  629. * and in case there are, a new set of settings will be generated and returned.
  630. * @param {ProjectConfigurationBrowserTargetTemplateHTMLSettings} currentHTML
  631. * The HTML settings defined on the target after merging it with its type template.
  632. * @return {ProjectConfigurationBrowserTargetTemplateHTMLSettings}
  633. * @ignore
  634. * @protected
  635. */
  636. _normalizeTargetHTML(currentHTML) {
  637. return this._normalizeSettingsWithDefault(currentHTML);
  638. }
  639. /**
  640. * Given a dictionary of settings that contains a `default` key, this method will check each of
  641. * the other keys and if its find any `null` value, it will replace that key value with the one
  642. * on the `default` key.
  643. * @param {Object} currentSettings The dictionary to "complete".
  644. * @property {*} default The default value that will be assigned to any other key with `null`
  645. * value.
  646. * @return {Object}
  647. * @ignore
  648. * @protected
  649. */
  650. _normalizeSettingsWithDefault(currentSettings) {
  651. const newSettings = Object.assign({}, currentSettings);
  652. const { default: defaultValue } = newSettings;
  653. delete newSettings.default;
  654. if (defaultValue !== null) {
  655. Object.keys(newSettings).forEach((name) => {
  656. if (newSettings[name] === null) {
  657. newSettings[name] = defaultValue;
  658. }
  659. });
  660. }
  661.  
  662. return newSettings;
  663. }
  664. }
  665. /**
  666. * The service provider that once registered on the app container will set an instance of
  667. * `Targets` as the `targets` service.
  668. * @example
  669. * // Register it on the container
  670. * container.register(targets);
  671. * // Getting access to the service instance
  672. * const targets = container.get('targets');
  673. * @type {Provider}
  674. */
  675. const targets = provider((app) => {
  676. app.set('targets', () => new Targets(
  677. app.get('dotEnvUtils'),
  678. app.get('events'),
  679. app.get('environmentUtils'),
  680. app.get('packageInfo'),
  681. app.get('pathUtils'),
  682. app.get('projectConfiguration').getConfig(),
  683. app.get('rootRequire'),
  684. app.get('utils')
  685. ));
  686. });
  687.  
  688. module.exports = {
  689. Targets,
  690. targets,
  691. };