index.js

const path = require('path');
const fs = require('fs-extra');
const Runner = require('jscodeshift/src/Runner');
const { repository } = require('../package.json');
const { prepareESMModules } = require('./esm');
const { log, findFile, getAbsPathInfo, requireModule } = require('./utils');

/**
 * The name that can be used in the list of files to specify the custom transformation
 * this module does.
 *
 * @type {string}
 * @ignore
 */
const CJS2ESM_TRANSFORMATION_NAME = '<cjs2esm>';
/**
 * A list of 5to6-codemod transformations that are executed from local patches.
 *
 * @type {string[]}
 * @ignore
 */
const CODEMOD_PATCHED_TRANSFORMATIONS = ['exports'];
/**
 * Setups everything necessary for the library to work.
 *
 * @returns {Promise<void>}
 */
const prepare = async () => {
  await prepareESMModules();
};

/**
 * This is called every time an unexpected error is thrown; it logs the error using the
 * `log`
 * function, with _nice_ colors, and adds a message to create an issue on the repository.
 *
 * @param {Error} error  The exception to _"handle"_.
 * @ignore
 */
const handleAnError = (error) => {
  const stack = error.stack.split('\n');
  const message = stack.shift();
  log('red', message);
  stack.forEach((line) => log('gray', line.trim()));
  const link = `https://github.com/${repository}/issues/new`;
  log('gray');
  log(
    'gray',
    `If the issue persist, create a ticket and I may be able to help you: ${link} :D`,
  );
};
/**
 * Adds an error handler to the process so if something fails, it will be logged with a
 * nice style and a custom emssage.
 *
 * @returns {Function} To remove the listeners.
 */
const addErrorHandler = () => {
  process.on('uncaughtException', handleAnError);
  process.on('unhandledRejection', handleAnError);
  return () => {
    process.removeListener('uncaughtException', handleAnError);
    process.removeListener('unhandledRejection', handleAnError);
  };
};
/**
 * Loads the configuration for the project.
 *
 * @returns {Promise<CJS2ESMOptions>}
 */
const getConfiguration = async () => {
  log('yellow', 'Loading configuration...');
  const cwd = process.cwd();
  const file = await findFile(['.cjs2esm', '.cjs2esm.json', '.cjs2esm.js'], cwd);

  let config = {};
  if (file === null) {
    const pkgJson = requireModule(path.join(cwd, 'package.json'));
    if (pkgJson.config && pkgJson.config.cjs2esm) {
      config = pkgJson.config.cjs2esm;
      log('green', 'Using configuration from the package.json');
    } else if (pkgJson.cjs2esm) {
      config = pkgJson.cjs2esm;
      log('green', 'Using configuration from the package.json');
    } else {
      log('gray', 'No configuration was found, using defaults...');
    }
  } else if (file.match(/\.js$/i)) {
    config = requireModule(file);
    log('green', `Configuration file found: \`${file}\``);
  } else {
    config = await fs.readJSON(file);
    log('green', `Configuration file found: \`${file}\``);
  }

  const result = {
    input: ['src'],
    output: 'esm',
    forceDirectory: null,
    modules: [],
    extension: {},
    addModuleEntry: false,
    addPackageJson: true,
    filesWithShebang: [],
    ...config,
  };

  result.extension = {
    use: 'js',
    ignore: [],
    ...result.extension,
  };

  result.input = result.input.map((item) => path.join(cwd, item));
  result.output = path.join(cwd, result.output);
  return result;
};
/**
 * Ensures the output directory exists and it's empty. If the directory exists, it removes
 * it and then creates it again.
 *
 * @param {string} output  The output directory the tool will use.
 * @returns {Promise}
 */
const ensureOutput = async (output) => {
  const exists = await fs.pathExists(output);
  if (exists) {
    await fs.remove(output);
  }

  await fs.mkdir(output);
  log('green', 'Output directory successfully cleaned');
};
/**
 * Finds all the JavaScript files on a given directory.
 *
 * @param {string} directory  The absolute path to the directory.
 * @returns {Promise<string[]>}
 * @ignore
 */
