Home Reference Source

src/services/utils/projextPlugin.js

const { provider } = require('jimple');
/**
 * This service handles all interaction between the plugin and projext. It takes care of validating
 * if projext is installed, registering the necessary events and generating the build commands for
 * when the plugin is used on a development environment.
 */
class ProjextPlugin {
  /**
   * Class constructor.
   * @param {Object}     info       The plugin's `package.json`. The service uses it to get the
   *                                name and send it on the build commands as the `--plugin` flag.
   * @param {RunnerFile} runnerFile To be able to update the runner file when a target is built.
   */
  constructor(info, runnerFile) {
    /**
     * The name of the plugin as it's defined on the `package.json`. It's used on the generated
     * build command(s) as the `--plugin` option. The flag is verified by the plugin in order to
     * building dependencies (other targets) when a target is running on a development
     * environment.
     * @type {string}
     */
    this.pluginName = info.name;
    /**
     * A local reference for the `runnerFile` service.
     * @type {RunnerFile}
     */
    this.runnerFile = runnerFile;
    /**
     * The name of the option flag the service will add on the build commands.
     * @type {string}
     */
    this._pluginFlagName = 'plugin';
    /**
     * Whether or not projext is installed on the current environment.
     * @type {boolean}
     * @ignore
     * @access protected
     */
    this._installed = this._detectInstallation();
    /**
     * When running along side projext, when the plugin gets registered, this property will hold a
     * reference to the projext instance.
     * @type {?Projext}
     * @ignore
     * @access protected
     */
    this._instance = null;
  }
  /**
   * Check whether projext is installed or not.
   * @return {boolean}
   */
  isInstalled() {
    return this._installed;
  }
  /**
   * Register all the necessary events for the plugin to work:
   * - Update the target information on the runner file when the target build command is generated.
   * - Add the runner file to the list of files projext copies.
   * - Update the runner file version when the revision file is created.
   *
   * @param {Projext} instance The projext instance that is registering the plugin.
   */
  registerPlugin(instance) {
    this._setInstance(instance);
    const events = this.get('events');
    events.once('build-target-commands-list', (commands, params, unknownOptions) => (
      this._updateBuildCommands(commands, params, unknownOptions)
    ));

    events.once('project-files-to-copy', (list) => this._updateCopyList(list));
    events.once('revision-file-created', (version) => this._updateFileVersion(version));
  }
  /**
   * Get a service from projext.
   * @param {string} service The service name.
   * @return {*}
   * @throws {Error} If the plugin hasn't been registered.
   */
  get(service) {
    if (!this._instance) {
      throw new Error('You can\'t access projext services if the plugin is not installed');
    }

    return this._instance.get(service);
  }
  /**
   * Generate a projext build command for one or more targets.
   * @param {string|Array} target                    A target name or a list of them.
   * @param {Object}       [args={}]                 A dictionary of arguments and their values to
   *                                                 send on the command. If this dictionary
   *                                                 contains a `target` key, it will be ignored
   *                                                 and removed, since it's the one used to send
   *                                                 the target name this method uses as parameter.
   * @param {string}       [environmentVariables=''] Environment variables to prefix the command
   *                                                 with. For example: `NODE_ENV=production`.
   * @return {Array} No matter if you used a single (`string`) target or a list (`Array`), it will
   *                 always return a list (`Array`) of commands.
   */
  getBuildCommandForTarget(target, args = {}, environmentVariables = '') {
    const list = Array.isArray(target) ? target : [target];
    const newArgs = Object.assign({}, args);
    delete newArgs.target;
    const result = list
    .map((name) => (
      this.getBuildCommand(
        Object.assign(
          { target: name },
          newArgs
        ),
        environmentVariables
      )
    ));

    return result;
  }
  /**
   * Generate a projext build command. If not overwritten by the `args` parameter, this method
   * sends an empty `target` and a `plugin` argument with the value of the `pluginName` property.
   * @param {Object} args                      A dictionary of arguments and their values to send
   *                                           on the command.
   * @param {string} [environmentVariables=''] Environment variables to prefix the command with.
   *                                           For example: `NODE_ENV=production`
   * @return {string}
   * @throws {Error} If projext is not installed and/or it couldn't access is instance.
   */
  getBuildCommand(args, environmentVariables = '') {
    this._loadInstalledInstanceIfNeeded();
    if (!this.isInstalled() || !this._instance) {
      throw new Error('You can\'t generate a build command if projext is not installed');
    }
    // Get the environment variables to append to the command.
    const env = environmentVariables ? `${environmentVariables} ` : '';
    // Get the service that will generate the build command.
    const projextCLIBuildCommand = this.get('cliBuildCommand');
    /**
     * If the projext CLI was instantiated, the service will have the name of the program, as it
     * gets set when the commands get registered; but if not, it means that we need to go to main
     * CLI service and get it from there.
     */
    let program = '';
    if (!projextCLIBuildCommand.cliName) {
      const cliName = this.get('cli').name;
      program = `${cliName} `;
    }
    // Generate the build command
    const command = projextCLIBuildCommand.generate(Object.assign(
      {
        target: '',
        [this._pluginFlagName]: this.pluginName,
      },
      args
    )).trim();
    // Prepend the environment variables and the program name, if needed.
    const result = `${env}${program}${command}`;
    // Return the final command.
    return result;
  }
  /**
   * Set the projext instance.
   * @param {Projext} instance The projext instance accessed either from registering the plugin or
   *                           by requiring the module directly.
   */
  _setInstance(instance) {
    this._instance = instance;
  }
  /**
   * Get an instance of projext by requiring the module.
   * @return {?Projext} If something is wrong with the module or projext is not installed, it will
   *                    return `null`.
   */
  _getInstalledInstance() {
    let instance;
    try {
      // eslint-disable-next-line global-require, node/no-missing-require, import/no-unresolved
      instance = require('projext/index');
    } catch (ignore) {
      instance = null;
    }

    return instance;
  }
  /**
   * If projext is installed but the plugin wasn't registerd (probably because the plugin was
   * executed from its own CLI), this method will try to set the instance by requiring the module.
   */
  _loadInstalledInstanceIfNeeded() {
    if (this.isInstalled() && !this._instance) {
      this._setInstance(this._getInstalledInstance());
    }
  }
  /**
   * Detect whether or not projext is installed on the current environment.
   * @return {boolean}
   */
  _detectInstallation() {
    let installed = true;
    try {
      // eslint-disable-next-line global-require, node/no-missing-require, import/no-unresolved
      require('projext');
    } catch (ignore) {
      installed = false;
    }

    return installed;
  }
  /**
   * This method gets called when projext is creating the build commands for a target. It takes
   * care of updating the runner file with the target information and, if the target needs other
   * targes to be built first in order to run, injecting the commands for building those targets.
   * @param {Array}                 commands       The list of commands projext uses to build and
   *                                               run the target.
   * @param {CLIBuildCommandParams} params         A dictionary with all the required information
   *                                               the service needs to run the command: The
   *                                               target information, the build type, whether or
   *                                               not the target will be executed, etc.
   * @param {Object}                unknownOptions Like `options`, this is also a dictionary of
   *                                               options the original command received, the
   *                                               difference is that these ones are unknown by
   *                                               the command, as they were probably injected by
   *                                               an event. In this case, the method checks if
   *                                               the plugin option the `getBuildCommand` method
   *                                               adds is present in order to determine whether
   *                                               it should add the build commands for the
   *                                               dependencies or not.
   * @return {Array} The updated list of commands.
   */
  _updateBuildCommands(commands, params, unknownOptions) {
    // Get the distribution directory path.
    const distPath = this.get('projectConfiguration').getConfig().paths.build;
    // Get the project version.
    const version = this.get('buildVersion').getVersion();
    // Save the target information on the runner file and get it once it's parsed.
    const targetInfo = this.runnerFile.update(params.target, version, distPath);
    // Define the list of commands that are going to be returned.
    let updatedCommands;
    /**
     * If the build command was ran from the plugin, the target type is `node` (a browser target
     * wouldn't return anything from `runnerFile.update`) and it needs other targets to be built
     * before running...
     */
    if (
      unknownOptions[this._pluginFlagName] === this.pluginName &&
      params.type === 'production' &&
      targetInfo &&
      targetInfo.options.build
    ) {
      // Get the commands for the other targets.
      const newCommands = this.getBuildCommandForTarget(
        targetInfo.options.build,
        Object.assign(
          {},
          unknownOptions,
          { type: params.type }
        )
      );
      // Push them first on the list of commands projext will run.
      updatedCommands = [
        ...newCommands,
        ...commands,
      ];
    } else {
      // ...otherwise, keep the list as it was received.
      updatedCommands = commands;
    }

    // Return the list of commands for projext to run.
    return updatedCommands;
  }
  /**
   * This method gets called when projext is copying the project files to the distribution
   * directory. It just adds the runner file to the list and returns it.
   * @param {Array} list The list of files projext is going to copy.
   * @return {Array} An updated list of files, with the runner file on it.
   */
  _updateCopyList(list) {
    return [
      ...list,
      this.runnerFile.getFilename(),
    ];
  }
  /**
   * This method gets called when projext is generating a revision file and it takes care of
   * updating the runner file with the generated version.
   * @param {string} version The new version for the revision file.
   */
  _updateFileVersion(version) {
    this.runnerFile.updateVersion(version);
  }
}
/**
 * The service provider that once registered on the app container will set an instance of
 * `ProjextPlugin` as the `projextPlugin` service.
 * @example
 * // Register it on the container
 * container.register(projextPlugin);
 * // Getting access to the service instance
 * const projextPlugin = container.get('projextPlugin');
 * @type {Provider}
 */
const projextPlugin = provider((app) => {
  app.set('projextPlugin', () => new ProjextPlugin(
    app.get('info'),
    app.get('runnerFile')
  ));
});

module.exports = {
  ProjextPlugin,
  projextPlugin,
};