@netlify/zip-it-and-ship-it
Version:
237 lines (236 loc) • 9.23 kB
JavaScript
import { MODULE_FORMAT } from '../utils/module_format.js';
import { isESMImportExport, isModuleExports } from './helpers.js';
/**
* Traverses a list of nodes and returns:
*
* 1. Named `config` object export (ESM or CJS)
* 2. Whether there is a default export (ESM or CJS)
* 3. Named `handler` function exports (ESM or CJS)
* 4. The module format syntax used in the file: if any `import` or `export`
* declarations are found, this is ESM; if not, this is CJS
*/
export const traverseNodes = (nodes, getAllBindings) => {
const handlerExports = [];
let configExport = {};
let hasDefaultExport = false;
let inputModuleFormat = MODULE_FORMAT.COMMONJS;
nodes.forEach((node) => {
if (isESMImportExport(node)) {
inputModuleFormat = MODULE_FORMAT.ESM;
}
const esmHandlerExports = getNamedESMExport(node, 'handler', getAllBindings);
const esmConfigExports = getNamedESMExport(node, 'config', getAllBindings);
if (esmConfigExports.length !== 0 && esmConfigExports[0].type === 'object-expression') {
configExport = esmConfigExports[0].object;
}
if (esmHandlerExports.length !== 0) {
if (esmHandlerExports.some(({ type }) => type === 'default')) {
hasDefaultExport = true;
return;
}
handlerExports.push(...esmHandlerExports);
return;
}
const cjsHandlerExports = getCJSExports(node, 'handler');
if (cjsHandlerExports.length !== 0) {
handlerExports.push(...cjsHandlerExports);
return;
}
const cjsDefaultExports = getCJSExports(node, 'default');
if (cjsDefaultExports.length !== 0) {
hasDefaultExport = true;
return;
}
if (isESMDefaultExport(node)) {
hasDefaultExport = true;
}
const esmConfig = parseConfigESMExport(node);
if (esmConfig !== undefined) {
configExport = esmConfig;
return;
}
const cjsConfigExports = getCJSExports(node, 'config');
if (cjsConfigExports.length !== 0 && cjsConfigExports[0].type === 'object-expression') {
configExport = cjsConfigExports[0].object;
}
});
return { configExport, handlerExports, hasDefaultExport, inputModuleFormat };
};
// Finds the main handler export in a CJS AST.
const getCJSExports = (node, name) => {
const handlerPaths = [
['module', 'exports', name],
['exports', name],
];
return handlerPaths.flatMap((handlerPath) => {
if (!isModuleExports(node, handlerPath)) {
return [];
}
return getExportsFromExpression(node.expression.right);
});
};
/**
* Finds a named ESM export with a given name. It's capable of finding exports
* with a variable declaration (`export const foo = "bar"`), but also resolve
* bindings and find things like `const baz = "1"; export { baz as foo }`.
*/
const getNamedESMExport = (node, name, getAllBindings) => {
if (node.type !== 'ExportNamedDeclaration' || node.exportKind !== 'value') {
return [];
}
const { declaration, specifiers } = node;
if (specifiers?.length > 0) {
return getExportsFromBindings(specifiers, name, getAllBindings);
}
if (declaration?.type !== 'VariableDeclaration') {
return [];
}
const handlerDeclaration = declaration.declarations.find((childDeclaration) => {
const { id, type } = childDeclaration;
return type === 'VariableDeclarator' && id.type === 'Identifier' && id.name === name;
});
const exports = getExportsFromExpression(handlerDeclaration?.init);
return exports;
};
/**
* Check if the node is an `ExportSpecifier` that has a identifier with a default export:
* - `export { x as default }`
*/
const isDefaultExport = (node) => {
const { type, exported } = node;
return type === 'ExportSpecifier' && exported.type === 'Identifier' && exported.name === 'default';
};
/**
* Check if the node is an `ExportSpecifier` that has a named export with
* the given name, either as:
* - `export { handler }`, or
* - `export { x as "handler" }`
*/
const isNamedExport = (node, name) => {
const { type, exported } = node;
return (type === 'ExportSpecifier' &&
((exported.type === 'Identifier' && exported.name === name) ||
(exported.type === 'StringLiteral' && exported.value === name)));
};
// Returns whether a given node is or contains a default export declaration.
const isESMDefaultExport = (node) => node.type === 'ExportDefaultDeclaration' ||
(node.type === 'ExportNamedDeclaration' &&
node.specifiers.some((exportSpecifier) => exportSpecifier.exported.type === 'Identifier' && exportSpecifier.exported.name === 'default'));
/**
* Finds a `config` named CJS export that maps to an object variable
* declaration, like:
*
* `export const config = { prop1: "value 1" }`
*/
const parseConfigESMExport = (node) => {
if (node.type === 'ExportNamedDeclaration' &&
node.declaration?.type === 'VariableDeclaration' &&
node.declaration.declarations[0].type === 'VariableDeclarator' &&
node.declaration.declarations[0].id.type === 'Identifier' &&
node.declaration.declarations[0].id.name === 'config' &&
node.declaration.declarations[0].init?.type === 'ObjectExpression') {
return parseObject(node.declaration.declarations[0].init);
}
};
/**
* Takes an object expression node and returns the object resulting from the
* subtree. Only values supported by the `parsePrimitive` method are returned,
* and any others will be ignored and excluded from the resulting object.
*/
const parseObject = (node) => node.properties.reduce((acc, property) => {
if (property.type === 'ObjectProperty' && property.key.type === 'Identifier') {
return {
...acc,
[property.key.name]: parsePrimitive(property.value),
};
}
if (property.type === 'ObjectProperty' && property.key.type === 'StringLiteral') {
return {
...acc,
[property.key.value]: parsePrimitive(property.value),
};
}
return acc;
}, {});
/**
* Takes an expression and, if it matches a JavaScript primitive type, returns
* the corresponding value. If not, `undefined` is returned.
* Currently, the following primitive types are supported:
*
* - boolean
* - number
* - object
* - string
* - array
* - null
*/
const parsePrimitive = (exp) => {
if (exp.type === 'BooleanLiteral' || exp.type === 'NumericLiteral' || exp.type === 'StringLiteral') {
return exp.value;
}
if (exp.type === 'ArrayExpression') {
return exp.elements.map((element) => {
if (element === null || element.type === 'SpreadElement') {
return;
}
return parsePrimitive(element);
});
}
if (exp.type === 'ObjectExpression') {
return parseObject(exp);
}
if (exp.type === 'NullLiteral') {
return null;
}
// special case: minifiers like to transform `true` to `!0` and `false` to `!1`.
// because this can be hard to turn off for some frameworks, we have a special case.
if (exp.type === 'UnaryExpression' && exp.operator === '!' && exp.argument.type === 'NumericLiteral') {
return !exp.argument.value;
}
};
/**
* Tries to resolve the export with a given name from a binding (variable).
* For example, the following would resolve correctly to the handler function:
*
* `let handler; handler = () => {}; export { handler }`
*/
const getExportsFromBindings = (specifiers, name, getAllBindings) => {
const specifier = specifiers.find((node) => isNamedExport(node, name));
// If there's no named export with the given name, check if there's a default
if (!specifier || specifier.type !== 'ExportSpecifier') {
const defaultExport = specifiers.find((node) => isDefaultExport(node));
if (defaultExport && defaultExport.type === 'ExportSpecifier') {
const binding = getAllBindings().get(defaultExport.local.name);
if (binding?.type === 'ArrowFunctionExpression' ||
binding?.type === 'FunctionDeclaration' ||
binding?.type === 'Identifier') {
return [{ type: 'default' }];
}
}
return [];
}
const binding = getAllBindings().get(specifier.local.name);
const exports = getExportsFromExpression(binding);
return exports;
};
const getExportsFromExpression = (node) => {
switch (node?.type) {
case 'CallExpression': {
const { arguments: args, callee } = node;
if (callee.type !== 'Identifier') {
return [];
}
return [{ args, local: callee.name, type: 'call-expression' }];
}
case 'ObjectExpression': {
const object = parseObject(node);
return [{ object, type: 'object-expression' }];
}
default: {
if (node !== undefined) {
return [{ type: 'other' }];
}
return [];
}
}
};