const findFiles = async (directory) => {
  let result = await fs.readdir(directory);
  result = result.filter((item) => !item.startsWith('.'));
  result = await Promise.all(
    result.map(async (item) => {
      const itempath = path.join(directory, item);
      const stats = await fs.stat(itempath);
      let newItem;
      if (stats.isDirectory()) {
        newItem = await findFiles(itempath);
      } else if (item.match(/\.js$/i)) {
        newItem = itempath;
      } else {
        newItem = null;
      }

      return newItem;
    }),
  );

  result = result
    .filter((item) => item !== null)
    .reduce(
      (acc, item) => (Array.isArray(item) ? [...acc, ...item] : [...acc, item]),
      [],
    );

  return result;
};
/**
 * Copies all the files from a source directory to the output directory, changing the
 * extensions if required.
 *
 * @param {string}          directory              The source directory from where the
 *                                                 files will be copied.
 * @param {string}          output                 The output directory where the files
 *                                                 should be copied to.
 * @param {ModuleExtension} useExtension           The extension the modules should use.
 * @param {boolean}         [forceDirectory=true]  If `false`, the directory itself won't
 *                                                 be copied,
 *                                                 just its contents.
 * @param {string[]}        [ignore=[]]            A list of expressions for paths that
 *                                                 should be ignored.
 * @returns {Promise<CJS2ESMCopiedFile[]>}
 * @ignore
 */
const copyDirectory = async (
  directory,
  output,
  useExtension,
  forceDirectory = true,
  ignore = [],
) => {
  const cwd = process.cwd();
  const extension = `.${useExtension}`;
  let contents = await findFiles(directory);
  if (ignore.length) {
    const ignoreExp = ignore.map((item) => new RegExp(item));
    contents = contents.filter((item) => !ignoreExp.some((exp) => item.match(exp)));
  }
  contents = await Promise.all(
    contents.map(async (item) => {
      let cleanPath = item.substr(cwd.length + 1);
      if (!forceDirectory) {
        cleanPath = cleanPath.split(path.sep);
        cleanPath.shift();
        cleanPath = cleanPath.join(path.sep);
      }

      let newPath = path.join(output, cleanPath);
      const { ext } = path.parse(newPath);
      if (ext !== extension) {
        newPath = newPath.replace(new RegExp(`\\${ext}$`), extension);
      }

      await fs.ensureDir(path.dirname(newPath));
      await fs.copyFile(item, newPath);
      return {
        from: item,
        to: newPath,
      };
    }),
  );

  return contents;
};
/**
 * Copies all the files the tool will transpile.
 *
 * @param {string[]}        input           The list of source paths where the files are
 *                                          located.
 * @param {string}          output          The output path where all the files will be
 *                                          transpiled to.
 * @param {ModuleExtension} useExtension    The extension the modules should use.
 * @param {?boolean}        forceDirectory  By default, if `input` has only one directory,
 *                                          the only thing copied will be its contents,
 *                                          instead of the directory itself; this
 *                                          parameter can be used to force it and always
 *                                          copy the directory.
 * @param {?string[]}       ignore          A list of expressions for paths that should be
 *                                          ignored.
 * @returns {Promise<CJS2ESMCopiedFile[]>}
 */
const copyFiles = async (input, output, useExtension, forceDirectory, ignore) => {
  let result;
  if (input.length === 1) {
    const [firstInput] = input;
    result = await copyDirectory(
      firstInput,
      output,
      useExtension,
      forceDirectory === true,
      ignore,
    );
  } else {
    result = await Promise.all(
      input.map((item) => copyDirectory(item, output, useExtension, undefined, ignore)),
    );

    result = result.reduce((acc, item) => [...acc, ...item], []);
  }

  return result;
};
/**
 * Takes a list of copied files, opens them, find if they have a shebang and removes it,
 * saves the files and returns a dictionary with the filepath and the shebang that was
 * removed.
 * This is necessary because the jscodeshift parser can't handle shebangs, and the whole
 * process explodes when a file has one.
 *
 * @param {CJS2ESMCopiedFile[]} files  The list of copied files with shebangs.
 * @returns {Promise<Object.<string, string>>} The keys are the path to the copied files
 *                                             and the values the shebangs they had.
 * @ignore
 */
