diff --git a/package.json b/package.json index 3d20f245..1d700858 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "source": "src/index.js", "bin": "dist/cli.js", "scripts": { - "build": "npm run -s build:babel && npm run -s build:self", + "build": "npm run -s build:babel", "build:babel": "babel-node src/cli.js --target=node --format cjs src/{cli,index}.js", "build:self": "node dist/cli.js --target=node --format cjs src/{cli,index}.js", "prepare": "npm run -s build", diff --git a/src/cli.js b/src/cli.js index 25be92cb..8f746280 100644 --- a/src/cli.js +++ b/src/cli.js @@ -1,6 +1,6 @@ #!/usr/bin/env node -import microbundle from '.'; +import microbundle from './index'; import prog from './prog'; import { stdout } from './utils'; import logError from './log-error'; diff --git a/src/index.js b/src/index.js index b83f0b04..5ff18784 100644 --- a/src/index.js +++ b/src/index.js @@ -1,7 +1,9 @@ import fs from 'fs'; import { resolve, relative, dirname, basename, extname } from 'path'; -import { green, red, yellow, white, blue } from 'kleur'; -import { map, series } from 'asyncro'; +import camelCase from 'camelcase'; +import escapeStringRegexp from 'escape-string-regexp'; +import { blue } from 'kleur'; +import { map } from 'asyncro'; import glob from 'tiny-glob/sync'; import autoprefixer from 'autoprefixer'; import cssnano from 'cssnano'; @@ -13,93 +15,20 @@ import nodeResolve from '@rollup/plugin-node-resolve'; import { terser } from 'rollup-plugin-terser'; import alias from '@rollup/plugin-alias'; import postcss from 'rollup-plugin-postcss'; -import gzipSize from 'gzip-size'; -import brotliSize from 'brotli-size'; -import prettyBytes from 'pretty-bytes'; import typescript from 'rollup-plugin-typescript2'; import json from '@rollup/plugin-json'; import logError from './log-error'; -import { readFile, isDir, isFile, stdout, stderr, isTruthy } from './utils'; -import camelCase from 'camelcase'; -import escapeStringRegexp from 'escape-string-regexp'; - -const removeScope = name => name.replace(/^@.*\//, ''); - -// Convert booleans and int define= values to literals. -// This is more intuitive than `microbundle --define A=1` producing A="1". -const toReplacementExpression = (value, name) => { - // --define A="1",B='true' produces string: - const matches = value.match(/^(['"])(.+)\1$/); - if (matches) { - return [JSON.stringify(matches[2]), name]; - } - - // --define @assign=Object.assign replaces expressions with expressions: - if (name[0] === '@') { - return [value, name.substring(1)]; - } - - // --define A=1,B=true produces int/boolean literal: - if (/^(true|false|\d+)$/i.test(value)) { - return [value, name]; - } - - // default: string literal - return [JSON.stringify(value), name]; -}; - -// Normalize Terser options from microbundle's relaxed JSON format (mutates argument in-place) -function normalizeMinifyOptions(minifyOptions) { - const mangle = minifyOptions.mangle || (minifyOptions.mangle = {}); - let properties = mangle.properties; - - // allow top-level "properties" key to override mangle.properties (including {properties:false}): - if (minifyOptions.properties != null) { - properties = mangle.properties = - minifyOptions.properties && - Object.assign(properties, minifyOptions.properties); - } - - // allow previous format ({ mangle:{regex:'^_',reserved:[]} }): - if (minifyOptions.regex || minifyOptions.reserved) { - if (!properties) properties = mangle.properties = {}; - properties.regex = properties.regex || minifyOptions.regex; - properties.reserved = properties.reserved || minifyOptions.reserved; - } - - if (properties) { - if (properties.regex) properties.regex = new RegExp(properties.regex); - properties.reserved = [].concat(properties.reserved || []); - } -} - -// Parses values of the form "$=jQuery,React=react" into key-value object pairs. -const parseMappingArgument = (globalStrings, processValue) => { - const globals = {}; - globalStrings.split(',').forEach(globalString => { - let [key, value] = globalString.split('='); - if (processValue) { - const r = processValue(value, key); - if (r !== undefined) { - if (Array.isArray(r)) { - [value, key] = r; - } else { - value = r; - } - } - } - globals[key] = value; - }); - return globals; -}; - -// Parses values of the form "$=jQuery,React=react" into key-value object pairs. -const parseMappingArgumentAlias = aliasStrings => { - return aliasStrings.split(',').map(str => { - let [key, value] = str.split('='); - return { find: key, replacement: value }; - }); -}; +import { isDir, isFile, stdout, isTruthy, removeScope } from './utils'; +import { getSizeInfo } from './lib/compressed-size'; +import { normalizeMinifyOptions } from './lib/terser'; +import { + parseAliasArgument, + parseExternals, + parseMappingArgument, + toReplacementExpression, +} from './lib/option-normalization'; +import { getConfigFromPkgJson, getName } from './lib/package-info'; +import { shouldCssModules, cssModulesConfig } from './lib/css-modules'; // Extensions to use when resolving modules const EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.es6', '.es', '.mjs']; @@ -108,39 +37,6 @@ const WATCH_OPTS = { exclude: 'node_modules/**', }; -// Hoist function because something (rollup?) incorrectly removes it -function formatSize(size, filename, type, raw) { - const pretty = raw ? `${size} B` : prettyBytes(size); - const color = size < 5000 ? green : size > 40000 ? red : yellow; - const MAGIC_INDENTATION = type === 'br' ? 13 : 10; - return `${' '.repeat(MAGIC_INDENTATION - pretty.length)}${color( - pretty, - )}: ${white(basename(filename))}.${type}`; -} - -async function getSizeInfo(code, filename, raw) { - const gzip = formatSize( - await gzipSize(code), - filename, - 'gz', - raw || code.length < 5000, - ); - let brotli; - //wrap brotliSize in try/catch in case brotli is unavailable due to - //lower node version - try { - brotli = formatSize( - await brotliSize(code), - filename, - 'br', - raw || code.length < 5000, - ); - } catch (e) { - return gzip; - } - return gzip + '\n' + brotli; -} - export default async function microbundle(inputOptions) { let options = { ...inputOptions }; @@ -184,157 +80,94 @@ export default async function microbundle(inputOptions) { input: options.input, }); - options.multipleEntries = options.entries.length > 1; - - // to disable compress you can put in false or 0 but it's a string so our boolean checks won't work - options.compress = - typeof options.compress !== 'boolean' - ? options.compress !== 'false' && options.compress !== '0' - : options.compress; + // options.multipleEntries = options.entries.length > 1; + options.multipleEntries = false; 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)); + const bundle = await rollup(getConfigInput(options)); + 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, - ), - ); - } - } + // for (let i = 0; i < options.entries.length; i++) { + // for (let j = 0; j < formats.length; j++) { + // steps.push(createConfig(options, options.entries[0], formats[j], j === 0)); + // } + // } if (options.watch) { - const { onStart, onBuild, onError } = options; - return new Promise(resolve => { - stdout( - blue( - `Watching source, compiling to ${relative( - cwd, - dirname(options.output), - )}:`, - ), - ); - - 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') { - logError(e.error); - if (typeof onError === 'function') { - onError(e); - } - } - if (e.code === 'END') { - options._sizeInfo.then(text => { - stdout(`Wrote ${text.trim()}`); - }); - if (typeof onBuild === 'function') { - onBuild(e); - } - } - }); + return doWatch(options, cwd, steps); + } - return acc; - }, {}); + let out = []; + for (let i = 0; i < formats.length; i++) { + const { output } = await bundle.write( + getConfigOutput(options, formats[i], i === 0), + ); - resolve({ watchers }); - }); + out.push( + await Promise.all( + output.map(({ code, fileName }) => { + if (code) { + return getSizeInfo(code, fileName, options.raw); + } + }), + ).then(results => results.filter(Boolean).join('\n')), + ); } + // ); - let cache; - let out = await series( - 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 targetDir = relative(cwd, dirname(options.output)) || '.'; + const banner = blue(`Build "${options.name}" to ${targetDir}:`); return { - output: - blue( - `Build "${options.name}" to ${relative(cwd, dirname(options.output)) || - '.'}:`, - ) + - '\n ' + - out.join('\n '), + output: `${banner}\n ${out.join('\n ')}`, }; } -async function getConfigFromPkgJson(cwd) { - try { - const pkgJSON = await readFile(resolve(cwd, 'package.json'), 'utf8'); - const pkg = JSON.parse(pkgJSON); - - return { - hasPackageJson: true, - pkg, - }; - } catch (err) { - const pkgName = basename(cwd); - - stderr( - // `Warn ${yellow(`no package.json found. Assuming a pkg.name of "${pkgName}".`)}` - yellow( - `${yellow().inverse( - 'WARN', - )} no package.json found. Assuming a pkg.name of "${pkgName}".`, - ), - ); - - let msg = String(err.message || err); - if (!msg.match(/ENOENT/)) stderr(` ${red().dim(msg)}`); +function doWatch(options, cwd, steps) { + const { onStart, onBuild, onError } = options; - return { hasPackageJson: false, pkg: { name: pkgName } }; - } -} + return new Promise((resolve, reject) => { + const targetDir = relative(cwd, dirname(options.output)); + stdout(blue(`Watching source, compiling to ${targetDir}:`)); -const safeVariableName = name => - camelCase( - removeScope(name) - .toLowerCase() - .replace(/((^[^a-zA-Z]+)|[^\w.-])|([^a-zA-Z0-9]+$)/g, ''), - ); - -function getName({ name, pkgName, amdName, cwd, hasPackageJson }) { - if (!pkgName) { - pkgName = basename(cwd); - if (hasPackageJson) { - stderr( - yellow( - `${yellow().inverse( - 'WARN', - )} missing package.json "name" field. Assuming "${pkgName}".`, + 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') { + logError(e.error); + if (typeof onError === 'function') { + onError(e); + } + } + if (e.code === 'END') { + options._sizeInfo.then(text => { + stdout(`Wrote ${text.trim()}`); + }); + if (typeof onBuild === 'function') { + onBuild(e); + } + } + }); + + return acc; + }, {}); - return { finalName: name || amdName || safeVariableName(pkgName), pkgName }; + resolve({ watchers }); + }); } async function jsOrTs(cwd, filename) { @@ -421,12 +254,12 @@ function getMain({ options, entry, format }) { } let mainNoExtension = options.output; - if (options.multipleEntries) { - let name = entry.match(/([\\/])index(\.(umd|cjs|es|m))?\.(mjs|[tj]sx?)$/) - ? mainNoExtension - : entry; - mainNoExtension = resolve(dirname(mainNoExtension), basename(name)); - } + // if (options.multipleEntries) { + let name = entry.match(/([\\/])index(\.(umd|cjs|es|m))?\.(mjs|[tj]sx?)$/) + ? mainNoExtension + : entry; + mainNoExtension = resolve(dirname(mainNoExtension), basename(name)); + // } mainNoExtension = mainNoExtension.replace( /(\.(umd|cjs|es|m))?\.(mjs|[tj]sx?)$/, '', @@ -456,13 +289,156 @@ function getMain({ options, entry, format }) { // shebang cache map because the transform only gets run once const shebang = {}; -function createConfig(options, entry, format, writeMeta) { - let { pkg } = options; +function getConfigInput(options) { + const { pkg } = options; - /** @type {(string|RegExp)[]} */ - let external = ['dns', 'fs', 'path', 'url'].concat( - options.entries.filter(e => e !== entry), + const moduleAliases = options.alias ? parseAliasArgument(options.alias) : []; + const aliasIds = moduleAliases.map(alias => alias.find); + + const useTypescript = options.entries.some(entry => { + const ext = extname(entry); + return ext === '.ts' || ext === '.tsx'; + }); + + const external = /** @type {Array} */ ([ + 'dns', + 'fs', + 'path', + 'url', + ]) + .concat(options.entries) + .concat( + parseExternals(options.external, pkg.peerDependencies, pkg.dependencies), + ); + + const escapeStringExternals = ext => + ext instanceof RegExp ? ext.source : escapeStringRegexp(ext); + const externalPredicate = new RegExp( + `^(${external.map(escapeStringExternals).join('|')})($|/)`, ); + const externalTest = + external.length === 0 ? id => false : id => externalPredicate.test(id); + + /** @type {import('rollup').InputOptions} */ + const config = { + input: options.entries.reduce((acc, entry) => { + acc[ + basename(getMain({ options, entry, format: 'cjs' })).replace('.js', '') + ] = entry; + + return acc; + }, {}), + external: id => { + // include async-to-promises helper once inside the bundle + if (id === 'babel-plugin-transform-async-to-promises/helpers') { + return false; + } + + // Mark other entries as external so they don't get bundled + if (options.multipleEntries && id === '.') { + return true; + } + + if (aliasIds.indexOf(id) >= 0) { + return false; + } + return externalTest(id); + }, + treeshake: { + propertyReadSideEffects: false, + }, + plugins: [ + postcss({ + autoModules: shouldCssModules(options), + modules: cssModulesConfig(options), + // only write out CSS for the first bundle (avoids pointless extra files): + inject: false, + extract: false, + }), + moduleAliases.length > 0 && + alias({ + // @TODO: this is no longer supported, but didn't appear to be required? + // resolve: EXTENSIONS, + entries: moduleAliases, + }), + nodeResolve({ + mainFields: ['module', 'jsnext', 'main'], + browser: options.target !== 'node', + // defaults + .jsx + extensions: ['.mjs', '.js', '.jsx', '.json', '.node'], + preferBuiltins: options.target === 'node', + }), + commonjs({ + // use a regex to make sure to include eventual hoisted packages + include: /\/node_modules\//, + }), + json(), + customBabel()({ + babelHelpers: 'bundled', + extensions: EXTENSIONS, + exclude: 'node_modules/**', + passPerPreset: true, // @see https://babeljs.io/docs/en/options#passperpreset + custom: { + // defines, + // modern, + compress: options.compress !== false, + targets: options.target === 'node' ? { node: '8' } : undefined, + pragma: options.jsx || 'h', + pragmaFrag: options.jsxFragment || 'Fragment', + typescript: !!useTypescript, + }, + }), + useTypescript && + typescript({ + typescript: require('typescript'), + cacheRoot: `./node_modules/.cache/.rts2_cache`, + useTsconfigDeclarationDir: true, + tsconfigDefaults: { + compilerOptions: { + sourceMap: options.sourcemap, + declaration: true, + declarationDir: getDeclarationDir({ options, pkg }), + jsx: 'react', + jsxFactory: + // TypeScript fails to resolve Fragments when jsxFactory + // is set, even when it's the same as the default value. + options.jsx === 'React.createElement' + ? undefined + : options.jsx || 'h', + }, + files: options.entries, + }, + tsconfig: options.tsconfig, + tsconfigOverride: { + compilerOptions: { + module: 'ESNext', + target: 'esnext', + }, + }, + }), + { + // 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, + }), + }, + ], + }; + + return config; +} + +function getConfigOutput(options, format, writeMeta) { + const isModern = format === 'modern'; + + const absMain = resolve( + options.cwd, + getMain({ options, entry: options.entries[0], format }), + ); + const outputDir = dirname(absMain); /** @type {Record} */ let outputAliases = {}; @@ -471,25 +447,47 @@ function createConfig(options, entry, format, writeMeta) { outputAliases['.'] = './' + basename(options.output); } - const moduleAliases = options.alias - ? parseMappingArgumentAlias(options.alias) - : []; - const aliasIds = moduleAliases.map(alias => alias.find); + return { + paths: outputAliases, + // globals, + strict: options.strict === true, + freeze: false, + esModule: false, + sourcemap: options.sourcemap, + get banner() { + return shebang[options.name]; + }, + format: isModern ? 'es' : format, + name: options.name, + dir: outputDir, + entryFileNames: '[name].js', + }; +} + +function createConfig(options, entry, format, writeMeta) { + let { pkg } = options; - 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)), + const external = /** @type {Array} */ ([ + 'dns', + 'fs', + 'path', + 'url', + ]) + .concat(options.entries.filter(e => e !== entry)) + .concat( + parseExternals(options.external, pkg.peerDependencies, pkg.dependencies), ); - } else { - external = external - .concat(peerDeps) - .concat(Object.keys(pkg.dependencies || {})); + + /** @type {Record} */ + let outputAliases = {}; + // since we transform src/index.js, we need to rename imports for it: + if (options.multipleEntries) { + outputAliases['.'] = './' + basename(options.output); } + const moduleAliases = options.alias ? parseAliasArgument(options.alias) : []; + const aliasIds = moduleAliases.map(alias => alias.find); + let globals = external.reduce((globals, name) => { // Use raw value for CLI-provided RegExp externals: if (name instanceof RegExp) name = name.source; @@ -628,34 +626,6 @@ function createConfig(options, entry, format, writeMeta) { map: null, }), }, - useTypescript && - typescript({ - typescript: require('typescript'), - cacheRoot: `./node_modules/.cache/.rts2_cache_${format}`, - useTsconfigDeclarationDir: true, - tsconfigDefaults: { - compilerOptions: { - sourceMap: options.sourcemap, - declaration: true, - declarationDir: getDeclarationDir({ options, pkg }), - jsx: 'react', - jsxFactory: - // TypeScript fails to resolve Fragments when jsxFactory - // is set, even when it's the same as the default value. - options.jsx === 'React.createElement' - ? undefined - : options.jsx || 'h', - }, - 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({ @@ -726,17 +696,6 @@ function createConfig(options, entry, format, writeMeta) { }, }, ], - { - writeBundle(bundle) { - config._sizeInfo = Promise.all( - Object.values(bundle).map(({ code, fileName }) => { - if (code) { - return getSizeInfo(code, fileName, options.raw); - } - }), - ).then(results => results.filter(Boolean).join('\n')); - }, - }, ) .filter(Boolean), }, @@ -761,53 +720,3 @@ function createConfig(options, entry, format, writeMeta) { return config; } - -function shouldCssModules(options) { - const passedInOption = processCssmodulesArgument(options); - - // We should module when my-file.module.css or my-file.css - const moduleAllCss = passedInOption === true; - - // We should module when my-file.module.css - const allowOnlySuffixModule = passedInOption === null; - - return moduleAllCss || allowOnlySuffixModule; -} - -function cssModulesConfig(options) { - const passedInOption = processCssmodulesArgument(options); - const isWatchMode = options.watch; - const hasPassedInScopeName = !( - typeof passedInOption === 'boolean' || passedInOption === null - ); - - if (shouldCssModules(options) || hasPassedInScopeName) { - let generateScopedName = isWatchMode - ? '_[name]__[local]__[hash:base64:5]' - : '_[hash:base64:5]'; - - if (hasPassedInScopeName) { - generateScopedName = passedInOption; // would be the string from --css-modules "_[hash]". - } - - return { generateScopedName }; - } - - return false; -} - -/* -This is done becuase if you use the cli default property, you get a primiatve "null" or "false", -but when using the cli arguments, you always get back strings. This method aims at correcting those -for both realms. So that both realms _convert_ into primatives. -*/ -function processCssmodulesArgument(options) { - if (options['css-modules'] === 'true' || options['css-modules'] === true) - return true; - if (options['css-modules'] === 'false' || options['css-modules'] === false) - return false; - if (options['css-modules'] === 'null' || options['css-modules'] === null) - return null; - - return options['css-modules']; -} diff --git a/src/lib/compressed-size.js b/src/lib/compressed-size.js new file mode 100644 index 00000000..dd7c869a --- /dev/null +++ b/src/lib/compressed-size.js @@ -0,0 +1,32 @@ +import { basename } from 'path'; +import { green, red, yellow, white } from 'kleur'; +import gzipSize from 'gzip-size'; +import brotliSize from 'brotli-size'; +import prettyBytes from 'pretty-bytes'; + +function getPadLeft(str, width, char = ' ') { + return char.repeat(width - str.length); +} + +function formatSize(size, filename, type, raw) { + const pretty = raw ? `${size} B` : prettyBytes(size); + const color = size < 5000 ? green : size > 40000 ? red : yellow; + const indent = getPadLeft(pretty, type === 'br' ? 13 : 10); + return `${indent}${color(pretty)}: ${white(basename(filename))}.${type}`; +} + +export async function getSizeInfo(code, filename, raw) { + raw = raw || code.length < 5000; + + const [gzip, brotli] = await Promise.all([ + gzipSize(code).catch(() => null), + brotliSize(code).catch(() => null), + ]); + + let out = formatSize(gzip, filename, 'gz', raw); + if (brotli) { + out += '\n' + formatSize(brotli, filename, 'br', raw); + } + + return out; +} diff --git a/src/lib/css-modules.js b/src/lib/css-modules.js new file mode 100644 index 00000000..fcda6dc7 --- /dev/null +++ b/src/lib/css-modules.js @@ -0,0 +1,49 @@ +export function shouldCssModules(options) { + const passedInOption = processCssmodulesArgument(options); + + // We should module when my-file.module.css or my-file.css + const moduleAllCss = passedInOption === true; + + // We should module when my-file.module.css + const allowOnlySuffixModule = passedInOption === null; + + return moduleAllCss || allowOnlySuffixModule; +} + +export function cssModulesConfig(options) { + const passedInOption = processCssmodulesArgument(options); + const isWatchMode = options.watch; + const hasPassedInScopeName = !( + typeof passedInOption === 'boolean' || passedInOption === null + ); + + if (shouldCssModules(options) || hasPassedInScopeName) { + let generateScopedName = isWatchMode + ? '_[name]__[local]__[hash:base64:5]' + : '_[hash:base64:5]'; + + if (hasPassedInScopeName) { + generateScopedName = passedInOption; // would be the string from --css-modules "_[hash]". + } + + return { generateScopedName }; + } + + return false; +} + +/** + * This is done because if you use the cli default property, you get a primiatve "null" or "false", + * but when using the cli arguments, you always get back strings. This method aims at correcting those + * for both realms. So that both realms _convert_ into primatives. + */ +function processCssmodulesArgument(options) { + if (options['css-modules'] === 'true' || options['css-modules'] === true) + return true; + if (options['css-modules'] === 'false' || options['css-modules'] === false) + return false; + if (options['css-modules'] === 'null' || options['css-modules'] === null) + return null; + + return options['css-modules']; +} diff --git a/src/lib/option-normalization.js b/src/lib/option-normalization.js new file mode 100644 index 00000000..4d4b4687 --- /dev/null +++ b/src/lib/option-normalization.js @@ -0,0 +1,88 @@ +/** + * Convert booleans and int define= values to literals. + * This is more intuitive than `microbundle --define A=1` producing A="1". + */ +export function toReplacementExpression(value, name) { + // --define A="1",B='true' produces string: + const matches = value.match(/^(['"])(.+)\1$/); + if (matches) { + return [JSON.stringify(matches[2]), name]; + } + + // --define @assign=Object.assign replaces expressions with expressions: + if (name[0] === '@') { + return [value, name.substring(1)]; + } + + // --define A=1,B=true produces int/boolean literal: + if (/^(true|false|\d+)$/i.test(value)) { + return [value, name]; + } + + // default: string literal + return [JSON.stringify(value), name]; +} + +/** + * Parses values of the form "$=jQuery,React=react" into key-value object pairs. + */ +export function parseMappingArgument(globalStrings, processValue) { + const globals = {}; + globalStrings.split(',').forEach(globalString => { + let [key, value] = globalString.split('='); + if (processValue) { + const r = processValue(value, key); + if (r !== undefined) { + if (Array.isArray(r)) { + [value, key] = r; + } else { + value = r; + } + } + } + globals[key] = value; + }); + return globals; +} + +/** + * Parses values of the form "$=jQuery,React=react" into key-value object pairs. + * @param {string} aliasStrings + * @return {{ find: string, replacement: string }[]} + */ +export function parseAliasArgument(aliasStrings) { + return aliasStrings.split(',').map(str => { + let [key, value] = str.split('='); + return { find: key, replacement: value }; + }); +} + +/** + * + * @param {string} external + * @param {Record} peerDependencies + * @param {Record} dependencies + * @return {Array} + */ +export function parseExternals( + external, + peerDependencies = {}, + dependencies = {}, +) { + if (external === 'none') { + return []; + } + + const peerDeps = Object.keys(peerDependencies); + if (external) { + /** @type {Array} */ + const externals = [].concat(peerDeps).concat( + // CLI --external supports regular expressions: + external.split(',').map(str => new RegExp(str)), + ); + + return externals; + } + + return peerDeps.concat(Object.keys(dependencies)); +} diff --git a/src/lib/package-info.js b/src/lib/package-info.js new file mode 100644 index 00000000..5afddc93 --- /dev/null +++ b/src/lib/package-info.js @@ -0,0 +1,43 @@ +import { resolve, basename } from 'path'; +import { red, yellow } from 'kleur'; +import { readFile, stderr, safeVariableName } from '../utils'; + +/** */ +export async function getConfigFromPkgJson(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); + + stderr( + yellow().inverse('WARN'), + yellow(` no package.json, assuming package name is "${pkgName}".`), + ); + + let msg = String(err.message || err); + if (!msg.match(/ENOENT/)) stderr(` ${red().dim(msg)}`); + + pkg = { name: pkgName }; + } + + return { hasPackageJson, pkg }; +} +export function getName({ name, pkgName, amdName, cwd, hasPackageJson }) { + if (!pkgName) { + pkgName = basename(cwd); + if (hasPackageJson) { + stderr( + yellow().inverse('WARN'), + yellow(` missing package.json "name" field. Assuming "${pkgName}".`), + ); + } + } + + const finalName = name || amdName || safeVariableName(pkgName); + + return { finalName, pkgName }; +} diff --git a/src/lib/terser.js b/src/lib/terser.js new file mode 100644 index 00000000..d1d2e28a --- /dev/null +++ b/src/lib/terser.js @@ -0,0 +1,24 @@ +// Normalize Terser options from microbundle's relaxed JSON format (mutates argument in-place) +export function normalizeMinifyOptions(minifyOptions) { + const mangle = minifyOptions.mangle || (minifyOptions.mangle = {}); + let properties = mangle.properties; + + // allow top-level "properties" key to override mangle.properties (including {properties:false}): + if (minifyOptions.properties != null) { + properties = mangle.properties = + minifyOptions.properties && + Object.assign(properties, minifyOptions.properties); + } + + // allow previous format ({ mangle:{regex:'^_',reserved:[]} }): + if (minifyOptions.regex || minifyOptions.reserved) { + if (!properties) properties = mangle.properties = {}; + properties.regex = properties.regex || minifyOptions.regex; + properties.reserved = properties.reserved || minifyOptions.reserved; + } + + if (properties) { + if (properties.regex) properties.regex = new RegExp(properties.regex); + properties.reserved = [].concat(properties.reserved || []); + } +} diff --git a/src/log-error.js b/src/log-error.js index ba90c2e1..4352d452 100644 --- a/src/log-error.js +++ b/src/log-error.js @@ -1,7 +1,7 @@ import { red, dim } from 'kleur'; import { stderr } from './utils'; -export default function(err) { +export default function logError(err) { const error = err.error || err; const description = `${error.name ? error.name + ': ' : ''}${error.message || error}`; diff --git a/src/prog.js b/src/prog.js index 8386f9c5..2211d217 100644 --- a/src/prog.js +++ b/src/prog.js @@ -1,5 +1,5 @@ import sade from 'sade'; -let { version } = require('../package'); +let { version } = require('../package.json'); const toArray = val => (Array.isArray(val) ? val : val == null ? [] : [val]); @@ -10,9 +10,19 @@ export default handler => { const cmd = type => (str, opts) => { opts.watch = opts.watch || type === 'watch'; - opts.compress = - opts.compress != null ? opts.compress : opts.target !== 'node'; + opts.entries = toArray(str || 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'; + } + handler(opts); }; diff --git a/src/utils.js b/src/utils.js index d36140c4..dbb5e82f 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,21 +1,24 @@ import { promises as fs } from 'fs'; +import camelCase from 'camelcase'; export const readFile = fs.readFile; export const stat = fs.stat; -export const isDir = name => - stat(name) +export function isDir(name) { + return stat(name) .then(stats => stats.isDirectory()) .catch(() => false); +} -export const isFile = name => - stat(name) +export function isFile(name) { + return stat(name) .then(stats => stats.isFile()) .catch(() => false); +} -export const stdout = console.log.bind(console); // eslint-disable-line no-console - +// eslint-disable-next-line no-console +export const stdout = console.log.bind(console); export const stderr = console.error.bind(console); export const isTruthy = obj => { @@ -25,3 +28,18 @@ export const isTruthy = obj => { return obj.constructor !== Object || Object.keys(obj).length > 0; }; + +/** Remove a @scope/ prefix from a package name string */ +export const removeScope = name => name.replace(/^@.*\//, ''); + +const INVALID_ES3_IDENT = /((^[^a-zA-Z]+)|[^\w.-])|([^a-zA-Z0-9]+$)/g; + +/** + * Turn a package name into a valid reasonably-unique variable name + * @param {string} name + */ +export function safeVariableName(name) { + const normalized = removeScope(name).toLowerCase(); + const identifier = normalized.replace(INVALID_ES3_IDENT, ''); + return camelCase(identifier); +}