You can not select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
					
					
						
							351 lines
						
					
					
						
							9.9 KiB
						
					
					
				
			
		
		
	
	
							351 lines
						
					
					
						
							9.9 KiB
						
					
					
				"use strict";
 | 
						|
 | 
						|
const p = require('path');
 | 
						|
 | 
						|
const resolve = require('resolve'); // const printAST = require('ast-pretty-print')
 | 
						|
 | 
						|
 | 
						|
const macrosRegex = /[./]macro(\.c?js)?$/;
 | 
						|
 | 
						|
const testMacrosRegex = v => macrosRegex.test(v); // https://stackoverflow.com/a/32749533/971592
 | 
						|
 | 
						|
 | 
						|
class MacroError extends Error {
 | 
						|
  constructor(message) {
 | 
						|
    super(message);
 | 
						|
    this.name = 'MacroError';
 | 
						|
    /* istanbul ignore else */
 | 
						|
 | 
						|
    if (typeof Error.captureStackTrace === 'function') {
 | 
						|
      Error.captureStackTrace(this, this.constructor);
 | 
						|
    } else if (!this.stack) {
 | 
						|
      this.stack = new Error(message).stack;
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
}
 | 
						|
 | 
						|
let _configExplorer = null;
 | 
						|
 | 
						|
function getConfigExplorer() {
 | 
						|
  return _configExplorer = _configExplorer || // Lazy load cosmiconfig since it is a relatively large bundle
 | 
						|
  require('cosmiconfig').cosmiconfigSync('babel-plugin-macros', {
 | 
						|
    searchPlaces: ['package.json', '.babel-plugin-macrosrc', '.babel-plugin-macrosrc.json', '.babel-plugin-macrosrc.yaml', '.babel-plugin-macrosrc.yml', '.babel-plugin-macrosrc.js', 'babel-plugin-macros.config.js'],
 | 
						|
    packageProp: 'babelMacros'
 | 
						|
  });
 | 
						|
}
 | 
						|
 | 
						|
function createMacro(macro, options = {}) {
 | 
						|
  if (options.configName === 'options') {
 | 
						|
    throw new Error(`You cannot use the configName "options". It is reserved for babel-plugin-macros.`);
 | 
						|
  }
 | 
						|
 | 
						|
  macroWrapper.isBabelMacro = true;
 | 
						|
  macroWrapper.options = options;
 | 
						|
  return macroWrapper;
 | 
						|
 | 
						|
  function macroWrapper(args) {
 | 
						|
    const {
 | 
						|
      source,
 | 
						|
      isBabelMacrosCall
 | 
						|
    } = args;
 | 
						|
 | 
						|
    if (!isBabelMacrosCall) {
 | 
						|
      throw new MacroError(`The macro you imported from "${source}" is being executed outside the context of compilation with babel-plugin-macros. ` + `This indicates that you don't have the babel plugin "babel-plugin-macros" configured correctly. ` + `Please see the documentation for how to configure babel-plugin-macros properly: ` + 'https://github.com/kentcdodds/babel-plugin-macros/blob/master/other/docs/user.md');
 | 
						|
    }
 | 
						|
 | 
						|
    return macro(args);
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
function nodeResolvePath(source, basedir) {
 | 
						|
  return resolve.sync(source, {
 | 
						|
    basedir,
 | 
						|
    extensions: ['.js', '.ts', '.tsx', '.mjs', '.cjs', '.jsx'],
 | 
						|
    // This is here to support the package being globally installed
 | 
						|
    // read more: https://github.com/kentcdodds/babel-plugin-macros/pull/138
 | 
						|
    paths: [p.resolve(__dirname, '../../')]
 | 
						|
  });
 | 
						|
}
 | 
						|
 | 
						|
function macrosPlugin(babel, // istanbul doesn't like the default of an object for the plugin options
 | 
						|
// but I think older versions of babel didn't always pass options
 | 
						|
// istanbul ignore next
 | 
						|
{
 | 
						|
  require: _require = require,
 | 
						|
  resolvePath = nodeResolvePath,
 | 
						|
  isMacrosName = testMacrosRegex,
 | 
						|
  ...options
 | 
						|
} = {}) {
 | 
						|
  function interopRequire(path) {
 | 
						|
    // eslint-disable-next-line import/no-dynamic-require
 | 
						|
    const o = _require(path);
 | 
						|
 | 
						|
    return o && o.__esModule && o.default ? o.default : o;
 | 
						|
  }
 | 
						|
 | 
						|
  return {
 | 
						|
    name: 'macros',
 | 
						|
    visitor: {
 | 
						|
      Program(progPath, state) {
 | 
						|
        progPath.traverse({
 | 
						|
          ImportDeclaration(path) {
 | 
						|
            const isMacros = looksLike(path, {
 | 
						|
              node: {
 | 
						|
                source: {
 | 
						|
                  value: v => isMacrosName(v)
 | 
						|
                }
 | 
						|
              }
 | 
						|
            });
 | 
						|
 | 
						|
            if (!isMacros) {
 | 
						|
              return;
 | 
						|
            }
 | 
						|
 | 
						|
            const imports = path.node.specifiers.map(s => ({
 | 
						|
              localName: s.local.name,
 | 
						|
              importedName: s.type === 'ImportDefaultSpecifier' ? 'default' : s.imported.name
 | 
						|
            }));
 | 
						|
            const source = path.node.source.value;
 | 
						|
            const result = applyMacros({
 | 
						|
              path,
 | 
						|
              imports,
 | 
						|
              source,
 | 
						|
              state,
 | 
						|
              babel,
 | 
						|
              interopRequire,
 | 
						|
              resolvePath,
 | 
						|
              options
 | 
						|
            });
 | 
						|
 | 
						|
            if (!result || !result.keepImports) {
 | 
						|
              path.remove();
 | 
						|
            }
 | 
						|
          },
 | 
						|
 | 
						|
          VariableDeclaration(path) {
 | 
						|
            const isMacros = child => looksLike(child, {
 | 
						|
              node: {
 | 
						|
                init: {
 | 
						|
                  callee: {
 | 
						|
                    type: 'Identifier',
 | 
						|
                    name: 'require'
 | 
						|
                  },
 | 
						|
                  arguments: args => args.length === 1 && isMacrosName(args[0].value)
 | 
						|
                }
 | 
						|
              }
 | 
						|
            });
 | 
						|
 | 
						|
            path.get('declarations').filter(isMacros).forEach(child => {
 | 
						|
              const imports = child.node.id.name ? [{
 | 
						|
                localName: child.node.id.name,
 | 
						|
                importedName: 'default'
 | 
						|
              }] : child.node.id.properties.map(property => ({
 | 
						|
                localName: property.value.name,
 | 
						|
                importedName: property.key.name
 | 
						|
              }));
 | 
						|
              const call = child.get('init');
 | 
						|
              const source = call.node.arguments[0].value;
 | 
						|
              const result = applyMacros({
 | 
						|
                path: call,
 | 
						|
                imports,
 | 
						|
                source,
 | 
						|
                state,
 | 
						|
                babel,
 | 
						|
                interopRequire,
 | 
						|
                resolvePath,
 | 
						|
                options
 | 
						|
              });
 | 
						|
 | 
						|
              if (!result || !result.keepImports) {
 | 
						|
                child.remove();
 | 
						|
              }
 | 
						|
            });
 | 
						|
          }
 | 
						|
 | 
						|
        });
 | 
						|
      }
 | 
						|
 | 
						|
    }
 | 
						|
  };
 | 
						|
} // eslint-disable-next-line complexity
 | 
						|
 | 
						|
 | 
						|
function applyMacros({
 | 
						|
  path,
 | 
						|
  imports,
 | 
						|
  source,
 | 
						|
  state,
 | 
						|
  babel,
 | 
						|
  interopRequire,
 | 
						|
  resolvePath,
 | 
						|
  options
 | 
						|
}) {
 | 
						|
  /* istanbul ignore next (pretty much only useful for astexplorer I think) */
 | 
						|
  const {
 | 
						|
    file: {
 | 
						|
      opts: {
 | 
						|
        filename = ''
 | 
						|
      }
 | 
						|
    }
 | 
						|
  } = state;
 | 
						|
  let hasReferences = false;
 | 
						|
  const referencePathsByImportName = imports.reduce((byName, {
 | 
						|
    importedName,
 | 
						|
    localName
 | 
						|
  }) => {
 | 
						|
    const binding = path.scope.getBinding(localName);
 | 
						|
    byName[importedName] = binding.referencePaths;
 | 
						|
    hasReferences = hasReferences || Boolean(byName[importedName].length);
 | 
						|
    return byName;
 | 
						|
  }, {});
 | 
						|
  const isRelative = source.indexOf('.') === 0;
 | 
						|
  const requirePath = resolvePath(source, p.dirname(getFullFilename(filename)));
 | 
						|
  const macro = interopRequire(requirePath);
 | 
						|
 | 
						|
  if (!macro.isBabelMacro) {
 | 
						|
    throw new Error(`The macro imported from "${source}" must be wrapped in "createMacro" ` + `which you can get from "babel-plugin-macros". ` + `Please refer to the documentation to see how to do this properly: https://github.com/kentcdodds/babel-plugin-macros/blob/master/other/docs/author.md#writing-a-macro`);
 | 
						|
  }
 | 
						|
 | 
						|
  const config = getConfig(macro, filename, source, options);
 | 
						|
  let result;
 | 
						|
 | 
						|
  try {
 | 
						|
    /**
 | 
						|
     * Other plugins that run before babel-plugin-macros might use path.replace, where a path is
 | 
						|
     * put into its own replacement. Apparently babel does not update the scope after such
 | 
						|
     * an operation. As a remedy, the whole scope is traversed again with an empty "Identifier"
 | 
						|
     * visitor - this makes the problem go away.
 | 
						|
     *
 | 
						|
     * See: https://github.com/kentcdodds/import-all.macro/issues/7
 | 
						|
     */
 | 
						|
    state.file.scope.path.traverse({
 | 
						|
      Identifier() {}
 | 
						|
 | 
						|
    });
 | 
						|
    result = macro({
 | 
						|
      references: referencePathsByImportName,
 | 
						|
      source,
 | 
						|
      state,
 | 
						|
      babel,
 | 
						|
      config,
 | 
						|
      isBabelMacrosCall: true
 | 
						|
    });
 | 
						|
  } catch (error) {
 | 
						|
    if (error.name === 'MacroError') {
 | 
						|
      throw error;
 | 
						|
    }
 | 
						|
 | 
						|
    error.message = `${source}: ${error.message}`;
 | 
						|
 | 
						|
    if (!isRelative) {
 | 
						|
      error.message = `${error.message} Learn more: https://www.npmjs.com/package/${source.replace( // remove everything after package name
 | 
						|
      // @org/package/macro -> @org/package
 | 
						|
      // package/macro      -> package
 | 
						|
      /^((?:@[^/]+\/)?[^/]+).*/, '$1')}`;
 | 
						|
    }
 | 
						|
 | 
						|
    throw error;
 | 
						|
  }
 | 
						|
 | 
						|
  return result;
 | 
						|
}
 | 
						|
 | 
						|
function getConfigFromFile(configName, filename) {
 | 
						|
  try {
 | 
						|
    const loaded = getConfigExplorer().search(filename);
 | 
						|
 | 
						|
    if (loaded) {
 | 
						|
      return {
 | 
						|
        options: loaded.config[configName],
 | 
						|
        path: loaded.filepath
 | 
						|
      };
 | 
						|
    }
 | 
						|
  } catch (e) {
 | 
						|
    return {
 | 
						|
      error: e
 | 
						|
    };
 | 
						|
  }
 | 
						|
 | 
						|
  return {};
 | 
						|
}
 | 
						|
 | 
						|
function getConfigFromOptions(configName, options) {
 | 
						|
  if (options.hasOwnProperty(configName)) {
 | 
						|
    if (options[configName] && typeof options[configName] !== 'object') {
 | 
						|
      // eslint-disable-next-line no-console
 | 
						|
      console.error(`The macro plugin options' ${configName} property was not an object or null.`);
 | 
						|
    } else {
 | 
						|
      return {
 | 
						|
        options: options[configName]
 | 
						|
      };
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  return {};
 | 
						|
}
 | 
						|
 | 
						|
function getConfig(macro, filename, source, options) {
 | 
						|
  const {
 | 
						|
    configName
 | 
						|
  } = macro.options;
 | 
						|
 | 
						|
  if (configName) {
 | 
						|
    const fileConfig = getConfigFromFile(configName, filename);
 | 
						|
    const optionsConfig = getConfigFromOptions(configName, options);
 | 
						|
 | 
						|
    if (optionsConfig.options === undefined && fileConfig.options === undefined && fileConfig.error !== undefined) {
 | 
						|
      // eslint-disable-next-line no-console
 | 
						|
      console.error(`There was an error trying to load the config "${configName}" ` + `for the macro imported from "${source}. ` + `Please see the error thrown for more information.`);
 | 
						|
      throw fileConfig.error;
 | 
						|
    }
 | 
						|
 | 
						|
    if (fileConfig.options !== undefined && optionsConfig.options !== undefined && typeof fileConfig.options !== 'object') {
 | 
						|
      throw new Error(`${fileConfig.path} specified a ${configName} config of type ` + `${typeof optionsConfig.options}, but the the macros plugin's ` + `options.${configName} did contain an object. Both configs must ` + `contain objects for their options to be mergeable.`);
 | 
						|
    }
 | 
						|
 | 
						|
    return { ...optionsConfig.options,
 | 
						|
      ...fileConfig.options
 | 
						|
    };
 | 
						|
  }
 | 
						|
 | 
						|
  return undefined;
 | 
						|
}
 | 
						|
/*
 | 
						|
 istanbul ignore next
 | 
						|
 because this is hard to test
 | 
						|
 and not worth it...
 | 
						|
 */
 | 
						|
 | 
						|
 | 
						|
function getFullFilename(filename) {
 | 
						|
  if (p.isAbsolute(filename)) {
 | 
						|
    return filename;
 | 
						|
  }
 | 
						|
 | 
						|
  return p.join(process.cwd(), filename);
 | 
						|
}
 | 
						|
 | 
						|
function looksLike(a, b) {
 | 
						|
  return a && b && Object.keys(b).every(bKey => {
 | 
						|
    const bVal = b[bKey];
 | 
						|
    const aVal = a[bKey];
 | 
						|
 | 
						|
    if (typeof bVal === 'function') {
 | 
						|
      return bVal(aVal);
 | 
						|
    }
 | 
						|
 | 
						|
    return isPrimitive(bVal) ? bVal === aVal : looksLike(aVal, bVal);
 | 
						|
  });
 | 
						|
}
 | 
						|
 | 
						|
function isPrimitive(val) {
 | 
						|
  // eslint-disable-next-line
 | 
						|
  return val == null || /^[sbn]/.test(typeof val);
 | 
						|
}
 | 
						|
 | 
						|
module.exports = macrosPlugin;
 | 
						|
Object.assign(module.exports, {
 | 
						|
  createMacro,
 | 
						|
  MacroError
 | 
						|
}); |