const removeShebangs = async (files) => {
  const result = await Promise.all(
    files.map(async (file) => {
      let contents = await fs.readFile(file.to, 'utf-8');
      let item;
      const match = /^#!.*?$/m.exec(contents);
      if (match) {
        const [shebang] = match;
        item = {
          shebang,
          file,
        };
        contents = contents.replace(shebang, '').trimLeft();
        await fs.writeFile(file.to, contents);
      } else {
        item = null;
      }

      return item;
    }),
  );

  return result
    .filter((item) => item)
    .reduce((acc, item) => ({ ...acc, [item.file.to]: item.shebang }), {});
};
/**
 * This is a complementary function for `removeShebangs`: it's used to restore the removed
 * shebangs once the transformation process its finished.
 * The function basically opens the files, adds the shebangs and saves them.
 *
 * @param {Object.<string, string>} shebangs  The keys are the path to the copied files
 *                                            and the values the shebangs they had.
 * @returns {Promise}
 * @ignore
 */
const restoreShebangs = (shebangs) =>
  Promise.all(
    Object.keys(shebangs).map(async (filepath) => {
      const shebang = shebangs[filepath];
      let contents = await fs.readFile(filepath, 'utf-8');
      contents = `${shebang}\n\n${contents}`;
      await fs.writeFile(filepath, contents);
    }),
  );
/**
 * Transforms all files from the output directory into ES Modules.
 *
 * @param {CJS2ESMCopiedFile[]} files    The list of files that were copied to the output
 *                                       directory.
 * @param {CJS2ESMOptions}      options  The options of the tool, so they can be sent to
 *                                       the transformers.
 * @returns {Promise}
 * @throws {Error} If there's a problem while transforming a file.
 */
const transformOutput = async (files, options) => {
  if (!files.length) {
    throw new Error('No files to transform were found');
  }

  const transformOptions = {
    verbose: 0,
    dry: false,
    print: false,
    babel: true,
    extension: files[0].to.match(/\.mjs$/i) ? 'mjs' : 'js',
    ignorePattern: [],
    ignoreConfig: [],
    runInBand: false,
    silent: true,
    parser: 'babel',
    cjs2esm: options,
  };

  const shebangExpressions = (options.filesWithShebang || []).map(
    (expression) => new RegExp(expression),
  );
  const filesWithShebang = files.filter(({ from }) =>
    shebangExpressions.some((expression) => from.match(expression)),
  );

  let shebangs;
  if (filesWithShebang.length) {
    shebangs = await removeShebangs(filesWithShebang);
  }

  const codemodOptions = {
    path: null,
    files: null,
    ...options.codemod,
  };

  const codemodFiles =
    Array.isArray(codemodOptions.files) && codemodOptions.files.length
      ? codemodOptions.files
      : ['cjs', 'exports', 'named-export-generation'];
  if (!codemodFiles.includes(CJS2ESM_TRANSFORMATION_NAME)) {
    codemodFiles.push(CJS2ESM_TRANSFORMATION_NAME);
  }

  const fileForResolveIndex = codemodFiles.findIndex(
    (file) => file !== CJS2ESM_TRANSFORMATION_NAME && file.match(/^\w/),
  );

  if (codemodFiles[0] === CJS2ESM_TRANSFORMATION_NAME) {
    throw new Error(`${CJS2ESM_TRANSFORMATION_NAME} cannot be the first one in the list`);
  }

  const fileForResolve = `${codemodFiles[fileForResolveIndex]}.js`;

  const cwd = process.cwd();
  let filepathForResolve;
  let canUsePatch = false;
  if (codemodOptions.path) {
    filepathForResolve = path.join(cwd, codemodOptions.path, fileForResolve);
  } else {
    canUsePatch = true;
    filepathForResolve = require.resolve(
      path.join('5to6-codemod', 'transforms', fileForResolve),
    );
  }

  const codeModPath = path.dirname(filepathForResolve);
  const transformations = codemodFiles.map((file, index) => {
    if (file === CJS2ESM_TRANSFORMATION_NAME) {
      return path.join(__dirname, 'transformer.js');
    }

    const fileWithExt = `${file}.js`;

    if (canUsePatch && CODEMOD_PATCHED_TRANSFORMATIONS.includes(file)) {
      return path.join(__dirname, '5to6-codemod', fileWithExt);
    }

    if (index === fileForResolveIndex) {
      return filepathForResolve;
    }

    if (fileWithExt.startsWith('.')) {
      return path.resolve(fileWithExt);
    }

    return path.join(codeModPath, fileWithExt);
  });

  log('yellow', `Transforming ${files.length} files...`);

  const results = await transformations.reduce(
    (acc, transformation) =>
      acc.then((prevStats) =>
        Runner.run(transformation, [options.output], transformOptions).then((stats) => [
          ...prevStats,
          stats,
        ]),
      ),
    Promise.resolve([null]),
  );

  if (shebangs) {
    await restoreShebangs(shebangs);
  }

  results.shift();
  const errorIndex = results.findIndex(
    (stats) => stats.ok + stats.nochange !== files.length,
  );
  if (errorIndex > -1) {
    let transformationError = transformations[errorIndex];
    transformationError = path.parse(transformationError).name;
    throw new Error(
      `At least one file couldn't be transformed with \`${transformationError}\``,
    );
  }

  files.forEach((file) => log('gray', `> ${file.to.substr(cwd.length + 1)}`));
  let totalTime = results.reduce(
    (acc, { timeElapsed }) => acc + parseFloat(timeElapsed),
    0.0,
  );
  const decimals = 2;
  totalTime = totalTime.toFixed(decimals);

  log('green', `All files were successfully transformed (${totalTime}s)!`);
};
/**
 * Given an absolute path for a folder, the function will try to find its "entry file": it
 * will check for `index.mjs` and `index.js`.
 *
 * @param {string} absPath  The absolute path to the folder.
 * @returns {Promise<?string>} If there's no `index`, the function will return `null`.
 * @ignore
 */
