Home Reference Source

src/index.js

import arrify from 'arrify'
import common from 'common-dir'
import merge from 'webpack-merge'
import path from 'path'
import {sync as globby} from 'globby'
import webpack from 'webpack'

// http://stackoverflow.com/questions/3115150
function _reEscape(text) {
	return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&')
}

// remove the file extension from the file path:
function _noext(f) {
	const ext = path.extname(f)
	return f.slice(0, -ext.length)
}

/**
 * The configuration builder class
 * @public
 */
class ConfigurationBuilder {
	constructor() {
		this._cfg = {}
	}

	/**
	 * Define aliases
	 * @see https://webpack.js.org/configuration/resolve/#resolve-alias
	 * @param {object} aliases The alias specifications
	 * @return {ConfigurationBuilder}
	 * @example
	 * // webpack.config.js
	 * module.exports = builder()
	 *   .alias({
	 *     'react': 'inferno-compat',
	 *     'react-dom': 'inferno-compat',
	 *   })
	 *   .build()
	 */
	alias(aliases) {
		return this.merge({
			resolve: {
				alias: aliases,
			},
		})
	}

	/**
	 * Returns a plain JavaScript object representing the built Webpack configuration
	 * @return {object}
	 * @example
	 * // webpack.config.js
	 * module.exports = builder()
	 *   .build()
	 */
	build() {
		return this._cfg
	}

	/**
	 * Set the output directory
	 * @param {string} dir The output directory; this path may be relative or absolute
	 * @param {boolean} chunkhash=true Whether to include [chunkhash] in the filename
	 * @return {ConfigurationBuilder}
	 * @example
	 * // webpack.config.js
	 * module.exports = builder()
	 *   .dest('build/public/js', false)
	 *   .build()
	 */
	dest(dir, chunkhash = true) {
		let ManifestPlugin = false
		try {
			ManifestPlugin = require('webpack-manifest-plugin')
		} catch (e) {} // eslint-disable-line no-empty
		chunkhash = chunkhash && Boolean(ManifestPlugin)
		if (chunkhash && ManifestPlugin) this.plugins(new ManifestPlugin())
		return this.merge({
			output: {
				path: path.resolve(dir),
				filename: `${chunkhash ? '[chunkhash].' : ''}[name].js`,
			},
		})
	}

	/**
	 * Specify whether or not to generate development configuration
	 * @param {boolean} enable Whether or not to enable development mode
	 * @return {ConfigurationBuilder}
	 * @example
	 * // webpack.config.js
	 * module.exports = builder()
	 *   .development((process.env.NODE_ENV || 'development') == 'development')
	 *   .build()
	 */
	development(enable) {
		if (!enable) return this
		return this.merge({
			devtool: 'source-map',
			output: {
				pathinfo: true,
			},
		})
	}

	/**
	 * Turns on overlay and optional proxy
	 * @param {Object} opts (plus optional 'proxy' options to be passed to 'http-proxy')
	 * @return {ConfigurationBuilder}
	 * @example
	 * // webpack.config.js
	 * module.exports = builder()
	 *   .devServer({
	 *     publicPath: '/js/',
	 *     proxy: {
	 *       target: 'https://localhost:8443/',
	 *       secure: false,
	 *     },
	 *   })
	 *   .build()
	 */
	devServer(opts) {
		opts = opts || {}
		const {proxy: proxyOptions} = opts
		delete opts.proxy
		return this.merge({
			devServer: Object.assign(
				{},
				{
					overlay: true,
					after(app) {
						if (typeof proxyOptions == 'undefined') return
						const proxy = require('http-proxy').createProxyServer(proxyOptions)
						app.use((req, res, next) => {
							console.error(
								'webpack-dev-middleware: proxy:',
								req.method,
								req.url
							)
							proxy.web(req, res, {}, err => {
								if (err) return next(err)
							})
						})
					},
				},
				opts
			),
		})
	}

	/**
	 * Add resolvable extensions (ex: '.jsx', '.css')
	 * @see https://webpack.js.org/configuration/resolve/#resolve-extensions
	 * @param {...string} ext The extensions to add
	 * @return {ConfigurationBuilder}
	 * @example
	 * // webpack.config.js
	 * module.exports = builder()
	 *   .extensions('.js', '.jsx', '.ts', '.css', '.less', '.scss')
	 *   .build()
	 */
	extensions(...ext) {
		return this.merge({
			resolve: {extensions: ext},
		})
	}

