@netlify/zip-it-and-ship-it
Version:
181 lines (180 loc) • 6.59 kB
JavaScript
import { promises as fs } from 'fs';
import { join, relative, resolve } from 'path';
import { parse } from '@babel/parser';
import { nonNullable } from '../../../utils/non_nullable.js';
const GLOB_WILDCARD = '**';
// Transforms an array of glob nodes into a glob string including an absolute
// path.
//
// Example: ["./files/", "*", ".json"] => "/home/ntl/files/*.json"
const getAbsoluteGlob = ({ basePath, globNodes, resolveDir, }) => {
if (!validateGlobNodes(globNodes)) {
return null;
}
const globStarIndex = globNodes.indexOf(GLOB_WILDCARD);
const [staticPath, dynamicPath] = globStarIndex === -1
? [globNodes.join(''), '']
: [globNodes.slice(0, globStarIndex).join(''), globNodes.slice(globStarIndex).join('')];
const absoluteStaticPath = resolve(resolveDir, staticPath);
const relativeStaticPath = relative(basePath, absoluteStaticPath);
const absoluteGlob = join(relativeStaticPath, dynamicPath);
return absoluteGlob;
};
// Returns GLOB_WILDCARD for AST nodes that are accepted as part of a dynamic
// expression and convertable to a wildcard character. This determines whether
// we convert an expression or leave it alone. For example:
//
// - `./files/${someVariable}`: Convert `someVariable` to GLOB_WILDCARD
// - `./files/${[some, array]}`: Don't convert expression
//
// The following AST nodes are converted to a wildcard:
//
// - CallExpression: `someFunction()`
// - ConditionalExpression: `someCond ? someValue : otherValue`
// - Identifier: `someVariable`
// - MemberExpression: `someArray[index]` or `someObject.property`
const getWildcardFromASTNode = (node) => {
switch (node.type) {
case 'CallExpression':
case 'ConditionalExpression':
case 'Identifier':
case 'MemberExpression':
return GLOB_WILDCARD;
default:
throw new Error('Expression member not supported');
}
};
// Tries to parse an expression, returning an object with:
// - `includedPathsGlob`: A glob with the files to be included in the bundle
// - `type`: The expression type (e.g. "require", "import")
export const parseExpression = ({ basePath, expression: rawExpression, resolveDir, }) => {
const { program } = parse(rawExpression, {
sourceType: 'module',
});
const [statement] = program.body;
if (statement.type !== 'ExpressionStatement') {
return;
}
const { expression } = statement;
if (expression.type === 'CallExpression' &&
expression.callee.type === 'Identifier' &&
expression.callee.name === 'require') {
try {
const includedPathsGlob = parseRequire({ basePath, expression, resolveDir });
return {
includedPathsGlob,
type: expression.callee.name,
};
}
catch {
// no-op
}
}
};
// Parses a JS/TS source and returns the resulting AST.
const parseSource = (source) => {
const ast = parse(source, {
plugins: ['typescript'],
sourceType: 'module',
// disable tokens, ranges and comments for performance and we do not use them
tokens: false,
ranges: false,
attachComment: false,
});
return ast.program;
};
// Parses a JS/TS source and returns the resulting AST. If there is a parsing
// error, it will get swallowed and `null` will be returned.
export const safelyParseSource = (source) => {
try {
return parseSource(source);
}
catch {
return null;
}
};
// Attempts to parse a JS/TS file at the given path, returning its AST if
// successful, or `null` if not.
export const safelyReadSource = async (path) => {
if (!path) {
return null;
}
try {
const source = await fs.readFile(path, 'utf8');
return source;
}
catch {
return null;
}
};
// Parses a `require()` and returns a glob string with an absolute path.
const parseRequire = ({ basePath, expression, resolveDir, }) => {
const { arguments: args = [] } = expression;
const [firstArg] = args;
if (firstArg === undefined) {
return;
}
if (firstArg.type === 'BinaryExpression') {
try {
const globNodes = parseBinaryExpression(firstArg);
return getAbsoluteGlob({ basePath, globNodes, resolveDir });
}
catch {
// no-op
}
}
if (firstArg.type === 'TemplateLiteral') {
const globNodes = parseTemplateLiteral(firstArg);
return getAbsoluteGlob({ basePath, globNodes, resolveDir });
}
};
// Transforms a binary expression AST node into an array of glob nodes, where
// static parts will be left untouched and identifiers will be replaced by
// `GLOB_WILDCARD`.
//
// Example: './files/' + lang + '.json' => ["./files/", "**", ".json"]
const parseBinaryExpression = (expression) => {
const { left, operator, right } = expression;
if (operator !== '+') {
throw new Error('Expression operator not supported');
}
const operands = [left, right].flatMap((operand) => {
switch (operand.type) {
case 'BinaryExpression':
return parseBinaryExpression(operand);
case 'StringLiteral':
return operand.value;
default:
return getWildcardFromASTNode(operand);
}
});
return operands;
};
// Transforms a template literal AST node into an array of glob nodes, where
// static parts will be left untouched and identifiers will be replaced by
// `GLOB_WILDCARD`.
//
// Example: `./files/${lang}.json` => ["./files/", "**", ".json"]
const parseTemplateLiteral = (expression) => {
const { expressions, quasis } = expression;
const parts = [...expressions, ...quasis].sort((partA, partB) => (partA.start ?? 0) - (partB.start ?? 0));
const globNodes = parts.map((part) => {
switch (part.type) {
case 'TemplateElement':
return part.value.cooked === '' ? null : part.value.cooked;
default:
return getWildcardFromASTNode(part);
}
});
return globNodes.filter(nonNullable);
};
// For our purposes, we consider a glob to be valid if all the nodes are
// strings and the first node is static (i.e. not a wildcard character).
const validateGlobNodes = (globNodes) => {
if (!globNodes) {
return false;
}
const hasStrings = globNodes.every((node) => typeof node === 'string');
const hasStaticHead = globNodes[0] !== GLOB_WILDCARD;
return hasStrings && hasStaticHead;
};