transformer.js

/**
 * Some comment about the function.
 */
const path = require('path');
const fs = require('fs-extra');
const { findFileSync, getAbsPathInfoSync } = require('./utils');

/**
 * @typedef {import('jscodeshift').API}      API
 * @typedef {import('jscodeshift').FileInfo} FileInfo
 */

/**
 * Creates the replacement path for an import statement for a folder. It validates if the
 * folder has a `package.json`, to keep it as it is, and if it fails, it tries to find an
 * index files,
 * `.mjs` or `.js`.
 *
 * @param {string} absPath     The absolute path for the folder.
 * @param {string} importPath  The path as it is on the import statement.
 * @returns {?string} If there's no `package.json` and no `index` was found, the function
 *                    will return `null`.
 */
const createReplacementForFolder = (absPath, importPath) => {
  let result;
  const pkgPath = path.join(absPath, 'package.json');
  const pkgExists = fs.pathExistsSync(pkgPath);
  if (pkgExists) {
    result = importPath.replace(/\/$/, '');
  } else {
    const file = findFileSync(['index.mjs', 'index.js'], absPath);
    result = file ? path.join(importPath, path.basename(file)) : null;
  }

  return result;
};
/**
 * This is the transformation for `jscodeshift` the tool uses to modify import statements,
 * add missing `.mjs` extensions and change paths if needed.
 *
 * @param {FileInfo}         file     The information of the file to transform.
 * @param {API}              api      The API that expose `jscodeshift`, with utilities
 *                                    for the transformation.
 * @param {TransformOptions} options  These options are sent by `jscodeshift`, but the
 *                                    tool injected its own options so the transformation
 *                                    can access to the settings related to the extension.
 * @returns {string}
 */
const transform = (file, api, options) => {
  const j = api.jscodeshift;
  // Extract the tool options.
  const { cjs2esm } = options;
  // Get the absolute path to the file directory, so it can be joined with the imports.
  const base = path.dirname(file.path);
  // Generate the AST.
  const root = j(file.source);
  // Generate the list of expressions to ignore import statements.
  const ignoreListForExt = cjs2esm.extension.ignore.map((ignore) => new RegExp(ignore));

  const originalFirstNode = root.find(j.Program).get('body', 0).node;

  // =================================================
  // Parse the import statements to add missing extensions.
  // =================================================
  root
    .find(j.ImportDeclaration)
    // Filter out the ones that are on the ignore list.
    .filter((item) => {
      const importPath = item.value.source.value;
      return (
        !ignoreListForExt.some((exp) => importPath.match(exp)) &&
        (importPath.startsWith('.') || importPath.match(/^\w/))
      );
    })
    .replaceWith((item) => {
      const importPath = item.value.source.value;
      // Resolve the absolute path for the import statement.
      const absPath = importPath.startsWith('.')
        ? path.join(base, importPath)
        : path.resolve('node_modules', importPath);
      const info = getAbsPathInfoSync(absPath);
      let replacement;
      if (info === null) {
        // No info was found, so "don't replace it".
        replacement = importPath;
      } else if (info.isFile) {
        // Join the import path directory with the real filename from the info.
        replacement = path.join(path.dirname(importPath), path.basename(info.path));
      } else {
        // If it's a directory, call the function that checks for a `package.json` or an `index`.
        const folderReplacement = createReplacementForFolder(absPath, importPath);
        replacement = folderReplacement || importPath;
      }

      /**
       * This is a hotfix; when you use `path.join()` with a path that starts with `./`, the
       * function removes it, but that's needed for import statements: if they path doesn't
       * starts with `.`, it assumes that it's on `node_modules`.
       */
      if (
        (importPath === '.' || importPath.startsWith('./')) &&
        !replacement.startsWith('./')
      ) {
        replacement = `./${replacement}`;
      }

      // Replace the node with a new one on the AST.
      return j.importDeclaration(item.value.specifiers, j.literal(replacement));
    });
  // =================================================
  // Parse the modules modifications.
  // =================================================
  if (cjs2esm.modules.length) {
    root
      .find(j.ImportDeclaration)
      // Filter out the import statments that don't need to be modified.
      .filter((item) => {
        const importPath = item.value.source.value;
        return cjs2esm.modules.some((mod) => importPath.startsWith(mod.name));
      })
      // Apply the modifications.
      .replaceWith((item) => {
        const importPath = item.value.source.value;
        const info = cjs2esm.modules.find((mod) => importPath.startsWith(mod.name));
        let replacement;
        if (info.find) {
          replacement = importPath.replace(new RegExp(info.find), info.path);
        } else {
          replacement = importPath.replace(
            new RegExp(`^${info.name}($|\\/)`),
            (match, endChar) => (endChar.endsWith('/') ? `${info.path}/` : info.path),
          );
        }

        return j.importDeclaration(item.value.specifiers, j.literal(replacement));
      });
  }

  const newFirstNode = root.find(j.Program).get('body', 0).node;
  if (newFirstNode !== originalFirstNode) {
    newFirstNode.comments = originalFirstNode.comments;
  }

  // Regenerate the file code.
  return root.toSource({
    quote: 'single',
  });
};

module.exports = transform;