@jvdx/core
Version:
Personal nodejs toolbox
835 lines (754 loc) • 24.6 kB
JavaScript
const fs = require('fs-extra');
const { EOL } = require('os');
const { resolve, basename, extname, dirname, relative } = require('path');
const { red, bold, underline, dim } = require('kleur');
const glob = require('tiny-glob/sync');
const { escapeStringRegexp, asyncMap, asyncSeries } = require('../utils');
const autoprefixer = require('autoprefixer');
const { rollup, watch } = require('rollup');
const commonjs = require('@rollup/plugin-commonjs');
const { default: babel } = require('@rollup/plugin-babel');
const customBabel = require('../shared/babel-custom');
const { builtinModules } = require('../shared/built-in-modules');
const { nodeResolve } = require('@rollup/plugin-node-resolve');
const terser = require('@rollup/plugin-terser');
const alias = require('@rollup/plugin-alias');
const postcss = require('rollup-plugin-postcss');
const typescript = require('rollup-plugin-typescript2');
const json = require('@rollup/plugin-json');
const offmainthread = require('@surma/rollup-plugin-off-main-thread');
const {
print,
printErr,
readFile,
stopwatch,
isDir,
jsOrTs,
removePkgScope,
parseMappingArgument,
toReplacementExpression,
parseAliasArgument,
isTruthy,
printWarn,
arrify,
resolveFrom,
camelCase,
} = require('../utils');
const { shouldCssModules, cssModulesConfig } = require('../shared/css-modules');
const { normalizeMinifyOptions } = require('../shared/terser');
const { getSizeInfo } = require('../shared/compressed-size');
const { clean } = require('./clean');
const WATCH_OPTS = {
exclude: 'node_modules/**',
};
const BUILD_EXTENSIONS = /(\.(umd|cjs|es|m))?\.([cm]?[tj]sx?)$/;
async function build(opts) {
const stop = stopwatch();
opts.entries = arrify(opts.entry).concat(opts._);
if (opts.compress != null) {
// Convert `--compress true/false/1/0` to booleans:
if (typeof opts.compress !== 'boolean') {
opts.compress = opts.compress !== 'false' && opts.compress !== '0';
}
} else {
// the default compress value is `true` for web, `false` for Node:
opts.compress = opts.target !== 'node';
}
let options = { ...opts };
options.cwd = resolve(process.cwd(), opts.cwd);
const cwd = options.cwd;
const { hasPackageJson, pkg } = await getConfigFromPkgJson(cwd);
options.pkg = {
...pkg,
...pkg.publishConfig,
};
const { finalName, pkgName } = getName({
name: options.name,
pkgName: options.pkg.name,
amdName: options.pkg.amdName,
hasPackageJson,
cwd,
});
options.name = finalName;
options.pkg.name = pkgName;
if (options.sourcemap === 'inline') {
printWarn('Inline sourcemaps should only be used for debugging purposes.');
} else if (options.sourcemap === 'false') {
options.sourcemap = false;
} else if (options.sourcemap !== false) {
options.sourcemap = true;
}
options.input = await getInput({
entries: options.entries,
cwd,
source: options.pkg.source,
module: options.pkg.module,
});
options.output = await getOutput({
cwd,
output: options.output,
pkgMain: options.pkg.main,
pkgName: options.pkg.name,
});
options.entries = await getEntries({
cwd,
input: options.input,
});
options.multipleEntries = options.entries.length > 1;
let formats = (options.format || options.formats).split(',');
// de-dupe formats and convert "esm" to "es":
formats = Array.from(new Set(formats.map(f => (f === 'esm' ? 'es' : f))));
// Always compile cjs first
formats.sort((a, b) => (a === 'cjs' ? -1 : a > b ? 1 : 0));
// Create build steps for each build entry
let steps = [];
for (let i = 0; i < options.entries.length; i++) {
for (let j = 0; j < formats.length; j++) {
steps.push(
createConfig(
options,
options.entries[i],
formats[j],
i === 0 && j === 0,
),
);
}
}
// Conditionally clean output directory
if (options.clean) {
await clean({
_: arrify(dirname(options.output).replace(process.cwd(), '.')),
});
}
if (options.watch) {
return doWatch(options, cwd, steps);
}
let cache;
let out = await asyncSeries(
steps.map(config => async () => {
const { inputOptions, outputOptions } = config;
if (inputOptions.cache !== false) {
inputOptions.cache = cache;
}
let bundle = await rollup(inputOptions);
cache = bundle;
await bundle.write(outputOptions);
return await config._sizeInfo;
}),
);
const { s } = stop();
const fileOutput = out
.map(bundles =>
bundles
.map(b => {
if (b == null) return null;
const base = b.base ? `${b.base}\n` : '';
const gzip = b.gzip ? dim(` -> ${b.gzip}\n`) : '';
const brotli = b.brotli ? dim(` -> ${b.brotli}\n`) : '';
return base + gzip + brotli;
})
.filter(Boolean)
.join('\n'),
)
.filter(Boolean)
.join('');
const output =
`Built in ${bold(`${s}s`)}\n` +
`\n` +
`${underline('Bundle(s)')}\n` +
`${fileOutput}`;
return { output };
}
exports.build = build;
////////////////////////////////////////////////////////////////////////////////
// Read build configuration from package.json
const getConfigFromPkgJson = async cwd => {
let hasPackageJson = false;
let pkg;
try {
const packageJson = await readFile(resolve(cwd, 'package.json'), 'utf8');
pkg = JSON.parse(packageJson);
hasPackageJson = true;
} catch (err) {
const pkgName = basename(cwd);
let warning = `No package.json, assuming package name is "${pkgName}".\n`;
let msg = String(err.message || err);
if (!msg.match(/ENOENT/)) {
warning += ` ${red().dim(msg)}\n`;
}
printWarn(warning);
pkg = { name: pkgName };
}
return { hasPackageJson, pkg };
};
////////////////////////////////////////////////////////////////////////////////
const INVALID_ES3_IDENT = /((^[^a-zA-Z]+)|[^\w.-])|([^a-zA-Z0-9]+$)/g;
// Return the package name and a normalized version of it
const getName = ({ name, pkgName, amdName, cwd, hasPackageJson }) => {
if (!pkgName) {
pkgName = basename(cwd);
if (hasPackageJson) {
printWarn(`Missing package.json "name" field. Assuming "${pkgName}".\n`);
}
}
// Normalize a given variable name by removing package scopes and invalid
// es3 identities
const normalizePkgName = vName => {
const normalized = vName.replace(/^@.*\//, '').toLowerCase();
const identifier = normalized.replace(INVALID_ES3_IDENT, '');
return camelCase(identifier);
};
const finalName = name || amdName || normalizePkgName(pkgName);
return { finalName, pkgName };
};
////////////////////////////////////////////////////////////////////////////////
// Return the relative paths to all package input files
async function getInput({ entries, cwd, source, module }) {
const input = [];
[]
.concat(
entries && entries.length
? entries
: (source &&
(Array.isArray(source) ? source : [source]).map(file =>
resolve(cwd, file),
)) ||
((await isDir(resolve(cwd, 'src'))) &&
(await jsOrTs(cwd, 'src/index'))) ||
(await jsOrTs(cwd, 'index')) ||
module,
)
.map(file => glob(file))
.forEach(file => input.push(...file));
return input;
}
////////////////////////////////////////////////////////////////////////////////
// Return the absolute path to the output bundle
async function getOutput({ cwd, output, pkgMain, pkgName }) {
let main = resolve(cwd, output || pkgMain || 'dist');
if (!main.match(/\.[a-z]+$/) || (await isDir(main))) {
main = resolve(main, `${removePkgScope(pkgName)}.js`);
}
return main;
}
////////////////////////////////////////////////////////////////////////////////
// Return the package entry file(s)
async function getEntries({ input, cwd }) {
let entries = (
await asyncMap([].concat(input), async file => {
file = resolve(cwd, file);
if (await isDir(file)) {
file = resolve(file, 'index.js');
}
return file;
})
).filter((item, i, arr) => arr.indexOf(item) === i);
return entries;
}
////////////////////////////////////////////////////////////////////////////////
// Return type declaration output directory
function getDeclarationDir({ options, pkg }) {
const { cwd, output } = options;
let result = output;
if (pkg.types || pkg.typings) {
result = pkg.types || pkg.typings;
result = resolve(cwd, result);
}
result = dirname(result);
return result;
}
////////////////////////////////////////////////////////////////////////////////
// Recursively walk the "exports" package.json property
function walkExports(exports, includeDefault) {
if (!exports) return null;
if (typeof exports === 'string') return exports;
let p = exports['.'] || exports.import || exports.module;
if (!p && includeDefault) p = exports.default;
return walkExports(p, includeDefault);
}
////////////////////////////////////////////////////////////////////////////////
// Return future main entry files of our bundled output
function getMain({ options, entry, format }) {
const { pkg } = options;
const pkgMain = options['pkg-main'];
const pkgTypeModule = pkg.type === 'module';
if (!pkgMain) {
return options.output;
}
let mainNoExtension = options.output;
if (options.multipleEntries) {
let name = entry.match(
new RegExp(/([\\/])index/.source + BUILD_EXTENSIONS.source),
)
? mainNoExtension
: entry;
mainNoExtension = resolve(dirname(mainNoExtension), basename(name));
}
mainNoExtension = mainNoExtension.replace(BUILD_EXTENSIONS, '');
// Converts fileName `/path/to/dist/jvdx` to `/path/to/dist/jvdx.esm.js` when
// fileNameFormat equals `x.esm.js`
const replaceName = (fileNameFormat, fileName) =>
resolve(
dirname(fileNameFormat),
fileName + basename(fileNameFormat).replace(/^[^.]+/, ''),
);
const mainsByFormat = {};
mainsByFormat.es = replaceName(
pkg.module && !pkg.module.match(/src\//)
? pkg.module
: pkg['jsnext:main'] || (pkgTypeModule ? 'x.esm.js' : 'x.esm.mjs'),
mainNoExtension,
);
mainsByFormat.modern = replaceName(
(pkg.exports && walkExports(pkg.exports, pkgTypeModule)) ||
(pkg.syntax && pkg.syntax.esmodules) ||
pkg.esmodule ||
(pkgTypeModule ? 'x.modern.js' : 'x.modern.mjs'),
mainNoExtension,
);
mainsByFormat.cjs = replaceName(
pkg['cjs:main'] || (pkgTypeModule ? 'x.cjs' : 'x.js'),
mainNoExtension,
);
mainsByFormat.umd = replaceName(
pkg['umd:main'] || pkg.unpkg || 'x.umd.js',
mainNoExtension,
);
return mainsByFormat[format] || mainsByFormat.cjs;
}
////////////////////////////////////////////////////////////////////////////////
// Handle building in `watch` mode
function doWatch(options, cwd, steps) {
const { onStart, onBuild, onError } = options;
return new Promise(resolve => {
const targetDir = './' + relative(cwd, dirname(options.output));
print(
`Watching source, compiling to ${bold(targetDir)} ${dim(
'(Ctrl + C to stop)',
)}`,
);
const watchers = steps.reduce((acc, options) => {
acc[options.inputOptions.input] = watch(
Object.assign(
{
output: options.outputOptions,
watch: WATCH_OPTS,
},
options.inputOptions,
),
).on('event', e => {
if (e.code === 'START') {
if (typeof onStart === 'function') {
onStart(e);
}
}
if (e.code === 'ERROR') {
printErr(e.error);
if (typeof onError === 'function') {
onError(e);
}
}
if (e.code === 'END') {
if (options._sizeInfo) {
options._sizeInfo.then(text => {
for (let entry of text) {
const base = entry != null && entry.base ? entry.base : '';
const gzip = entry != null && entry.gzip ? entry.gzip : '';
const brotli =
entry != null && entry.brotli ? entry.brotli : '';
print(`Wrote ${base} ${dim(`(${gzip}; ${brotli})`)}`);
}
});
}
if (typeof onBuild === 'function') {
onBuild(e);
}
}
});
return acc;
}, {});
resolve({ watchers });
});
}
////////////////////////////////////////////////////////////////////////////////
// shebang cache map because the transform only gets run once
const shebang = {};
// Create a babel transform configuration
function createConfig(options, entry, format, writeMeta) {
let { pkg } = options;
// Define output aliases
let outputAliases = {};
// since we transform src/index.js, we need to rename imports for it:
if (options.multipleEntries) {
outputAliases['.'] = './' + basename(options.output);
}
// Handle aliasing node modules
const moduleAliases = options.alias ? parseAliasArgument(options.alias) : [];
const aliasIds = moduleAliases.map(alias => alias.find);
// Handle replacement of constants with hard-coded values
let defines = {};
if (options.define) {
defines = Object.assign(
defines,
parseMappingArgument(options.define, toReplacementExpression),
);
}
// Set modern flag
const modern = format === 'modern';
// Define externals and globals
let external = ['dns', 'fs', 'path', 'url'].concat(
options.entries.filter(e => e !== entry),
);
// We want to silence rollup warnings for node builtins as
// rollup-node-resolve treats them as externals anyway
// @see https://github.com/rollup/plugins/tree/master/packages/node-resolve/#resolving-built-ins-like-fs
if (options.target === 'node') {
external = external.concat([/node:.*/], ...builtinModules);
}
const peerDeps = Object.keys(pkg.peerDependencies || {});
if (options.external === 'none') {
// Bundle everything (external=[])
} else if (options.external) {
external = external.concat(peerDeps).concat(
// CLI --external supports regular expressions
options.external.split(',').map(str => new RegExp(str)),
);
} else {
external = external
.concat(peerDeps)
.concat(Object.keys(pkg.dependencies || {}));
}
let globals = external.reduce((globals, name) => {
// Use raw value for CLI-provided RegExp externals
if (name instanceof RegExp) name = name.source;
// Valid JS identifiers are usually library globals
if (name.match(/^[a-z_$][a-z0-9_\-$]*$/)) {
globals[name] = camelCase(name);
}
return globals;
}, {});
if (options.globals && options.globals !== 'none') {
globals = Object.assign(globals, parseMappingArgument(options.globals));
}
let nameCache = {};
const bareNameCache = nameCache;
// Support "minify" field and legacy "mangle" field via package.json:
const rawMinifyValue = options.pkg.minify || options.pkg.mangle || {};
let minifyOptions = typeof rawMinifyValue === 'string' ? {} : rawMinifyValue;
const getNameCachePath =
typeof rawMinifyValue === 'string'
? () => resolve(options.cwd, rawMinifyValue)
: () => resolve(options.cwd, 'mangle.json');
const externalPredicate = new RegExp(
`^(${external
.map(ext =>
ext instanceof RegExp ? ext.source : escapeStringRegexp(ext),
)
.join('|')})($|/)`,
);
let endsWithNewLine = false;
let nameCacheIndentTabs = false;
function loadNameCache() {
try {
const data = fs.readFileSync(getNameCachePath(), 'utf8');
nameCacheIndentTabs = /^\t+/gm.test(data);
endsWithNewLine = data.endsWith(EOL);
nameCache = JSON.parse(data);
// mangle.json can contain a "minify" field, same format as the pkg.
// mangle:
if (nameCache.minify) {
minifyOptions = Object.assign(
{},
minifyOptions || {},
nameCache.minify,
);
}
// eslint-disable-next-line no-unused-vars
} catch (_) {
/* empty */
}
}
loadNameCache();
normalizeMinifyOptions(minifyOptions);
if (nameCache === bareNameCache) nameCache = null;
const absMain = resolve(options.cwd, getMain({ options, entry, format }));
const outputDir = dirname(absMain);
const outputEntryFileName = basename(absMain);
const targetDir =
'./' + relative(options.cwd, dirname(options.output)) || '.';
// Warn about the (somewhat) breaking change in #950
if (format === 'es' && !pkg.module && outputEntryFileName.endsWith('.mjs')) {
printWarn(
'Warning: your package.json does not specify {"type":"module"}. jvdx assumes this is a CommonJS package and is generating ES Modules with the ".mjs" file extension.',
);
}
const useTypescript = extname(entry) === '.ts' || extname(entry) === '.tsx';
const emitDeclaration =
options.generateTypes == null
? !!(pkg.types || pkg.typings)
: options.generateTypes;
const useWorkerLoader = options.workers !== false;
let config = {
inputOptions: {
// Disable Rollup's cache for modern builds to prevent re-use of legacy
// transpiled modules:
cache: modern ? false : undefined,
input: entry,
external: id => {
if (id === 'babel-plugin-transform-async-to-promises/helpers') {
return false;
}
if (options.multipleEntries && id === '.') {
return true;
}
if (aliasIds.indexOf(id) >= 0) {
return false;
}
if (external.length === 0) {
return false;
}
return externalPredicate.test(id);
},
onwarn: warning => {
// https://github.com/rollup/rollup/blob/0fa9758cb7b1976537ae0875d085669e3a21e918/src/utils/error.ts#L324
if (warning.code === 'UNRESOLVED_IMPORT') {
printWarn(
`Failed to resolve the module ${warning.source} imported by ${warning.importer}` +
`\nIs the module installed? Note:` +
`\n ↳ to inline a module into your bundle, install it to "devDependencies".` +
`\n ↳ to depend on a module via import/require, install it to "dependencies".`,
);
return;
}
printWarn(warning.message);
},
treeshake: {
propertyReadSideEffects: false,
},
plugins: []
.concat(
// Process CSS files with post-css and autoprefixer
postcss({
plugins: [autoprefixer()],
autoModules: shouldCssModules(options),
modules: cssModulesConfig(options),
// only write out CSS for the first bundle
// (avoids pointless extra files)
inject: false,
extract:
!!writeMeta &&
options.css !== 'inline' &&
options.output.replace(BUILD_EXTENSIONS, '.css'),
minimize: options.compress,
sourceMap: options.sourcemap && options.css !== 'inline',
}),
// Process module aliases
moduleAliases.length > 0 && alias({ entries: moduleAliases }),
// Locate modules using the Node resolution algorithm
nodeResolve({
mainFields: ['module', 'jsnext', 'main'],
browser: options.target !== 'node',
exportConditions: [options.target === 'node' ? 'node' : 'browser'],
// defaults + .jsx
extensions: ['.mjs', '.js', '.jsx', '.json', '.node'],
preferBuiltins: options.target === 'node' ? true : undefined,
}),
{
// We have to remove shebang so it doesn't end up in the middle of
// the code somewhere
transform: code => ({
code: code.replace(/^#![^\n]*/, bang => {
shebang[options.name] = bang;
}),
map: null,
}),
},
// Convert CommonJS modules to ES6 modules
// Use a regex to make sure to include eventual hoisted packages
commonjs({
include: /\/node_modules\//,
esmExternals: false,
requireReturnsDefault: 'namespace',
}),
// Convert .json files to ES6 modules
json(),
// Handle Typescript
(useTypescript || emitDeclaration) &&
typescript({
cwd: options.cwd,
typescript: require(
resolveFrom(options.cwd, 'typescript', true) || 'typescript',
),
cacheRoot: `./node_modules/.cache/.rts2_cache_${format}`,
useTsconfigDeclarationDir: true,
tsconfigDefaults: {
compilerOptions: {
sourceMap: options.sourcemap,
declaration: options.generateTypes !== false,
allowJs: true,
emitDeclarationOnly: options.generateTypes && !useTypescript,
...(options.generateTypes !== false && {
declarationDir: getDeclarationDir({ options, pkg }),
}),
jsx: 'preserve',
jsxFactory: options.jsx ? 'React.createElement' : undefined,
jsxFragmentFactory: options.jsx
? 'React.Fragment'
: undefined,
},
files: options.entries,
},
tsconfig: options.tsconfig,
tsconfigOverride: {
compilerOptions: {
module: 'ESNext',
target: 'ESNext',
},
},
}),
// If defines is not set, we shouldn't run babel through node_modules
isTruthy(defines) &&
babel({
babelHelpers: 'bundled',
babelrc: false,
compact: false,
configFile: false,
include: 'node_modules/**',
plugins: [
[
require.resolve('babel-plugin-transform-replace-expressions'),
{ replace: defines },
],
],
}),
customBabel()({
babelHelpers: 'bundled',
extensions: ['.ts', '.tsx', '.js', '.jsx', '.es6', '.es', '.mjs'],
// NOTE(joel): Use a regex to make sure to exclude eventual hoisted
// packages
exclude: /\/node_modules\//,
// @see https://babeljs.io/docs/en/options#passperpreset
passPerPreset: true,
custom: {
defines,
modern,
compress: options.compress !== false,
targets: options.target === 'node' ? { node: '14' } : undefined,
typescript: !!useTypescript,
jsx: options.jsx || false,
},
}),
options.compress !== false && [
terser({
compress: Object.assign(
{
keep_infinity: true,
pure_getters: true,
// Ideally we'd just get Terser to respect existing Arrow
// functions...
// unsafe_arrows: true,
passes: 10,
},
typeof minifyOptions.compress === 'boolean'
? minifyOptions.compress
: minifyOptions.compress || {},
),
format: {
// By default, Terser wraps function arguments in extra parens
// to trigger eager parsing. Whether this is a good idea is way
// too specific to guess, so we optimize for size by default
wrap_func_args: false,
comments: /^\s*([@#]__[A-Z]__\s*$|@cc_on)/,
preserve_annotations: true,
},
module: modern,
ecma: modern ? 2017 : 5,
toplevel: modern || format === 'cjs' || format === 'es',
mangle:
typeof minifyOptions.mangle === 'boolean'
? minifyOptions.mangle
: Object.assign({}, minifyOptions.mangle || {}),
nameCache,
}),
nameCache && {
// before hook
options: loadNameCache,
// after hook
writeBundle() {
if (writeMeta && nameCache) {
const filename = getNameCachePath();
let json = JSON.stringify(
nameCache,
null,
nameCacheIndentTabs ? '\t' : 2,
);
if (endsWithNewLine) json += EOL;
fs.writeFile(filename, json, () => {});
}
},
},
],
// Off-Main-Thread only works with amd and esm.
// @see: https://github.com/surma/rollup-plugin-off-main-thread#config
useWorkerLoader && (format === 'es' || modern) && offmainthread(),
/** @type {import('rollup').Plugin} */
({
name: 'postprocessing',
// Rollup 2 injects `globalThis`, which is nice, but doesn't really
// make sense for jvdx. Only ESM environments necessitate
// `globalThis`, and UMD bundles can't be properly loaded as ESM.
// So we remove the `globalThis` check, replacing it with
// `this||self` to match Rollup 1's output
renderChunk(code, chunk, opts) {
if (opts.format === 'umd') {
// minified:
code = code.replace(
/([a-zA-Z$_]+)="undefined"!=typeof globalThis\?globalThis:(\1\|\|self)/,
'$2',
);
// unminified:
code = code.replace(
/(global *= *)typeof +globalThis *!== *['"]undefined['"] *\? *globalThis *: *(global *\|\| *self)/,
'$1$2',
);
return { code, map: null };
}
},
writeBundle(_, bundle) {
config._sizeInfo = Promise.all(
Object.values(bundle).map(({ code, fileName }) => {
if (code) {
return getSizeInfo(
code,
`${dim(targetDir + '/')}${fileName}`,
options.raw,
);
}
return null;
}),
);
},
}),
)
.filter(Boolean),
},
outputOptions: {
paths: outputAliases,
globals,
strict: options.strict === true,
freeze: false,
esModule: false,
sourcemap: options.sourcemap,
get banner() {
return shebang[options.name];
},
format: modern ? 'es' : format,
name: options.name && options.name.replace(/^global\./, ''),
extend: /^global\./.test(options.name),
dir: outputDir,
entryFileNames: outputEntryFileName,
exports: 'auto',
},
};
return config;
}