	/**
	 * Exclude certain modules/files from the bundle
	 * @param {string|string[]} depsOrFiles The array of files/module to exclude
	 * @param {string} moduleType see: https://webpack.js.org/configuration/externals/#externals
	 * @example
	 * module.exports = builder()
	 *   .externals(Object.keys(require('./package').dependencies))
	 *   .build()
	 *
	 * // Exclude specific file:
	 *
	 * module.exports = builder()
	 *   // Note how we are using 'config/index' and not 'config/index.js'
	 *   // Be sure to match exactly how you are requiring/importing the file
	 *   // For example, if you require the file with `require('./config/index')`
	 *   // then exclude 'config/index' (without extension).  However if your code
	 *   // looks like `require('./config/index.js')`, then exclude 'config/index.js'
	 *   .externals(path.resolve('config/index'))
	 *   .build()
	 */
	externals(depsOrFiles, moduleType = 'commonjs') {
		depsOrFiles = arrify(depsOrFiles)
		return this.merge({
			externals(ctx, req, done) {
				let mod = req
				if (mod.startsWith('@')) mod = mod.split('/')[1]
				if (/^(?:\.\.?|\/)/.test(mod)) {
					// file
					mod = path.resolve(ctx, mod)
				} else mod = /^(?:.*!)?((?:@[^\/]+\/)?[^\/]+)/.exec(req)[1]

				if (depsOrFiles.includes(mod)) return done(null, `${moduleType} ${req}`)
				else return done()
			},
		})
	}

	/**
	 * Add a loader
	 * @see https://webpack.js.org/configuration/module/#module-rules
	 * @param {string|Array<string>} ext The file extensions to match
	 * @param {string|Array<string>} loader The loader spec
	 * @param {?object} query The loader parameters
	 * @return {ConfigurationBuilder}
	 * @example
	 * // webpack.config.js
	 * module.exports = builder()
	 *   .loader(['.js', '.jsx'], 'babel-loader', {
	 *     presets: ['latest', 'react'],
	 *   })
	 *   .build()
	 */
	loader(ext, loader, query) {
		return this.merge({
			module: {
				rules: [
					Object.assign(
						{
							test: new RegExp(
								`(${arrify(ext)
									.map(type => _reEscape(type))
									.join('|')})$`
							),
							loader,
							exclude: /\/node_modules/,
						},
						query ? {query} : null
					),
				],
			},
		})
	}

	/**
	 * Merge configuration using `webpack-merge`
	 * @see https://github.com/survivejs/webpack-merge
	 * @param {...object} cfgs The configurations to merge
	 * @return {ConfigurationBuilder}
	 * @example
	 * // webpack.config.js
	 * module.exports = builder()
	 *   .merge({
	 *     resolve: {
	 *       modules: ['node_modules'],
	 *     },
	 *   })
	 *   .build()
	 */
	merge(...cfgs) {
		Object.assign(this._cfg, merge(this._cfg, ...cfgs))
		return this
	}

	/**
	 * Add plugins
	 * @param {...object} plugins The plugins to add
	 * @return {ConfigurationBuilder}
	 * @example
	 * // webpack.config.js
	 * module.exports = builder()
	 *   .plugins(new Plugin1(), new Plugin2())
	 *   .build()
	 */
	plugins(...plugins) {
		return this.merge({plugins})
	}

	/**
	 * Specify whether or not to generate production configuration
	 * @param {boolean} enable Whether or not to enable production mode
	 * @return {ConfigurationBuilder}
	 * @example
	 * // webpack.config.js
	 * module.exports = builder()
	 *   .production(process.env.NODE_ENV == 'production')
	 *   .build()
	 */
	production(enable) {
		if (!enable) return this
		return this.plugins(
			new webpack.optimize.UglifyJsPlugin({
				comments: false,
				compress: {warnings: false},
				minimize: true,
			}),
			new webpack.DefinePlugin({
				'process.env.NODE_ENV': JSON.stringify('production'),
			})
		)
	}

	/**
	 * Set entry/entries based on glob(s)
	 * @param {...string} globs The file globs
	 * @return {ConfigurationBuilder}
	 * @example
	 * // webpack.config.js
	 * module.exports = builder()
	 *   .src('src/*.js')
	 *   .build()
	 */
	src(...globs) {
		const cwd = process.cwd()
		const files = globby(globs, {cwd}).map(f => path.resolve(cwd, f))
		const base = files.length == 1 ? path.dirname(files[0]) : common(files)
		return this.merge({
			entry: Object.assign(
				{},
				...files.map(file => ({[_noext(path.relative(base, file))]: file}))
			),
		})
	}

	/**
	 * Specify vendor modules
	 * @param {...string} modules The vendor modules
	 * @return {ConfigurationBuilder}
	 * @example
	 * // webpack.config.js
	 * module.exports = builder()
	 *   .vendor('react', 'react-dom', ...)
	 *   .build()
	 */
	vendor(...modules) {
		return this.plugins(
			new webpack.optimize.CommonsChunkPlugin({names: ['vendor', 'manifest']})
		).merge({
			entry: {
				vendor: modules,
			},
		})
	}
}

/**
 * Creates a configuration builder and returns it
 * @return {ConfigurationBuilder}
 */
export default function builder() {
	return new ConfigurationBuilder()
}