microbundle/src/index.js
2018-08-14 13:10:36 +02:00

470 lines
12 KiB
JavaScript

import 'acorn-jsx';
import fs from 'fs';
import { resolve, relative, dirname, basename, extname } from 'path';
import chalk from 'chalk';
import { map, series } from 'asyncro';
import glob from 'tiny-glob/sync';
import autoprefixer from 'autoprefixer';
import cssnano from 'cssnano';
import { rollup, watch } from 'rollup';
import nodent from 'rollup-plugin-nodent';
import commonjs from 'rollup-plugin-commonjs';
import nodeResolve from 'rollup-plugin-node-resolve';
import buble from 'rollup-plugin-buble';
import uglify from 'rollup-plugin-uglify';
import postcss from 'rollup-plugin-postcss';
import alias from 'rollup-plugin-strict-alias';
import gzipSize from 'gzip-size';
import brotliSize from 'brotli-size';
import prettyBytes from 'pretty-bytes';
import shebangPlugin from 'rollup-plugin-preserve-shebang';
import typescript from 'rollup-plugin-typescript2';
import flow from './lib/flow-plugin';
import logError from './log-error';
import { readFile, isDir, isFile, stdout, stderr } from './utils';
import camelCase from 'camelcase';
const removeScope = name => name.replace(/^@.*\//, '');
const safeVariableName = name =>
camelCase(
removeScope(name)
.toLowerCase()
.replace(/((^[^a-zA-Z]+)|[^\w.-])|([^a-zA-Z0-9]+$)/g, ''),
);
const parseGlobals = globalStrings => {
const globals = {};
globalStrings.split(',').forEach(globalString => {
const [localName, globalName] = globalString.split('=');
globals[localName] = globalName;
});
return globals;
};
const WATCH_OPTS = {
exclude: 'node_modules/**',
};
export default async function microbundle(options) {
let cwd = (options.cwd = resolve(process.cwd(), options.cwd)),
hasPackageJson = true;
try {
options.pkg = JSON.parse(
await readFile(resolve(cwd, 'package.json'), 'utf8'),
);
} catch (err) {
stderr(
chalk.yellow(
`${chalk.yellow.inverse(
'WARN',
)} no package.json found. Assuming a pkg.name of "${basename(
options.cwd,
)}".`,
),
);
let msg = String(err.message || err);
if (!msg.match(/ENOENT/)) stderr(` ${chalk.red.dim(msg)}`);
options.pkg = {};
hasPackageJson = false;
}
if (!options.pkg.name) {
options.pkg.name = basename(options.cwd);
if (hasPackageJson) {
stderr(
chalk.yellow(
`${chalk.yellow.inverse(
'WARN',
)} missing package.json "name" field. Assuming "${
options.pkg.name
}".`,
),
);
}
}
options.name =
options.name || options.pkg.amdName || safeVariableName(options.pkg.name);
const jsOrTs = async filename =>
resolve(
cwd,
`${filename}${
(await isFile(resolve(cwd, filename + '.ts')))
? '.ts'
: (await isFile(resolve(cwd, filename + '.tsx')))
? '.tsx'
: '.js'
}`,
);
options.input = [];
[]
.concat(
options.entries && options.entries.length
? options.entries
: (options.pkg.source && resolve(cwd, options.pkg.source)) ||
((await isDir(resolve(cwd, 'src'))) && (await jsOrTs('src/index'))) ||
(await jsOrTs('index')) ||
options.pkg.module,
)
.map(file => glob(file))
.forEach(file => options.input.push(...file));
let main = resolve(cwd, options.output || options.pkg.main || 'dist');
if (!main.match(/\.[a-z]+$/) || (await isDir(main))) {
main = resolve(main, `${removeScope(options.pkg.name)}.js`);
}
options.output = main;
let entries = (await map([].concat(options.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);
options.entries = entries;
options.multipleEntries = entries.length > 1;
let formats = (options.format || options.formats).split(',');
// always compile cjs first if it's there:
formats.sort((a, b) => (a === 'cjs' ? -1 : a > b ? 1 : 0));
let steps = [];
for (let i = 0; i < entries.length; i++) {
for (let j = 0; j < formats.length; j++) {
steps.push(
createConfig(options, entries[i], formats[j], i === 0 && j === 0),
);
}
}
function formatSize(size, filename, type) {
const pretty = prettyBytes(size);
const color = size < 5000 ? 'green' : size > 40000 ? 'red' : 'yellow';
const MAGIC_INDENTATION = type === 'br' ? 13 : 10;
return `${' '.repeat(MAGIC_INDENTATION - pretty.length)}${chalk[color](
pretty,
)}: ${chalk.white(basename(filename))}.${type}`;
}
async function getSizeInfo(code, filename) {
const gzip = formatSize(await gzipSize(code), filename, 'gz');
const brotli = formatSize(brotliSize.sync(code), filename, 'br');
return gzip + '\n' + brotli;
}
if (options.watch) {
const onBuild = options.onBuild;
return new Promise((resolve, reject) => {
stdout(
chalk.blue(
`Watching source, compiling to ${relative(
cwd,
dirname(options.output),
)}:`,
),
);
steps.map(options => {
watch(
Object.assign(
{
output: options.outputOptions,
watch: WATCH_OPTS,
},
options.inputOptions,
),
).on('event', e => {
if (e.code === 'FATAL') {
return reject(e.error);
} else if (e.code === 'ERROR') {
logError(e.error);
}
if (e.code === 'END') {
getSizeInfo(options._code, options.outputOptions.file).then(
text => {
stdout(`Wrote ${text.trim()}`);
},
);
if (typeof onBuild === 'function') {
onBuild(e);
}
}
});
});
});
}
let cache;
let out = await series(
steps.map(({ inputOptions, outputOptions }) => async () => {
inputOptions.cache = cache;
let bundle = await rollup(inputOptions);
cache = bundle;
const { code } = await bundle.write(outputOptions);
return await getSizeInfo(code, outputOptions.file);
}),
);
return (
chalk.blue(
`Build "${options.name}" to ${relative(cwd, dirname(options.output)) ||
'.'}:`,
) +
'\n ' +
out.join('\n ')
);
}
function createConfig(options, entry, format, writeMeta) {
let { pkg } = options;
let external = ['dns', 'fs', 'path', 'url'].concat(
options.entries.filter(e => e !== entry),
);
let aliases = {};
// since we transform src/index.js, we need to rename imports for it:
if (options.multipleEntries) {
aliases['.'] = './' + basename(options.output);
}
let useNodeResolve = true;
const peerDeps = Object.keys(pkg.peerDependencies || {});
if (options.external === 'none') {
// bundle everything (external=[])
} else if (options.external) {
external = external.concat(peerDeps).concat(options.external.split(','));
} else {
external = external
.concat(peerDeps)
.concat(Object.keys(pkg.dependencies || {}));
}
let globals = external.reduce((globals, name) => {
// valid JS identifiers are usually library globals:
if (name.match(/^[a-z_$][a-z0-9_$]*$/)) {
globals[name] = name;
}
return globals;
}, {});
if (options.globals && options.globals !== 'none') {
globals = Object.assign(globals, parseGlobals(options.globals));
}
function replaceName(filename, name) {
return resolve(
dirname(filename),
name + basename(filename).replace(/^[^.]+/, ''),
);
}
let mainNoExtension = options.output;
if (options.multipleEntries) {
let name = entry.match(/([\\/])index(\.(umd|cjs|es|m))?\.m?js$/)
? mainNoExtension
: entry;
mainNoExtension = resolve(dirname(mainNoExtension), basename(name));
}
mainNoExtension = mainNoExtension.replace(/(\.(umd|cjs|es|m))?\.m?js$/, '');
let moduleMain = replaceName(
pkg.module && !pkg.module.match(/src\//)
? pkg.module
: pkg['jsnext:main'] || 'x.mjs',
mainNoExtension,
);
let cjsMain = replaceName(pkg['cjs:main'] || 'x.js', mainNoExtension);
let umdMain = replaceName(pkg['umd:main'] || 'x.umd.js', mainNoExtension);
// let rollupName = safeVariableName(basename(entry).replace(/\.js$/, ''));
let nameCache = {};
let mangleOptions = options.pkg.mangle || false;
let exportType;
if (format !== 'es') {
try {
let file = fs.readFileSync(entry, 'utf-8');
let hasDefault = /\bexport\s*default\s*[a-zA-Z_$]/.test(file);
let hasNamed =
/\bexport\s*(let|const|var|async|function\*?)\s*[a-zA-Z_$*]/.test(
file,
) || /^\s*export\s*\{/m.test(file);
if (hasDefault && hasNamed) exportType = 'default';
} catch (e) {}
}
const useTypescript = extname(entry) === '.ts' || extname(entry) === '.tsx';
const externalPredicate = new RegExp(`^(${external.join('|')})($|/)`);
const externalTest =
external.length === 0 ? () => false : id => externalPredicate.test(id);
function loadNameCache() {
try {
nameCache = JSON.parse(
fs.readFileSync(resolve(options.cwd, 'mangle.json'), 'utf8'),
);
} catch (e) {}
}
loadNameCache();
let config = {
inputOptions: {
input: exportType ? resolve(__dirname, '../src/lib/__entry__.js') : entry,
external: id => {
if (options.multipleEntries && id === '.') {
return true;
}
return externalTest(id);
},
plugins: []
.concat(
alias({
__microbundle_entry__: entry,
}),
postcss({
plugins: [
autoprefixer(),
options.compress !== false &&
cssnano({
preset: 'default',
}),
].filter(Boolean),
// only write out CSS for the first bundle (avoids pointless extra files):
inject: false,
extract: !!writeMeta,
}),
useTypescript &&
typescript({
typescript: require('typescript'),
tsconfigDefaults: {
compilerOptions: { declaration: true, jsx: options.jsx },
},
}),
!useTypescript && flow({ all: true, pretty: true }),
nodent({
exclude: 'node_modules/**',
noRuntime: true,
promises: true,
transformations: {
forOf: false,
},
parser: {
plugins: {
jsx: true,
},
},
}),
!useTypescript &&
buble({
exclude: 'node_modules/**',
jsx: options.jsx || 'h',
objectAssign: options.assign || 'Object.assign',
transforms: {
dangerousForOf: true,
dangerousTaggedTemplateString: true,
},
}),
useNodeResolve &&
commonjs({
include: 'node_modules/**',
}),
useNodeResolve &&
nodeResolve({
module: true,
jsnext: true,
browser: options.target !== 'node',
}),
// We should upstream this to rollup
// format==='cjs' && replace({
// [`module.exports = ${rollupName};`]: '',
// [`var ${rollupName} =`]: 'module.exports ='
// }),
// This works for the general case, but could cause nasty scope bugs.
// format==='umd' && replace({
// [`return ${rollupName};`]: '',
// [`var ${rollupName} =`]: 'return'
// }),
// format==='es' && replace({
// [`export default ${rollupName};`]: '',
// [`var ${rollupName} =`]: 'export default'
// }),
options.compress !== false && [
uglify({
sourceMap: true,
output: { comments: false },
compress: {
keep_infinity: true,
pure_getters: true,
},
warnings: true,
ecma: 5,
toplevel: format === 'cjs' || format === 'es',
mangle: {
properties: mangleOptions
? {
regex: mangleOptions.regex
? new RegExp(mangleOptions.regex)
: null,
reserved: mangleOptions.reserved || [],
}
: false,
},
nameCache,
}),
mangleOptions && {
// before hook
options: loadNameCache,
// after hook
onwrite() {
if (writeMeta && nameCache) {
fs.writeFile(
resolve(options.cwd, 'mangle.json'),
JSON.stringify(nameCache, null, 2),
Object,
);
}
},
},
],
{
ongenerate(outputOptions, { code }) {
config._code = code;
},
},
shebangPlugin(),
)
.filter(Boolean),
},
outputOptions: {
exports: exportType ? 'default' : undefined,
paths: aliases,
globals,
strict: options.strict === true,
legacy: true,
freeze: false,
esModule: false,
sourcemap: options.sourcemap !== false,
treeshake: {
propertyReadSideEffects: false,
},
format,
name: options.name,
file: resolve(
options.cwd,
(format === 'es' && moduleMain) ||
(format === 'umd' && umdMain) ||
cjsMain,
),
},
};
return config;
}