services/jsMerger.js

const babylon = require('babylon');
const babelTraverse = require('@babel/traverse').default;
const babelTypes = require('@babel/types');
const babelGenerator = require('@babel/generator').default;
const { provider } = require('@homer0/jimple');

/**
 * Gets the name/ID of an AST node declaration.
 *
 * @callback JSMergerNameFuntion
 * @param {Object} nodePath  The AST node path object that contains the declaration.
 * @returns {string}
 * @ignore
 */

/**
 * @typedef {Object} JSMergerCodeData
 * @property {Object}  ast               The AST of the JS block code.
 * @property {Array}   imports           A list of all the import declarations on the
 *                                       block.
 * @property {Array}   variables         A list of all the variables declarations on the
 *                                       block.
 * @property {Array}   bindings          A list of all the bindings declarations on the
 *                                       block.
 * @property {Array}   functions         A list of all the functions declarations on the
 *                                       block.
 * @property {?Object} firstContentNode  A reference to the first node on the block that
 *                                       is not an import declaration. This is used as an
 *                                       "anchor" to move all the import declarations from
 *                                       the extend block to before this node.
 * @ignore
 */

/**
 * This class takes care of merging two blocks of JS code by parsing their ASTs and getting rid
 * of duplicated variables, "bindings" and functions. By "binding", it referes to the named
 * exports Svelte uses as props/bindings/attributes for the components.
 */
class JSMerger {
  /**
   * @ignore
   */
  constructor() {
    /**
     * @ignore
     */
    this._getBindingName = this._getBindingName.bind(this);
    /**
     * @ignore
     */
    this._getVariableName = this._getVariableName.bind(this);
    /**
     * @ignore
     */
    this._getFunctionName = this._getFunctionName.bind(this);
  }
  /**
   * Merges two blocks of JS code.
   *
   * @param {string} base      The code that is being overwritten/extended.
   * @param {string} extended  The code that overwrites/extends.
   * @returns {string}
   */
  mergeCode(base, extended) {
    const baseData = this._getCodeData(base);
    const extendedData = this._getCodeData(extended);

    if (baseData.firstContentNode) {
      extendedData.imports.reverse().reduce((prevNode, importNode) => {
        const { node: toAdd } = importNode;
        importNode.remove();
        const [newNode] = prevNode.insertBefore(toAdd);
        return newNode;
      }, baseData.firstContentNode);
    }

    this._extendPaths(baseData.bindings, extendedData.bindings, this._getBindingName);
    this._extendPaths(baseData.variables, extendedData.variables, this._getVariableName);
    this._extendPaths(baseData.functions, extendedData.functions, this._getFunctionName);

    const { code: baseCode } = babelGenerator(baseData.ast, {}, base);
    const { code: extendedCode } = babelGenerator(extendedData.ast, {}, extended);

    return `${baseCode}\n${extendedCode}`;
  }
  /**
   * Processes a list of declarations of the same type (variables, bindings or functions)
   * and replaces the original definitions with the extended ones.
   *
   * @param {Array}               basePaths      The list of declarations from the
   *                                             "original block".
   * @param {Array}               extendedPaths  The list of declarations from the
   *                                             "extended block".
   * @param {JSMergerNameFuntion} nameFn         The function that returns the name/ID of
   *                                             the declaration.
   * @access protected
   * @ignore
   */
  _extendPaths(basePaths, extendedPaths, nameFn) {
    const pathsByName = basePaths.reduce(
      (acc, nodePath) => ({
        ...acc,
        [nameFn(nodePath)]: {
          base: nodePath,
        },
      }),
      {},
    );

    extendedPaths.forEach((nodePath) => {
      const name = nameFn(nodePath);
      if (pathsByName[name]) {
        pathsByName[name].extended = nodePath;
      }
    });

    Object.keys(pathsByName).forEach((name) => {
      const nodePath = pathsByName[name];
      if (nodePath.extended) {
        const { node: toMove } = nodePath.extended;
        nodePath.extended.remove();
        nodePath.base.insertAfter(toMove);
        nodePath.base.remove();
      }
    });
  }
  /**
   * Gets the name of a binding declaration. By "binding", it referes to the named exports
   * Svelte uses as props/bindings/attributes for the components.
   *
   * @param {Object} nodePath  The AST node path object that contains the declaration.
   * @returns {string}
   * @access protected
   * @ignore
   */
  _getBindingName(nodePath) {
    return nodePath.node.declaration.declarations[0].id.name;
  }
  /**
   * Parses a block of JS code in order to get the relevant information the class needs in
   * order to do a merge.
   *
   * @param {string} code  The block of JS code to parse.
   * @returns {JSMergerCodeData}
   * @access protected
   * @ignore
   */
  _getCodeData(code) {
    const ast = babylon.parse(code, {
      sourceType: 'module',
    });

    const imports = [];
    const variables = [];
    const bindings = [];
    const functions = [];
    let firstContentNode = null;
    babelTraverse(ast, {
      // eslint-disable-next-line jsdoc/require-jsdoc
      enter: (nodePath) => {
        const isRoot = nodePath.parent && babelTypes.isProgram(nodePath.parent);
        if (babelTypes.isImportDeclaration(nodePath)) {
          imports.push(nodePath);
        } else if (!firstContentNode && isRoot) {
          firstContentNode = nodePath;
        }

        if (babelTypes.isVariableDeclaration(nodePath) && nodePath.parent) {
          if (isRoot) {
            variables.push(nodePath);
          } else if (babelTypes.isExportNamedDeclaration(nodePath.parent)) {
            bindings.push(nodePath.parentPath);
          }
        } else if (babelTypes.isFunctionDeclaration(nodePath) && isRoot) {
          functions.push(nodePath);
        }
      },
    });

    return {
      ast,
      imports,
      variables,
      bindings,
      functions,
      firstContentNode,
    };
  }
  /**
   * Gets the name of a function declaration.
   *
   * @param {Object} nodePath  The AST node path object that contains the declaration.
   * @returns {string}
   * @access protected
   * @ignore
   */
  _getFunctionName(nodePath) {
    return nodePath.node.id.name;
  }
  /**
   * Gets the name of a variable declaration.
   *
   * @param {Object} nodePath  The AST node path object that contains the declaration.
   * @returns {string}
   * @access protected
   * @ignore
   */
  _getVariableName(nodePath) {
    return nodePath.node.declarations[0].id.name;
  }
}
/**
 * The service provider that once registered on {@link SvelteExtend} will save the an
 * instance of {@link JSMerger} as the `jsMerger` service.
 *
 * @type {Provider}
 */
const jsMerger = provider((app) => {
  app.set('jsMerger', () => new JSMerger());
});

module.exports = {
  JSMerger,
  jsMerger,
};