Home Manual Reference Source

src/services/common/babelHelper.js

const { provider } = require('jimple');

/**
 * @typedef {function} UpdateEnvPresetFunction
 * @param {Object} options The current options of the `env` preset.
 * @return {Object} The updated options for the `env` preset.
 */

/**
 * A set of utilities to easily modify a Babel configuration.
 */
class BabelHelper {
  /**
   * Adds a plugin or a list of them to a Babel configuration. If the `plugins` option doesn't
   * exist, the method will create it.
   * @param {Object}       configuration The configuration to update.
   * @param {string|Array} plugin        A plugin name or configuration `Array` (`[name, options]`),
   *                                     or a list of them.
   * @return {Object} The updated configuration.
   */
  static addPlugin(configuration, plugin) {
    return this._addConfigurationItem(configuration, plugin, 'plugins');
  }
  /**
   * Adds a preset or a list of them to a Babel configuration. If the `presets` option doesn't
   * exist, the method will create it.
   * @param {Object}       configuration The configuration to update.
   * @param {string|Array} preset        A plugin name or configuration `Array` (`[name, options]`),
   *                                     or a list of them.
   * @return {Object} The updated configuration.
   */
  static addPreset(configuration, preset) {
    return this._addConfigurationItem(configuration, preset, 'presets');
  }
  /**
   * Update the options of the `env` preset on a Babel configuration. If the `presets` option
   * doesn't exist, it will create one and add the preset. If `presets` exists and there's
   * already an `env` preset, it will update it. But if `presets` exists but there's no `env`
   * preset, it won't do anything.
   * @param {Object}                  configuration The configuration to update.
   * @param {UpdateEnvPresetFunction} updateFn      The function called in order to update the
   *                                                `env` preset options.
   * @return {Object} The updated configuration.
   */
  static updateEnvPreset(configuration, updateFn) {
    // Get a new reference for the configuration.
    const updatedConfiguration = Object.assign({}, configuration);
    /**
     * Define a flag that will eventually indicate whether the configuration needs the `presets`
     * option or not.
     */
    let needsPresets = false;
    // Define a flag that will eventually tell if the configuration has the `env` preset or not.
    let hasEnvPreset = false;
    /**
     * Define the variable that, if the configuration has an `env` preset, indicate the index of
     * the preset on the list.
     */
    let envPresetIndex = -1;
    // Define the name of `env` preset; to avoid having the string on multiple places.
    const envPresetName = '@babel/preset-env';
    // If the configuration has presets...
    if (updatedConfiguration.presets && updatedConfiguration.presets.length) {
      // ...get the index of the `env` preset.
      envPresetIndex = updatedConfiguration.presets.findIndex((preset) => {
        const [presetName] = preset;
        return presetName === envPresetName;
      });
      // Set the value of the flag that indicates if the `env` preset exists.
      hasEnvPreset = envPresetIndex > -1;
    } else {
      // ...otherwise, set the flag that indicates the configuration doesn't have presets.
      needsPresets = true;
    }
    /**
     * This is the important part: Only proceed with the update if the configuration doesn't have
     * presets or if it already has an `env` preset.
     * If the configuration already has a list of presets that doesn't include the `env` preset,
     * then it's probably a custom setup and adding it may cause conflicts.
     */
    if (needsPresets) {
      /**
       * Invoke the callback with an empty dictionary and add a new `presets` options with just the
       * `env` preset.
       */
      updatedConfiguration.presets = [
        [envPresetName, updateFn({})],
      ];
    } else if (hasEnvPreset) {
      /**
       * If the `env` preset already existed, invoke the callback with the current options in order
       * to get new ones, define a new `env` preset and replace it on the list.
       */
      const [, currentEnvPresetOptions] = updatedConfiguration.presets[envPresetIndex];
      updatedConfiguration.presets[envPresetIndex] = [
        envPresetName,
        updateFn(currentEnvPresetOptions),
      ];
    }
    // Return the updated configuration.
    return updatedConfiguration;
  }
  /**
   * Add a required feature to the `env` preset options (it will go on the `include` option).
   * @param {Object}       configuration The configuration to update.
   * @param {string|Array} feature       The name of the feature to add or a list of them.
   * @return {Object} The updated configuration.
   */
  static addEnvPresetFeature(configuration, feature) {
    // Call the method to update the `env` preset options.
    return this.updateEnvPreset(configuration, (options) => {
      // Normalize the received `feature` parameter into an `Array`.
      const features = Array.isArray(feature) ? feature : [feature];
      // Generate a new reference for the options.
      const updatedOptions = Object.assign({}, options);
      // If the options already include a list of required features...
      if (updatedOptions.include) {
        // ...push only those that are not already present.
        updatedOptions.include.push(
          ...features.filter((name) => !updatedOptions.include.includes(name))
        );
      } else {
        // ...otherwise, copy the entire list of features into the option.
        updatedOptions.include = features.slice();
      }

      // Return the updated options.
      return updatedOptions;
    });
  }
  /**
   * Disable the `env` preset `modules` option as it may cause conflict with some packages.
   * @param {Object} configuration The configuration to update.
   * @return {Object} The updated configuration.
   */
  static disableEnvPresetModules(configuration) {
    // Call the method to update the `env` preset options.
    return this.updateEnvPreset(
      configuration,
      // Return an updated dictionary of options with `modules` disabled.
      (options) => Object.assign({}, options, { modules: false })
    );
  }
  /**
   * This is a helper method for adding things that share the same structure on a Babel
   * configuration, like plugins and presets: They can be a `string` with the plugin/preset name
   * or an `Array` with the name and its options.
   * @param {Object}       configuration The configuration to update.
   * @param {string|Array} item          An item name or configuration `Array` (`[name, options]`),
   *                                     or a list of them.
   * @param {string}       property      The name of the items property (like `plugins` or
   *                                     `presets`).
   * @return {Object} The updated configuration.
   * @access protected
   * @ignore
   */
  static _addConfigurationItem(configuration, item, property) {
    let singleItem = true;
    if (Array.isArray(item)) {
      const [itemName, itemOptions] = item;
      const itemAndOptionsLength = 2;
      if (
        item.length !== itemAndOptionsLength ||
        typeof itemName !== 'string' ||
        Array.isArray(itemOptions) ||
        typeof itemOptions !== 'object'
      ) {
        singleItem = false;
      }
    }

    // Normalize the received `item` parameter into an `Array`.
    const items = singleItem ? [item] : item;
    // Get a new reference for the configuration.
    const updatedConfiguration = Object.assign({}, configuration);
    // Define the variable for the list where the new plugin(s) will be added.
    let newItemsList;
    /**
     * Define a variable that may contain a list of the existing items' names. The reason for
     * this is that when adding new items (like presets or plugins), the method can validate if
     * they already are on the list by calling `.includes`; otherwise, and because most of Babel
     * items can be either a `string` or an `Array` (`[name, options]`), it would have to do a
     * `.some` or `.find` with a callback that checks the type of the existing item.
     */
    let existingItems;
    // If the configuration already has a property for the items...
    if (updatedConfiguration[property]) {
      // ...set the existing list as the one where the new items are going to be added.
      newItemsList = updatedConfiguration[property];
      // And generate a list with the names of the existing items.
      existingItems = newItemsList.map((existingItem) => (
        (Array.isArray(existingItem) ? existingItem[0] : existingItem)
      ));
    } else {
      // ...otherwise, set a empty list.
      newItemsList = [];
    }
    // Loop all the items that should be added.
    items.forEach((itemInfo) => {
      // Get the item name.
      const name = Array.isArray(itemInfo) ? itemInfo[0] : itemInfo;
      /**
       * If the configuration didn't have the required items property, or the item is not on the
       * list...
       */
      if (!existingItems || !existingItems.includes(name)) {
        // ...add it to the list.
        newItemsList.push(itemInfo);
      }
    });

    // Replace the items property on the configuration with the updated list.
    updatedConfiguration[property] = newItemsList;
    // Return the updated configuration.
    return updatedConfiguration;
  }
}
/**
 * The service provider that once registered on the app container will set a reference of
 * `BabelHelper` as the `babelHelper` service.
 * @example
 * // Register it on the container
 * container.register(babelHelper);
 * // Getting access to the service reference
 * const babelHelper = container.get('babelHelper');
 * @type {Provider}
 */
const babelHelper = provider((app) => {
  app.set('babelHelper', () => BabelHelper);
});

module.exports = {
  BabelHelper,
  babelHelper,
};