const findFolderEntryPath = async (absPath) => {
  const file = await findFile(['index.mjs', 'index.js'], absPath);
  return file ? path.join(absPath, path.basename(file)) : null;
};
/**
 * Updates the project `package.json` by adding a `module` property that points to the
 * transformed version of the current `main` property.
 *
 * @param {CJS2ESMCopiedFile[]} files  The list of files that were copied, so the function
 *                                     can find the transformed path for the `main` file.
 * @returns {Promise}
 * @throws {Error} If the function can't find the transformed version of the `main`
 *                 file.
 */
const updatePackageJSON = async (files) => {
  const cwd = process.cwd();
  const pkgJsonPath = path.join(cwd, 'package.json');
  const pkgJson = requireModule(pkgJsonPath);
  let result;
  if (pkgJson.main) {
    let mainPath = path.join(cwd, pkgJson.main);
    const info = await getAbsPathInfo(mainPath);
    if (info.isFile) {
      mainPath = info.path;
    } else {
      mainPath = await findFolderEntryPath(info.path);
      if (!mainPath) {
        throw new Error(`The entry file can't be found: \`${info.path}\``);
      }
    }
    const file = files.find((item) => item.from === mainPath);
    if (file) {
      result = path.relative(cwd, file.to).replace(/^(\w)/, './$1');
      pkgJson.module = result;
      await fs.writeJSON(pkgJsonPath, pkgJson, { spaces: 2 });
      log('green', 'The module property was successfully added to the package.json!');
    } else {
      log(
        'yellow',
        'It doesnt seem like the main file was transformed, package.json update aborted',
      );
      result = null;
    }
  } else {
    log('yellow', "There's no main property, package.json update aborted");
    result = null;
  }

  return result;
};
/**
 * Adds a `package.json` with `type` set to `module` on the output directory. This is so
 * Node can properly resolve the ESM files.
 *
 * @param {string} output  The output directory the tool will use.
 * @returns {Promise}
 */
const addPackageJSON = async (output) => {
  await fs.writeJSON(
    path.join(output, 'package.json'),
    { type: 'module' },
    { spaces: 2 },
  );
  log('green', 'The packages.json for the ESM version was successfully added!');
};

module.exports.prepare = prepare;
module.exports.addErrorHandler = addErrorHandler;
module.exports.getConfiguration = getConfiguration;
module.exports.ensureOutput = ensureOutput;
module.exports.copyFiles = copyFiles;
module.exports.transformOutput = transformOutput;
module.exports.updatePackageJSON = updatePackageJSON;
module.exports.addPackageJSON = addPackageJSON;
module.exports.CJS2ESM_TRANSFORMATION_NAME = CJS2ESM_TRANSFORMATION_NAME;