diff --git a/CHANGES.md b/CHANGES.md index b986fe81..59b6ce7f 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,8 +1,24 @@ # Unreleased (in `master`) +## Breaking Changes + +- Validation of the configuration object provided by `nwb.config.js` files has been expanded, so previously valid config files may now be invalid: + - Unexpected properties in top-level configuration or in `babel`, `karma`, `npm` and `webpack` configuration (i.e. anything that's not documented in the [Configuration docs](https://github.com/insin/nwb/blob/master/docs/Configuration.md)) are now treated as errors. + - Basic type checking is now performed for all documented configuration properties. + - After upgrading, run `nwb check-config` to check your configuration file. + +## `nwb.config.js` Config Changes + +- Deprecated the `webpack.compat.sinon` flag for Sinon 1.x compatibility settings, as subsequent major versions since July 2017 support Webpack out of the box. + ## Added -- You can now provide a [`karma.config()` function](https://github.com/insin/nwb/blob/master/docs/Configuration.md#config-function-1) which will be given the generated Karma config to do whatever it wants with [[#408](https://github.com/insin/nwb/issues/408)] +- You can now provide a [`babel.config()` function](https://github.com/insin/nwb/blob/master/docs/Configuration.md#config-function) which will be given the generated Babel config to do whatever it wants with. +- You can now provide a [`karma.config()` function](https://github.com/insin/nwb/blob/master/docs/Configuration.md#config-function-2) which will be given the generated Karma config to do whatever it wants with [[#408](https://github.com/insin/nwb/issues/408)] + +## Changed + +- Simplified configuration of locales in [`webpack.compat` config](https://github.com/insin/nwb/blob/master/docs/Configuration.md#compat-object) ## Dependencies @@ -17,7 +33,8 @@ ## Docs -- Added more headings to the [Commands docs]() to make them easier to browse, and to make feature flags such as `--copy-files` for component builds more visible [[#407](https://github.com/insin/nwb/issues/407)] +- Added missing docs for [`webpack.copy` config](https://github.com/insin/nwb/blob/master/docs/Configuration.md#copy-array--object) +- Added more headings to the [Commands docs](https://github.com/insin/nwb/blob/master/docs/Commands.md#commands) to make them easier to browse, and to make feature flags such as `--copy-files` for component builds more visible [[#407](https://github.com/insin/nwb/issues/407)] # 0.20.0 / 2017-11-18 diff --git a/docs/Configuration.md b/docs/Configuration.md index d9621648..3deb3842 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -38,11 +38,11 @@ If a function is exported, it will be passed an object with the following proper - `args`: a parsed version of arguments passed to the `nwb` command - `command`: the name of the command currently being executed, e.g. `'build'` or `'test'` -- `webpack`: nwb's version of the `webpack` module, giving you access to the other plugins webpack provides. +- `webpack`: nwb's version of the `webpack` module, giving you access to plugins bundled with Webpack. #### Example Configuration Files -- [react-hn's `nwb.config.js`](https://github.com/insin/react-hn/blob/concat/nwb.config.js) is a simple configuration file with minor tweaks to Babel and Webpack config. +- [bootstrap-4-nwb's `nwb.config.js`](https://github.com/insin/bootstrap-4-nwb/blob/master/nwb.config.js) is a simple configuration file with minor tweaks to Babel and Webpack config. - [React Yelp Clone's `nwb.config.js`](https://github.com/insin/react-yelp-clone/blob/nwb/nwb.config.js) configures Babel, Karma and Webpack to allow nwb to be dropped into an existing app to handle its development tooling, [reducing the amount of `devDependencies` and configuration](https://github.com/insin/react-yelp-clone/compare/master...nwb) which need to be managed. ### Configuration Via Arguments @@ -80,26 +80,28 @@ The configuration object can include the following properties: - [`babel.reactConstantElements`](#reactconstantelements-false) - disable use of React constant element hoisting in production builds - [`babel.runtime`](#runtime-string--boolean) - enable the `transform-runtime` plugin with different configurations - [`babel.stage`](#stage-number--false) - control which experimental and upcoming JavaScript features can be used + - [`babel.config`](#config-function) - an escape hatch for manually editing the generated Babel config - [Webpack Configuration](#webpack-configuration) - [`webpack`](#webpack-object) - [`webpack.aliases`](#aliases-object) - rewrite certain import paths - [`webpack.autoprefixer`](#autoprefixer-string--object) - options for Autoprefixer - [`webpack.compat`](#compat-object) - enable Webpack compatibility tweaks for commonly-used modules + - [`webpack.copy`](#copy-array--object) - patterns and options for `CopyWebpackPlugin` - [`webpack.debug`](#debug-boolean) - create a more debuggable production build - [`webpack.define`](#define-object) - options for `DefinePlugin`, for replacing certain expressions with values - [`webpack.extractText`](#extracttext-object--boolean) - options for `ExtractTextPlugin` - [`webpack.hoisting`](#hoisting-boolean) - enable partial scope hoisting with Webpack 3's `ModuleConcatenationPlugin` - [`webpack.html`](#html-object) - options for `HtmlPlugin` - [`webpack.install`](#install-object) - options for `NpmInstallPlugin` + - [`webpack.publicPath`](#publicpath-string) - path to static resources - [`webpack.rules`](#rules-object) - tweak the configuration of generated Webpack rules and their loaders - [Default Rules](#default-rules) - [Customising loaders](#customising-loaders) - [Disabling default rules](#disabling-default-rules) - - [`webpack.publicPath`](#publicpath-string) - path to static resources - [`webpack.styles`](#styles-object--false--old) - customise creation of Webpack rules for stylesheets - [`webpack.uglify`](#uglify-object--false) - configure use of Webpack's `UglifyJsPlugin` - [`webpack.extra`](#extra-object) - an escape hatch for extra Webpack config, which will be merged into the generated config - - [`webpack.config`](#config-function) - an escape hatch for manually editing the generated Webpack config + - [`webpack.config`](#config-function-1) - an escape hatch for manually editing the generated Webpack config - [Dev Server Configuration](#dev-server-configuration) - [`devServer`](#devserver-object) - configure Webpack Dev Server - [Karma Configuration](#karma-configuration) @@ -112,13 +114,13 @@ The configuration object can include the following properties: - [`karma.testContext`](#testcontext-string) - point to a Webpack context module for your tests - [`karma.testFiles`](#testfiles-string--arraystring) - patterns for test files - [`karma.extra`](#extra-object-1) - an escape hatch for extra Karma config, which will be merged into the generated config - - [`karma.config`](#config-function-1) - an escape hatch for manually editing the generated Karma config + - [`karma.config`](#config-function-2) - an escape hatch for manually editing the generated Karma config - [npm Build Configuration](#npm-build-configuration) - [`npm`](#npm-object) - [`npm.cjs`](#esmodules-boolean) - toggle creation of a CommonJS build - [`npm.esModules`](#esmodules-boolean) - toggle creation of an ES modules build - UMD build - - [`npm.umd`](#umd-string--object) - enable a UMD build which exports a global variable + - [`npm.umd`](#umd-string--object--false) - configure a UMD build which exports a global variable - [`umd.global`](#global-string) - global variable name exported by UMD build - [`umd.externals`](#externals-object) - dependencies to use via global variables in UMD build - [`package.json` fields](#packagejson-umd-banner-configuration) @@ -328,6 +330,25 @@ module.exports = { } ``` +##### `config`: `Function` + +Finally, if you need *complete* control, provide a `babel.config()` function which will be given the generated config. + +> **Note:** you *must* return a config object from this function. + +```js +module.exports = { + webpack: { + config(config) { + // Change config as you wish + + // You MUST return the edited config object + return config + } + } +} +``` + ### Webpack Configuration #### `webpack`: `Object` @@ -395,28 +416,24 @@ The following libraries are supported: ###### `enzyme`: `Boolean` -Set to `true` for [Enzyme](http://airbnb.io/enzyme/) v2 compatibility - this also assumes you're using React v15.5 or v15.6. - -###### `intl`: `Object` - -If you use [intl](https://www.npmjs.com/package/intl) in a Webpack build, all the locales it supports will be imported by default and your build will be larger than you were expecting! +*deprecated in v0.19.1+* -Provide an object with a `locales` Array specifying language codes for the locales you want to load, or a String if you only need one locale. - -###### `moment`: `Object` +Set to `true` for [Enzyme](http://airbnb.io/enzyme/) v2 compatibility - this also assumes you're using React v15.5 or v15.6. -If you use [Moment.js](http://momentjs.com/) in a Webpack build, all the locales it supports will be imported by default and your build will be larger than you were expecting! +###### `intl` / `moment` / `react-intl`: `String | Array | Object` -Provide an object with a `locales` Array specifying language codes for the locales you want to load, or a String if you only need one locale. +*changed in v0.21.0* -###### `react-intl`: `Object` +If you use [intl](https://www.npmjs.com/package/intl), [Moment.js](http://momentjs.com/) or [react-intl](https://github.com/yahoo/react-intl) in a Webpack build, all the locales they support will be imported by default and your build will be larger than you were expecting! -If you use [react-intl](https://github.com/yahoo/react-intl) in a Webpack build, all the locales it supports will be imported by default and your build will be larger than you were expecting! +Provide an Array specifying language codes for the locales you want to load, or a String if you only need one locale. -Provide an object with a `locales` Array specifying language codes for the locales you want to load, or a String if you only need one locale. +> For backwards-compatibility with older versions of nwb, locales can also be provided as an Object with a `locales` property. ###### `sinon`: `Boolean` +*deprecated in v0.21.0+* + Set to `true` for [Sinon.js](http://sinonjs.org/) 1.x compatibility. --- @@ -427,16 +444,49 @@ Example config showing the use of multiple `compat` settings: module.exports = { webpack: { compat: { - enzyme: true, - moment: { - locales: ['de', 'en-gb', 'es', 'fr', 'it'] - }, + moment: ['de', 'en-gb', 'es', 'fr', 'it'], sinon: true } } } ``` +##### `copy`: `Array | Object` + +Configures [`CopyWebpackPlugin` patterns and options](https://github.com/webpack-contrib/extract-text-webpack-plugin#options). + +By default, nwb uses [`CopyWebpackPlugin`](https://github.com/webpack-contrib/extract-text-webpack-plugin#options) to copy any contents in an app's `public/` directory to the output directory. + +To add extra [copy patterns](https://github.com/webpack-contrib/copy-webpack-plugin#pattern-properties), you can provide an Array of them: + +```js +module.exports = { + webpack: { + copy: [ + // Copy directory contents to output + {from: 'path/to/mystuff'} + ] + } +} +``` + +To configure [plugin options](https://github.com/webpack-contrib/copy-webpack-plugin#available-options), provide an object with `options` or `patterns` properties: + +```js +module.exports = { + webpack: { + copy: { + options: { + debug: true + }, + patterns: [ + {from: 'path/to/mystuff'} + ] + } + } +} +``` + ##### `debug`: `boolean` Enables a more debuggable production build: @@ -563,6 +613,42 @@ module.exports = { Configures [options for `NpmInstallPlugin`](https://github.com/webpack-contrib/npm-install-webpack-plugin#usage), which will be used if you pass an `--install` flag to nwb commands which run a development server. +##### `publicPath`: `String` + +> This is just Webpack's [`output.publicPath` config](https://webpack.js.org/configuration/output/#output-publicpath) pulled up a level to make it more convenient to configure. + +`publicPath` defines the URL static resources will be referenced by in build output, such as `` and `` tags in generated HTML, `url()` in stylesheets and paths to any static resources you `require()` into your modules. + +The default `publicPath` configured for most app builds is `/`, which assumes you will be serving your app's static resources from the root of whatever URL it's hosted at: + +```html + +``` + +If you're serving static resources from a different path, or from an external URL such as a CDN, set it as the `publicPath`: + +```js +module.exports = { + webpack: { + publicPath: 'https://cdn.example.com/myapp/' + } +} +``` + +The exception is the React component demo app, which doesn't set a `publicPath`, generating a build without any root URL paths to static resources. This allows you to serve it at any path without configuration (e.g. on GitHub Project Pages), or open the generated `index.html` file directly in a browser, which is ideal for distributing app builds which don't require a server to run. + +If you want to create a path-independent build, set `publicPath` to blank or `null`: + +```js +module.exports = { + webpack: { + publicPath: '' + } +} +``` + +The trade-off for path-independence is HTML5 History routing won't work, as serving up `index.html` at anything but its real path will mean its static resource URLs won't resolve. You will have to fall back on hash-based routing if you need it. + ##### `rules`: `Object` Each [Webpack rule](https://webpack.js.org/configuration/module/#module-rules) generated by nwb has a unique id you can use to customise it: @@ -663,42 +749,6 @@ module.exports = { } ``` -##### `publicPath`: `String` - -> This is just Webpack's [`output.publicPath` config](https://webpack.js.org/configuration/output/#output-publicpath) pulled up a level to make it more convenient to configure. - -`publicPath` defines the URL static resources will be referenced by in build output, such as `` and `` tags in generated HTML, `url()` in stylesheets and paths to any static resources you `require()` into your modules. - -The default `publicPath` configured for most app builds is `/`, which assumes you will be serving your app's static resources from the root of whatever URL it's hosted at: - -```html - -``` - -If you're serving static resources from a different path, or from an external URL such as a CDN, set it as the `publicPath`: - -```js -module.exports = { - webpack: { - publicPath: 'https://cdn.example.com/myapp/' - } -} -``` - -The exception is the React component demo app, which doesn't set a `publicPath`, generating a build without any root URL paths to static resources. This allows you to serve it at any path without configuration (e.g. on GitHub Project Pages), or open the generated `index.html` file directly in a browser, which is ideal for distributing app builds which don't require a server to run. - -If you want to create a path-independent build, set `publicPath` to blank or `null`: - -```js -module.exports = { - webpack: { - publicPath: '' - } -} -``` - -The trade-off for path-independence is HTML5 History routing won't work, as serving up `index.html` at anything but its real path will mean its static resource URLs won't resolve. You will have to fall back on hash-based routing if you need it. - ##### `styles`: `Object | false | 'old'` Configures how nwb creates Webpack config for importing stylesheets. @@ -957,24 +1007,6 @@ module.exports = { > **Note:** If you're configuring frameworks and you want to use the Mocha framework plugin managed by nwb, just pass its name as in the above example. -##### `testContext`: `String` - -Use this configuration to point to a [Webpack context module](/docs/Testing.md#using-a-test-context-module) for your tests if you need to run code prior to any tests being run, such as customising the assertion library you're using, or global before and after hooks. - -If you provide a context module, it is responsible for including tests via Webpack's `require.context()` - see the [example in the Testing docs](/docs/Testing.md#using-a-test-context-module). - -If the default [`testFiles`](#testfiles-string--arraystring) config wouldn't have picked up your tests, you must also configure it so they can be excluded from code coverage. - -##### `testFiles`: `String | Array` - -> Default: `.spec.js`, `.test.js` or `-test.js` files anywhere under `src/`, `test/` or `tests/` - -[Minimatch glob patterns](https://github.com/isaacs/minimatch) for test files. - -If [`karma.testContext`](#testcontext-string) is not being used, this controls which files Karma will run tests from. - -This can also be used to exclude tests from code coverage if you're using [`karma.testContext`](#testcontext-string) - if the default `testFiles` patterns wouldn't have picked up your tests, configure this as well to exclude then from code coverage. - ##### `plugins`: `Array` A list of plugins to be loaded by Karma - this should be used in combination with [`browsers`](#browsers-arraystring--plugin), [`frameworks`](#frameworks-arraystring--plugin) and [`reporters`](#reporters-arraystring--plugin) config as necessary. @@ -1010,6 +1042,24 @@ module.exports = { } ``` +##### `testContext`: `String` + +Use this configuration to point to a [Webpack context module](/docs/Testing.md#using-a-test-context-module) for your tests if you need to run code prior to any tests being run, such as customising the assertion library you're using, or global before and after hooks. + +If you provide a context module, it is responsible for including tests via Webpack's `require.context()` - see the [example in the Testing docs](/docs/Testing.md#using-a-test-context-module). + +If the default [`testFiles`](#testfiles-string--arraystring) config wouldn't have picked up your tests, you must also configure it so they can be excluded from code coverage. + +##### `testFiles`: `String | Array` + +> Default: `.spec.js`, `.test.js` or `-test.js` files anywhere under `src/`, `test/` or `tests/` + +[Minimatch glob patterns](https://github.com/isaacs/minimatch) for test files. + +If [`karma.testContext`](#testcontext-string) is not being used, this controls which files Karma will run tests from. + +This can also be used to exclude tests from code coverage if you're using [`karma.testContext`](#testcontext-string) - if the default `testFiles` patterns wouldn't have picked up your tests, configure this as well to exclude then from code coverage. + ##### `extra`: `Object` Extra configuration to be merged into the generated Karma configuration using [webpack-merge](https://github.com/survivejs/webpack-merge#webpack-merge---merge-designed-for-webpack). @@ -1088,9 +1138,9 @@ When providing an ES6 modules build, you should also provide the following in `p These are included automatically if you create a project with an ES6 modules build enabled. -##### `umd`: `String | Object` +##### `umd`: `String | Object | false` -Configures creation of a UMD build when you run `nwb build` for a React component/library or web module. +Configures a UMD build when you run `nwb build` for a React component/library or web module. If you just need to configure the global variable the UMD build will export, you can use a String: @@ -1128,6 +1178,8 @@ module.exports = { } ``` +The UMD build can also be explicitly disabled by configuring `umd = false`. + #### `package.json` UMD Banner Configuration A banner comment added to UMD builds will use as many of the following `package.json` fields as are present: diff --git a/docs/guides/QuickDevelopment.md b/docs/guides/QuickDevelopment.md index 85ade6e6..54cf6bfa 100644 --- a/docs/guides/QuickDevelopment.md +++ b/docs/guides/QuickDevelopment.md @@ -183,7 +183,7 @@ Create a build of a React project which uses Inferno or Preact as the runtime vi - [`do` expressions](http://babeljs.io/docs/plugins/transform-do-expressions/#detail) - [`::` function binding operator](http://babeljs.io/docs/plugins/transform-function-bind/#detail) -- Polyfills for `Promise`, `fetch()` and `Object.assign()`, which can be disabled with a `--no-polyfill` flag if you don’t need them or want to provide your own. +- Polyfills for `Promise`, `fetch()` and `Object.assign()`, which can be disabled with a `--no-polyfill` flag if you don’t need them or want to provide your own. - Import images and stylesheets into JavaScript like any other module, to be handled by Webpack as part of its build. ```js diff --git a/src/commands/build.js b/src/commands/build.js index f9bd3aca..018a2dd3 100644 --- a/src/commands/build.js +++ b/src/commands/build.js @@ -1,5 +1,5 @@ +import {getProjectType} from '../config' import {INFERNO_APP, PREACT_APP, REACT_APP, REACT_COMPONENT, WEB_APP, WEB_MODULE} from '../constants' -import {getProjectType} from '../getUserConfig' import buildInfernoApp from './build-inferno-app' import buildPreactApp from './build-preact-app' import buildReactApp from './build-react-app' diff --git a/src/commands/check-config.js b/src/commands/check-config.js index b6976001..ed71f43c 100644 --- a/src/commands/check-config.js +++ b/src/commands/check-config.js @@ -1,5 +1,4 @@ -import getUserConfig, {UserConfigReport} from '../getUserConfig' -import getPluginConfig from '../getPluginConfig' +import {getPluginConfig, getUserConfig, UserConfigReport} from '../config' function getFullEnv(env) { if (env === 'dev') return 'development' diff --git a/src/commands/clean.js b/src/commands/clean.js index 70b09be9..33d5af9c 100644 --- a/src/commands/clean.js +++ b/src/commands/clean.js @@ -1,5 +1,5 @@ +import {getProjectType} from '../config' import {INFERNO_APP, PREACT_APP, REACT_APP, REACT_COMPONENT, WEB_APP, WEB_MODULE} from '../constants' -import {getProjectType} from '../getUserConfig' import cleanApp from './clean-app' import cleanModule from './clean-module' diff --git a/src/commands/serve.js b/src/commands/serve.js index eba42853..b35ca0f0 100644 --- a/src/commands/serve.js +++ b/src/commands/serve.js @@ -1,7 +1,7 @@ // @flow +import {getProjectType} from '../config' import {INFERNO_APP, PREACT_APP, REACT_APP, REACT_COMPONENT, WEB_APP} from '../constants' import {UserError} from '../errors' -import {getProjectType} from '../getUserConfig' import serveInfernoApp from './serve-inferno-app' import servePreactApp from './serve-preact-app' import serveReactApp from './serve-react-app' diff --git a/src/commands/test.js b/src/commands/test.js index 5007bfa5..37f16188 100644 --- a/src/commands/test.js +++ b/src/commands/test.js @@ -1,6 +1,6 @@ // @flow +import {getProjectType} from '../config' import {INFERNO_APP, PREACT_APP, REACT_APP, REACT_COMPONENT} from '../constants' -import {getProjectType} from '../getUserConfig' import karmaServer from '../karmaServer' import testInferno from './test-inferno' import testPreact from './test-preact' diff --git a/src/config/UserConfigReport.js b/src/config/UserConfigReport.js new file mode 100644 index 00000000..3cfd39b0 --- /dev/null +++ b/src/config/UserConfigReport.js @@ -0,0 +1,106 @@ +import util from 'util' + +import chalk from 'chalk' +import figures from 'figures' + +import {pluralise as s} from '../utils' + +export default class UserConfigReport { + constructor({configFileExists, configPath} = {}) { + this.configFileExists = configFileExists + this.configPath = configPath + this.deprecations = [] + this.errors = [] + this.hints = [] + this.hasArgumentOverrides = false + } + + deprecated(path, ...messages) { + this.deprecations.push({path, messages}) + } + + error(path, value, message) { + this.errors.push({path, value, message}) + } + + hasErrors() { + return this.errors.length > 0 + } + + hasSomethingToReport() { + return this.errors.length + this.deprecations.length + this.hints.length > 0 + } + + hint(path, ...messages) { + this.hints.push({path, messages}) + } + + getConfigSource() { + if (this.configFileExists) { + let description = this.configPath + if (this.hasArgumentOverrides) { + description += ' (with CLI argument overrides)' + } + return description + } + else if (this.hasArgumentOverrides) { + return 'config via CLI arguments' + } + return 'funsies' + } + + getReport() { + let report = [] + + report.push(chalk.underline(`nwb config report for ${this.getConfigSource()}`)) + report.push('') + + if (!this.hasSomethingToReport()) { + report.push(chalk.green(`${figures.tick} Nothing to report!`)) + return report.join('\n') + } + + if (this.errors.length) { + let count = this.errors.length > 1 ? `${this.errors.length} ` : '' + report.push(chalk.red.underline(`${count}Error${s(this.errors.length)}`)) + report.push('') + } + this.errors.forEach(({path, value, message}) => { + report.push(`${chalk.red(`${figures.cross} ${path}`)} ${chalk.cyan('=')} ${util.inspect(value)}`) + report.push(` ${message}`) + report.push('') + }) + + if (this.deprecations.length) { + let count = this.deprecations.length > 1 ? `${this.deprecations.length} ` : '' + report.push(chalk.yellow.underline(`${count}Deprecation Warning${s(this.deprecations.length)}`)) + report.push('') + } + this.deprecations.forEach(({path, messages}) => { + report.push(chalk.yellow(`${figures.warning} ${path}`)) + messages.forEach(message => { + report.push(` ${message}`) + }) + report.push('') + }) + + if (this.hints.length) { + let count = this.hints.length > 1 ? `${this.hints.length} ` : '' + report.push(chalk.cyan.underline(`${count}Hint${s(this.hints.length)}`)) + report.push('') + } + this.hints.forEach(({path, messages}) => { + report.push(chalk.cyan(`${figures.info} ${path}`)) + messages.forEach(message => { + report.push(` ${message}`) + }) + report.push('') + }) + + return report.join('\n') + } + + log() { + console.log(this.getReport()) + } +} diff --git a/src/config/babel.js b/src/config/babel.js new file mode 100644 index 00000000..8ce99c47 --- /dev/null +++ b/src/config/babel.js @@ -0,0 +1,156 @@ +import chalk from 'chalk' + +import {pluralise as s, typeOf} from '../utils' + +const BABEL_RUNTIME_OPTIONS = new Set(['helpers', 'polyfill']) + +export function processBabelConfig({report, userConfig}) { + let { + cherryPick, + env, + loose, + plugins, + presets, + removePropTypes, + reactConstantElements, + runtime, + stage, + config, + ...unexpectedConfig + } = userConfig.babel + + let unexpectedProps = Object.keys(unexpectedConfig) + if (unexpectedProps.length > 0) { + report.error( + 'babel', + unexpectedProps.join(', '), + `Unexpected prop${s(unexpectedProps.length)} in ${chalk.cyan('babel')} config - ` + + 'see https://github.com/insin/nwb/blob/master/docs/Configuration.md#babel-configuration for supported config' + ) + } + + // cherryPick + if ('cherryPick' in userConfig.babel) { + if (typeOf(cherryPick) !== 'string' && typeOf(cherryPick) !== 'array') { + report.error( + 'babel.cherryPick', + cherryPick, + `Must be a ${chalk.cyan('String')} or an ${chalk.cyan('Array')}` + ) + } + } + + // env + if ('env' in userConfig.babel) { + if (typeOf(env) !== 'Object') { + report.error( + 'babel.env', + env, + `Must be an ${chalk.cyan('Object')}` + ) + } + } + + // loose + if ('loose' in userConfig.babel) { + if (typeOf(loose) !== 'boolean') { + report.error( + 'babel.loose', + loose, + `Must be ${chalk.cyan('Boolean')}` + ) + } + } + + // plugins + if ('plugins' in userConfig.babel) { + if (typeOf(plugins) === 'string') { + userConfig.babel.plugins = [plugins] + } + else if (typeOf(userConfig.babel.plugins) !== 'array') { + report.error( + 'babel.plugins', + plugins, + `Must be a ${chalk.cyan('String')} or an ${chalk.cyan('Array')}` + ) + } + } + + // presets + if ('presets' in userConfig.babel) { + if (typeOf(presets) === 'string') { + userConfig.babel.presets = [presets] + } + else if (typeOf(presets) !== 'array') { + report.error( + 'babel.presets', + presets, + `Must be a ${chalk.cyan('String')} or an ${chalk.cyan('Array')}` + ) + } + } + + // removePropTypes + if ('removePropTypes' in userConfig.babel) { + if (removePropTypes !== false && typeOf(removePropTypes) !== 'object') { + report.error( + `babel.removePropTypes`, + removePropTypes, + `Must be ${chalk.cyan('false')} (to disable removal of PropTypes) ` + + `or an ${chalk.cyan('Object')} (to configure react-remove-prop-types)` + ) + } + } + + // reactConstantElements + if ('reactConstantElements' in userConfig.babel) { + if (typeOf(reactConstantElements) !== 'boolean') { + report.error( + 'babel.reactConstantElements', + reactConstantElements, + `Must be ${chalk.cyan('Boolean')}` + ) + } + } + + // runtime + if ('runtime' in userConfig.babel && + typeOf(runtime) !== 'boolean' && + !BABEL_RUNTIME_OPTIONS.has(runtime)) { + report.error( + 'babel.runtime', + runtime, + `Must be ${chalk.cyan('Boolean')}, ${chalk.cyan("'helpers'")} or ${chalk.cyan("'polyfill'")}` + ) + } + + // stage + if ('stage' in userConfig.babel) { + if (typeOf(stage) === 'number') { + if (stage < 0 || stage > 3) { + report.error( + 'babel.stage', + stage, + `Must be between ${chalk.cyan(0)} and ${chalk.cyan(3)}` + ) + } + } + else if (stage !== false) { + report.error( + 'babel.stage', + stage, + `Must be a ${chalk.cyan('Number')} between ${chalk.cyan('0')} and ${chalk.cyan('3')} (to choose a stage preset), ` + + `or ${chalk.cyan('false')} (to disable use of a stage preset)` + ) + } + } + + // config + if ('config' in userConfig.babel && typeOf(config) !== 'function') { + report.error( + `babel.config`, + `type: ${typeOf(config)}`, + `Must be a ${chalk.cyan('Function')}` + ) + } +} diff --git a/src/config/index.js b/src/config/index.js new file mode 100644 index 00000000..764cdaad --- /dev/null +++ b/src/config/index.js @@ -0,0 +1,3 @@ +export {getPluginConfig} from './plugin' +export {getProjectType, getUserConfig} from './user' +export {default as UserConfigReport} from './UserConfigReport' diff --git a/src/config/karma.js b/src/config/karma.js new file mode 100644 index 00000000..db7f1ab4 --- /dev/null +++ b/src/config/karma.js @@ -0,0 +1,140 @@ +import fs from 'fs' + +import chalk from 'chalk' + +import {pluralise as s, typeOf} from '../utils' + +export function processKarmaConfig({report, userConfig}) { + let { + browsers, + excludeFromCoverage, + frameworks, + plugins, + reporters, + testContext, + testFiles, + extra, + config, + ...unexpectedConfig + } = userConfig.karma + + let unexpectedProps = Object.keys(unexpectedConfig) + if (unexpectedProps.length > 0) { + report.error( + 'karma', + unexpectedProps.join(', '), + `Unexpected prop${s(unexpectedProps.length)} in ${chalk.cyan('karma')} config - ` + + 'see https://github.com/insin/nwb/blob/master/docs/Configuration.md#karma-configuration for supported config' + ) + } + + // browsers + if ('browsers' in userConfig.karma) { + if (typeOf(browsers) !== 'array') { + report.error( + 'karma.browsers', + browsers, + `Must be an ${chalk.cyan('Array')}` + ) + } + } + + // excludeFromCoverage + if ('excludeFromCoverage' in userConfig.karma) { + if (typeOf(excludeFromCoverage) === 'string') { + userConfig.karma.excludeFromCoverage = [excludeFromCoverage] + } + else if (typeOf(excludeFromCoverage) !== 'array') { + report.error( + 'karma.excludeFromCoverage', + excludeFromCoverage, + `Must be a ${chalk.cyan('String')} or an ${chalk.cyan('Array')}` + ) + } + } + + // frameworks + if ('frameworks' in userConfig.karma) { + if (typeOf(frameworks) !== 'array') { + report.error( + 'karma.frameworks', + frameworks, + `Must be an ${chalk.cyan('Array')}` + ) + } + } + + // plugins + if ('plugins' in userConfig.karma) { + if (typeOf(plugins) !== 'array') { + report.error( + 'karma.plugins', + plugins, + `Must be an ${chalk.cyan('Array')}` + ) + } + } + + // reporters + if ('reporters' in userConfig.karma) { + if (typeOf(reporters) !== 'array') { + report.error( + 'karma.reporters', + reporters, + `Must be an ${chalk.cyan('Array')}` + ) + } + } + + // testContext + if ('testContext' in userConfig.karma) { + if (typeOf(testContext) !== 'string') { + report.error( + 'karma.testContext', + testContext, + `Must be a ${chalk.cyan('String')}` + ) + } + else if (!fs.existsSync(testContext)) { + report.error( + 'karma.testContext', + testContext, + `The specified test context module does not exist` + ) + } + } + + // testFiles + if ('testFiles' in userConfig.karma) { + if (typeOf(testFiles) === 'string') { + userConfig.karma.testFiles = [testFiles] + } + else if (typeOf(testFiles) !== 'array') { + report.error( + 'karma.testFiles', + testFiles, + `Must be a ${chalk.cyan('String')} or an ${chalk.cyan('Array')}` + ) + } + } + + // extra + if ('extra' in userConfig.karma) { + if (typeOf(extra) !== 'object') { + report.error( + 'karma.extra', + `type: ${typeOf(extra)}`, + `Must be an ${chalk.cyan('Object')}` + ) + } + } + + // config + if ('config' in userConfig.karma && typeOf(config) !== 'function') { + report.error( + `karma.config`, + `type: ${typeOf(config)}`, + `Must be a ${chalk.cyan('Function')}` + ) + } +} diff --git a/src/config/npm.js b/src/config/npm.js new file mode 100644 index 00000000..5477beae --- /dev/null +++ b/src/config/npm.js @@ -0,0 +1,96 @@ +import chalk from 'chalk' + +import {pluralise as s, typeOf} from '../utils' + +export function processNpmBuildConfig({report, userConfig}) { + let { + cjs, + esModules, + umd, + ...unexpectedConfig + } = userConfig.npm + + let unexpectedProps = Object.keys(unexpectedConfig) + if (unexpectedProps.length > 0) { + report.error( + 'npm', + unexpectedProps.join(', '), + `Unexpected prop${s(unexpectedProps.length)} in ${chalk.cyan('babel')} config - ` + + 'see https://github.com/insin/nwb/blob/master/docs/Configuration.md#npm-build-configuration for supported config' + ) + } + + // cjs + if ('cjs' in userConfig.npm) { + if (typeOf(cjs) !== 'boolean') { + report.error( + 'npm.cjs', + cjs, + `Must be ${chalk.cyan('Boolean')}` + ) + } + } + + // esModules + if ('esModules' in userConfig.npm) { + if (typeOf(esModules) !== 'boolean') { + report.error( + 'npm.esModules', + esModules, + `Must be ${chalk.cyan('Boolean')}` + ) + } + } + + // umd + if ('umd' in userConfig.npm) { + if (umd === false) { + // ok + } + else if (typeOf(umd) === 'string') { + userConfig.npm.umd = {global: umd} + } + else if (typeOf(umd) !== 'object') { + report.error( + 'npm.umd', + umd, + `Must be a ${chalk.cyan('String')} (for ${chalk.cyan('global')} ` + + `config only)}, an ${chalk.cyan('Object')} (for any UMD build options) ` + + `or ${chalk.cyan('false')} (to explicitly disable the UMD build)` + ) + } + else { + let { + global: umdGlobal, + externals, + ...unexpectedConfig + } = umd + + let unexpectedProps = Object.keys(unexpectedConfig) + if (unexpectedProps.length > 0) { + report.error( + 'npm.umd', + unexpectedProps.join(', '), + `Unexpected prop${s(unexpectedProps.length)} in ${chalk.cyan('npm.umd')} config - ` + + 'see https://github.com/insin/nwb/blob/master/docs/Configuration.md#umd-string--object for supported config' + ) + } + + if ('global' in umd && typeOf(umdGlobal) !== 'string') { + report.error( + 'npm.umd.global', + umdGlobal, + `Must be a ${chalk.cyan('String')}` + ) + } + + if ('externals' in umd && typeOf(externals) !== 'object') { + report.error( + 'npm.umd.externals', + externals, + `Must be an ${chalk.cyan('Object')}` + ) + } + } + } +} diff --git a/src/getPluginConfig.js b/src/config/plugin.js similarity index 88% rename from src/getPluginConfig.js rename to src/config/plugin.js index f76a9fd8..a40f21c0 100644 --- a/src/getPluginConfig.js +++ b/src/config/plugin.js @@ -3,8 +3,8 @@ import path from 'path' import resolve from 'resolve' import merge from 'webpack-merge' -import debug from './debug' -import {deepToString, getArgsPlugins, unique} from './utils' +import debug from '../debug' +import {deepToString, getArgsPlugins, unique} from '../utils' function getPackagePlugins(cwd) { let pkg = require(path.join(cwd, 'package.json')) @@ -19,7 +19,7 @@ function getPackagePlugins(cwd) { * arguments when supported, import them and merge the plugin config objects * they export. */ -export default function getPluginConfig(args = {}, {cwd = process.cwd()} = {}) { +export function getPluginConfig(args = {}, {cwd = process.cwd()} = {}) { let plugins = [] try { diff --git a/src/config/user.js b/src/config/user.js new file mode 100644 index 00000000..5077dea7 --- /dev/null +++ b/src/config/user.js @@ -0,0 +1,217 @@ +import fs from 'fs' +import path from 'path' + +import chalk from 'chalk' +import webpack from 'webpack' + +import {CONFIG_FILE_NAME, PROJECT_TYPES} from '../constants' +import debug from '../debug' +import {ConfigValidationError} from '../errors' +import {deepToString, joinAnd, pluralise as s, replaceArrayMerge, typeOf} from '../utils' +import {processBabelConfig} from './babel' +import {processKarmaConfig} from './karma' +import {processNpmBuildConfig} from './npm' +import UserConfigReport from './UserConfigReport' +import {processWebpackConfig} from './webpack' + +const DEFAULT_REQUIRED = false + +/** + * Load a user config file and process it. + */ +export function getUserConfig(args = {}, options = {}) { + let { + check = false, + pluginConfig = {}, // eslint-disable-line no-unused-vars + required = DEFAULT_REQUIRED, + } = options + + // Try to load default user config, or use a config file path we were given + let userConfig = {} + let configPath = path.resolve(args.config || CONFIG_FILE_NAME) + + // Bail early if a config file is required by the current command, or if the + // user specified a custom location, and it doesn't exist. + let configFileExists = fs.existsSync(configPath) + if ((args.config || required) && !configFileExists) { + throw new Error(`Couldn't find a config file at ${configPath}`) + } + + // If a config file exists, it should be a valid module regardless of whether + // or not it's required. + if (configFileExists) { + try { + userConfig = require(configPath) + debug('imported config module from %s', configPath) + // Delete the config file from the require cache as some builds need to + // import it multiple times with a different NODE_ENV in place. + delete require.cache[configPath] + } + catch (e) { + throw new Error(`Couldn't import the config file at ${configPath}: ${e.message}\n${e.stack}`) + } + } + + userConfig = processUserConfig({ + args, + check, + configFileExists, + configPath, + pluginConfig, + required, + userConfig, + }) + + if (configFileExists) { + userConfig.path = configPath + } + + return userConfig +} + +/** + * Load a user config file to get its project type. If we need to check the + * project type, a config file must exist. + */ +export function getProjectType(args = {}) { + // Try to load default user config, or use a config file path we were given + let userConfig = {} + let configPath = path.resolve(args.config || CONFIG_FILE_NAME) + + // Bail early if a config file doesn't exist + let configFileExists = fs.existsSync(configPath) + if (!configFileExists) { + throw new Error(`Couldn't find a config file at ${configPath} to determine project type.`) + } + + try { + userConfig = require(configPath) + // Delete the file from the require cache as it may be imported multiple + // times with a different NODE_ENV in place depending on the command. + delete require.cache[configPath] + } + catch (e) { + throw new Error(`Couldn't import the config file at ${configPath}: ${e.message}\n${e.stack}`) + } + + // Config modules can export a function if they need to access the current + // command or the webpack dependency nwb manages for them. + if (typeOf(userConfig) === 'function') { + userConfig = userConfig({ + args, + command: args._[0], + webpack, + }) + } + + let report = new UserConfigReport({configFileExists, configPath}) + + if (!PROJECT_TYPES.has(userConfig.type)) { + report.error('type', userConfig.type, `Must be one of: ${joinAnd(Array.from(PROJECT_TYPES), 'or')}`) + } + if (report.hasErrors()) { + throw new ConfigValidationError(report) + } + + return userConfig.type +} + +/** + * Validate user config and perform any supported transformations to it. + */ +export function processUserConfig({ + args = {}, + check = false, + configFileExists, + configPath, + pluginConfig = {}, + required = DEFAULT_REQUIRED, + userConfig, + }) { + // Config modules can export a function if they need to access the current + // command or the webpack dependency nwb manages for them. + if (typeOf(userConfig) === 'function') { + userConfig = userConfig({ + args, + command: args._[0], + webpack, + }) + } + + let report = new UserConfigReport({configFileExists, configPath}) + + let { + type, polyfill, + babel, devServer, karma, npm, webpack: _webpack, // eslint-disable-line no-unused-vars + ...unexpectedConfig + } = userConfig + + let unexpectedProps = Object.keys(unexpectedConfig) + if (unexpectedProps.length > 0) { + report.error( + 'nwb config', + unexpectedProps.join(', '), + `Unexpected top-level prop${s(unexpectedProps.length)} in nwb config - ` + + 'see https://github.com/insin/nwb/blob/master/docs/Configuration.md for supported config' + ) + } + + if ((required || 'type' in userConfig) && !PROJECT_TYPES.has(type)) { + report.error('type', userConfig.type, `Must be one of: ${joinAnd(Array.from(PROJECT_TYPES), 'or')}`) + } + + if ('polyfill' in userConfig && typeOf(polyfill) !== 'boolean') { + report.error( + 'polyfill', + `type: ${typeOf(polyfill)}`, + `Must be ${chalk.cyan('Boolean')}` + ) + } + + let argumentOverrides = {} + void ['babel', 'devServer', 'karma', 'npm', 'webpack'].forEach(prop => { + // Set defaults for top-level config objects so we don't have to + // existence-check them everywhere. + if (!(prop in userConfig)) { + userConfig[prop] = {} + } + else if (typeOf(userConfig[prop]) !== 'object') { + report.error( + prop, + `type: ${typeOf(userConfig[prop])}`, + `${chalk.cyan(prop)} config must be an ${chalk.cyan('Object')} ` + ) + // Set a valid default so further checks can continue + userConfig[prop] = {} + } + // Allow config overrides via --path.to.config in args + if (typeOf(args[prop]) === 'object') { + argumentOverrides[prop] = args[prop] + } + }) + + if (Object.keys(argumentOverrides).length > 0) { + debug('user config arguments: %s', deepToString(argumentOverrides)) + userConfig = replaceArrayMerge(userConfig, argumentOverrides) + report.hasArgumentOverrides = true + } + + processBabelConfig({report, userConfig}) + processKarmaConfig({report, userConfig}) + processNpmBuildConfig({report, userConfig}) + processWebpackConfig({pluginConfig, report, userConfig}) + + if (report.hasErrors()) { + throw new ConfigValidationError(report) + } + if (check) { + throw report + } + if (report.hasSomethingToReport()) { + report.log() + } + + debug('user config: %s', deepToString(userConfig)) + + return userConfig +} diff --git a/src/config/webpack.js b/src/config/webpack.js new file mode 100644 index 00000000..81d01314 --- /dev/null +++ b/src/config/webpack.js @@ -0,0 +1,485 @@ +import chalk from 'chalk' + +import {INFERNO_APP, PREACT_APP} from '../constants' +import {COMPAT_CONFIGS} from '../createWebpackConfig' +import {joinAnd, pluralise as s, typeOf} from '../utils' + +const DEFAULT_STYLE_LOADERS = new Set(['css', 'postcss']) + +// TODO Remove in a future version +let warnedAboutEnzymeCompat = false +let warnedAboutOldStyleRules = false +let warnedAboutSinonCompat = false + +export function processWebpackConfig({pluginConfig, report, userConfig}) { + let { + aliases, + autoprefixer, + compat, + copy, + debug, + define, + extractText, + hoisting, + html, + install, + publicPath, + rules, + styles, + uglify, + extra, + config, + ...unexpectedConfig + } = userConfig.webpack + + let unexpectedProps = Object.keys(unexpectedConfig) + if (unexpectedProps.length > 0) { + report.error( + 'webpack', + unexpectedProps.join(', '), + `Unexpected prop${s(unexpectedProps.length)} in ${chalk.cyan('webpack')} config - ` + + 'see https://github.com/insin/nwb/blob/master/docs/Configuration.md#webpack-configuration for supported config' + ) + } + + // aliases + if ('aliases' in userConfig.webpack) { + if (typeOf(aliases) !== 'object') { + report.error( + 'webpack.aliases', + `type: ${typeOf(aliases)}`, + `Must be an ${chalk.cyan('Object')}` + ) + } + else { + checkForRedundantCompatAliases( + userConfig.type, + aliases, + 'webpack.aliases', + report + ) + } + } + + // autoprefixer + if ('autoprefixer' in userConfig.webpack) { + // Convenience: allow Autoprefixer browsers config to be configured as a String + if (typeOf(autoprefixer) === 'string') { + userConfig.webpack.autoprefixer = {browsers: autoprefixer} + } + else if (typeOf(autoprefixer) !== 'object') { + report.error( + 'webpack.autoprefixer', + `type: ${typeOf(autoprefixer)}`, + `Must be a ${chalk.cyan('String')} (for ${chalk.cyan('browsers')} config only) ` + + `or an ${chalk.cyan('Object')} (for any Autoprefixer options)` + ) + } + } + + // compat + if ('compat' in userConfig.webpack) { + if (typeOf(compat) !== 'object') { + report.error( + 'webpack.compat', + `type: ${typeOf(compat)}`, + `Must be an ${chalk.cyan('Object')}` + ) + } + else { + // Validate compat props + let compatProps = Object.keys(compat) + let unexpectedCompatProps = compatProps.filter(prop => !(prop in COMPAT_CONFIGS)) + if (unexpectedCompatProps.length > 0) { + report.error( + 'webpack.compat', + unexpectedCompatProps.join(', '), + `Unexpected prop${s(unexpectedCompatProps.length)} in ${chalk.cyan('webpack.compat')}. ` + + `Valid props are: ${joinAnd(Object.keys(COMPAT_CONFIGS).map(p => chalk.cyan(p)), 'or')}`) + } + + if (compat.enzyme) { + if (!warnedAboutEnzymeCompat) { + report.deprecated( + 'webpack.compat.enzyme', + `The ${chalk.cyan('enzyme')} compat flag applies to Enzyme 2.x, which is no longer the current major version`, + 'Consider upgrading Enzyme if you can, which now requires configuring Enzyme itself rather than Webpack config' + ) + warnedAboutEnzymeCompat = true + } + } + + if (compat.sinon) { + if (!warnedAboutSinonCompat) { + report.deprecated( + 'webpack.compat.sinon', + `The ${chalk.cyan('sinon')} compat flag applies to Sinon 1.x, which is no longer the current major version`, + 'Consider upgrading Sinon if you can, which now works with Webpack out of the box' + ) + warnedAboutSinonCompat = true + } + } + + void ['intl', 'moment', 'react-intl'].forEach(compatProp => { + let config = compat[compatProp] + let configType = typeOf(config) + if (configType === 'string') { + compat[compatProp] = {locales: [config]} + } + else if (configType === 'array') { + compat[compatProp] = {locales: config} + } + else if (configType === 'object') { + if (typeOf(config.locales) === 'string') { + config.locales = [config.locales] + } + else if (typeOf(config.locales) !== 'array') { + report.error( + `webpack.compat.${compatProp}.locales`, + config.locales, + `Must be a ${chalk.cyan('String')} (single locale name) or an ${chalk.cyan('Array')} of locales` + ) + } + } + else { + report.error( + `webpack.compat.${compatProp}`, + `type: ${configType}`, + `Must be a ${chalk.cyan('String')} (single locale name), an ${chalk.cyan('Array')} ` + + `of locales or an ${chalk.cyan('Object')} with a ${chalk.cyan('locales')} property - ` + + 'see https://github.com/insin/nwb/blob/master/docs/Configuration.md#compat-object ' + ) + } + }) + } + } + + // copy + if ('copy' in userConfig.webpack) { + if (typeOf(copy) === 'array') { + userConfig.webpack.copy = {patterns: copy} + } + else if (typeOf(copy) === 'object') { + if (!copy.patterns && !copy.options) { + report.error( + 'webpack.copy', + copy, + `Must include ${chalk.cyan('patterns')} or ${chalk.cyan('options')}` + ) + } + if (copy.patterns && typeOf(copy.patterns) !== 'array') { + report.error( + 'webpack.copy.patterns', + copy.patterns, + `Must be an ${chalk.cyan('Array')}` + ) + } + if (copy.options && typeOf(copy.options) !== 'object') { + report.error( + 'webpack.copy.options', + copy.options, + `Must be an ${chalk.cyan('Object')}` + ) + } + } + else { + report.error( + 'webpack.copy', + copy, + `Must be an ${chalk.cyan('Array')} or an ${chalk.cyan('Object')}` + ) + } + } + + // debug + if (debug) { + // Make it harder for the user to forget to disable the production debug build + // if they've enabled it in the config file. + report.hint('webpack.debug', + "Don't forget to disable the debug build before building for production" + ) + } + + // define + if ('define' in userConfig.webpack) { + if (typeOf(define) !== 'object') { + report.error( + 'webpack.define', + `type: ${typeOf(define)}`, + `Must be an ${chalk.cyan('Object')}` + ) + } + } + + // extractText + if ('extractText' in userConfig.webpack) { + if (typeOf(extractText) !== 'object') { + report.error( + 'webpack.extractText', + `type: ${typeOf(extractText)}`, + `Must be an ${chalk.cyan('Object')}` + ) + } + } + + // hoisting + if ('hoisting' in userConfig.webpack) { + if (typeOf(hoisting) !== 'boolean') { + report.error( + 'webpack.hoisting', + `type: ${typeOf(hoisting)}`, + `Must be a ${chalk.cyan('Boolean')}` + ) + } + } + + // html + if ('html' in userConfig.webpack) { + if (typeOf(html) !== 'object') { + report.error( + 'webpack.html', + `type: ${typeOf(html)}`, + `Must be an ${chalk.cyan('Object')}` + ) + } + } + + // install + if ('install' in userConfig.webpack) { + if (typeOf(install) !== 'object') { + report.error( + 'webpack.install', + `type: ${typeOf(install)}`, + `Must be an ${chalk.cyan('Object')}` + ) + } + } + + // publicPath + if ('publicPath' in userConfig.webpack) { + if (typeOf(publicPath) !== 'string') { + report.error( + 'webpack.publicPath', + `type: ${typeOf(publicPath)}`, + `Must be a ${chalk.cyan('String')}` + ) + } + } + + // rules + if ('rules' in userConfig.webpack) { + if (typeOf(rules) !== 'object') { + report.error( + 'webpack.rules', + `type: ${typeOf(rules)}`, + `Must be an ${chalk.cyan('Object')}` + ) + } + else { + let error = false + Object.keys(rules).forEach(ruleId => { + let rule = rules[ruleId] + if (rule.use && typeOf(rule.use) !== 'array') { + report.error( + `webpack.rules.${ruleId}.use`, + `type: ${typeOf(rule.use)}`, + `Must be an ${chalk.cyan('Array')}` + ) + error = true + } + }) + if (!error) { + prepareWebpackRuleConfig(rules) + } + } + } + + // styles + if ('styles' in userConfig.webpack) { + let configType = typeOf(styles) + let help = `Must be ${chalk.cyan("'old'")} (to use default style rules from nwb <= v0.15), ` + + `${chalk.cyan('false')} (to disable default style rules) or ` + + `an ${chalk.cyan('Object')} (to configure custom style rules)` + if (configType === 'string' && styles !== 'old') { + report.error('webpack.styles', styles, help) + if (!warnedAboutOldStyleRules) { + report.deprecated('webpack.styles', 'Support for default style rules from nwb <= v0.15 will be removed in a future release') + warnedAboutOldStyleRules = true + } + } + else if (configType === 'boolean' && styles !== false) { + report.error('webpack.styles', styles, help) + } + else if (configType !== 'object' && configType !== 'boolean') { + report.error('webpack.styles', `type: ${configType}`, help) + } + else { + let styleTypeIds = ['css'] + if (pluginConfig.cssPreprocessors) { + styleTypeIds = styleTypeIds.concat(Object.keys(pluginConfig.cssPreprocessors)) + } + let error = false + Object.keys(styles).forEach(styleType => { + if (styleTypeIds.indexOf(styleType) === -1) { + report.error( + 'webpack.styles', + `property: ${styleType}`, + `Unknown style type - must be ${joinAnd(styleTypeIds.map(s => chalk.cyan(s)), 'or')}` + ) + error = true + } + else if (typeOf(styles[styleType]) !== 'array') { + report.error( + `webpack.styles.${styleType}`, + `type: ${typeOf(styles[styleType])}`, + `Must be an ${chalk.cyan('Array')} - if you don't need multiple custom rules, ` + + `configure the defaults via ${chalk.cyan('webpack.rules')} instead` + ) + error = true + } + else { + styles[styleType].forEach((styleConfig, index) => { + let { + test, include, exclude, // eslint-disable-line no-unused-vars + ...loaderConfig + } = styleConfig + Object.keys(loaderConfig).forEach(loaderId => { + if (!DEFAULT_STYLE_LOADERS.has(loaderId) && loaderId !== styleType) { + // XXX Assumption: preprocessors provide a single loader which + // is configured with the same id as the style type id. + // XXX Using Array.from() manually as babel-preset-env with a + // Node 4 target is tranpiling Array spreads to concat() + // calls without ensuring Sets are converted to Arrays. + let loaderIds = Array.from(new Set([ + ...Array.from(DEFAULT_STYLE_LOADERS), + styleType + ])).map(id => chalk.cyan(id)) + report.error( + `webpack.styles.${styleType}[${index}]`, + `property: ${loaderId}`, + `Must be ${chalk.cyan('include')}, ${chalk.cyan('exclude')} or a loader id: ${joinAnd(loaderIds, 'or')}` + ) + error = true + } + }) + }) + } + }) + if (!error) { + prepareWebpackStyleConfig(styles) + } + } + } + + // uglify + if ('uglify' in userConfig.webpack) { + if (uglify !== false && typeOf(uglify) !== 'object') { + report.error( + `webpack.uglify`, + uglify, + `Must be ${chalk.cyan('false')} (to disable UglifyJsPlugin) or ` + + `an ${chalk.cyan('Object')} (to configure UglifyJsPlugin)` + ) + } + } + + // extra + if ('extra' in userConfig.webpack) { + if (typeOf(extra) !== 'object') { + report.error( + 'webpack.extra', + `type: ${typeOf(extra)}`, + `Must be an ${chalk.cyan('Object')}` + ) + } + else { + if (typeOf(extra.output) === 'object' && extra.output.publicPath) { + report.hint('webpack.extra.output.publicPath', + `You can use the more convenient ${chalk.cyan('webpack.publicPath')} config instead` + ) + } + if (typeOf(extra.resolve) === 'object' && extra.resolve.alias) { + report.hint('webpack.extra.resolve.alias', + `You can use the more convenient ${chalk.cyan('webpack.aliases')} config instead` + ) + checkForRedundantCompatAliases( + userConfig.type, + extra.resolve.alias, + 'webpack.extra.resolve.alias', + report + ) + } + } + } + + // config + if ('config' in userConfig.webpack && typeOf(config) !== 'function') { + report.error( + `webpack.config`, + `type: ${typeOf(config)}`, + `Must be a ${chalk.cyan('Function')}` + ) + } +} + +/** + * Tell the user if they've manually set up the same React compatibility aliases + * nwb configured by default. + */ +function checkForRedundantCompatAliases(projectType, aliases, configPath, report) { + if (!new Set([INFERNO_APP, PREACT_APP]).has(projectType)) return + + let compatModule = `${projectType.split('-')[0]}-compat` + if (aliases.react && aliases.react.includes(compatModule)) { + report.hint(`${configPath}.react`, + `nwb aliases ${chalk.yellow('react')} to ${chalk.cyan(compatModule)} by default, so you can remove this config` + ) + } + if (aliases['react-dom'] && aliases['react-dom'].includes(compatModule)) { + report.hint(`${configPath}.react-dom`, + `nwb aliases ${chalk.yellow('react-dom')} to ${chalk.cyan(compatModule)} by default, so you can remove this config` + ) + } +} + +/** + * Move loader options into an options object, allowing users to provide flatter + * config. + */ +export function prepareWebpackRuleConfig(rules) { + Object.keys(rules).forEach(ruleId => { + let rule = rules[ruleId] + // XXX Special case for stylus-loader, which uses a 'use' option for plugins + if ((rule.use && !/stylus$/.test(ruleId)) || rule.options) return + let { + exclude, include, test, loader, // eslint-disable-line no-unused-vars + ...options + } = rule + if (Object.keys(options).length > 0) { + rule.options = options + Object.keys(options).forEach(prop => delete rule[prop]) + } + }) +} + +/** + * Move loader options into a loaders object, allowing users to provide flatter + * config. + */ +export function prepareWebpackStyleConfig(styles) { + Object.keys(styles).forEach(type => { + styles[type].forEach(styleConfig => { + let { + exclude, include, // eslint-disable-line no-unused-vars + ...loaderConfig + } = styleConfig + if (Object.keys(loaderConfig).length > 0) { + styleConfig.loaders = {} + Object.keys(loaderConfig).forEach(loader => { + styleConfig.loaders[loader] = {options: styleConfig[loader]} + delete styleConfig[loader] + }) + } + }) + }) +} diff --git a/src/createBabelConfig.js b/src/createBabelConfig.js index 17d28279..47462a5b 100644 --- a/src/createBabelConfig.js +++ b/src/createBabelConfig.js @@ -1,6 +1,7 @@ // @flow import path from 'path' +import {UserError} from './errors' import {typeOf} from './utils' type BabelPluginConfig = string | [string, Object]; @@ -24,6 +25,7 @@ type BuildOptions = { type UserOptions = { cherryPick?: string | string[], + config?: (BabelConfig) => BabelConfig, env?: Object, loose?: boolean, plugins?: BabelPluginConfig[], @@ -39,7 +41,8 @@ const RUNTIME_PATH = path.dirname(require.resolve('babel-runtime/package')) export default function createBabelConfig( buildConfig: BuildOptions = {}, - userConfig: UserOptions = {} + userConfig: UserOptions = {}, + userConfigPath: string = '' ): BabelConfig { let { commonJSInterop, @@ -54,6 +57,7 @@ export default function createBabelConfig( let { cherryPick, + config: userConfigFunction, env = {}, loose, plugins: userPlugins = [], @@ -180,5 +184,14 @@ export default function createBabelConfig( config.plugins = plugins } + // Finally, give the user a chance to do whatever they want with the generated + // config. + if (typeof userConfigFunction === 'function') { + config = userConfigFunction(config) + if (!config) { + throw new UserError(`babel.config() in ${userConfigPath} didn't return anything - it must return the Babel config object.`) + } + } + return config } diff --git a/src/createKarmaConfig.js b/src/createKarmaConfig.js index 3773f842..e3b88921 100644 --- a/src/createKarmaConfig.js +++ b/src/createKarmaConfig.js @@ -132,9 +132,7 @@ export default function createKarmaConfig(args, buildConfig, pluginConfig, userC }) let {excludeFromCoverage = DEFAULT_EXCLUDE_FROM_COVERAGE} = userKarma - if (typeOf(excludeFromCoverage) === 'string') excludeFromCoverage = [excludeFromCoverage] let testFiles = userKarma.testFiles || DEFAULT_TEST_FILES - if (typeOf(testFiles) === 'string') testFiles = [testFiles] // Polyfill by default for browsers which lack features (hello PhantomJS) let files = [require.resolve('babel-polyfill/dist/polyfill.js')] @@ -217,7 +215,7 @@ export default function createKarmaConfig(args, buildConfig, pluginConfig, userC karmaConfig = merge(karmaConfig, userKarma.extra) } - // Finally, give them a chance to do whatever they want with the generated + // Finally, give the user a chance to do whatever they want with the generated // config. if (typeOf(userKarma.config) === 'function') { karmaConfig = userKarma.config(karmaConfig) diff --git a/src/createServerWebpackConfig.js b/src/createServerWebpackConfig.js index 893b2cb8..0b3f3983 100644 --- a/src/createServerWebpackConfig.js +++ b/src/createServerWebpackConfig.js @@ -1,7 +1,6 @@ // @flow +import {getPluginConfig, getUserConfig} from './config' import createWebpackConfig from './createWebpackConfig' -import getPluginConfig from './getPluginConfig' -import getUserConfig from './getUserConfig' import type {ServerConfig} from './types' diff --git a/src/createWebpackConfig.js b/src/createWebpackConfig.js index e50758c1..5e2d6b23 100644 --- a/src/createWebpackConfig.js +++ b/src/createWebpackConfig.js @@ -786,7 +786,7 @@ export default function createWebpackConfig( } // Generate config for babel-loader and set it as loader config for the build - buildRulesConfig.babel = {options: createBabelConfig(buildBabelConfig, userConfig.babel)} + buildRulesConfig.babel = {options: createBabelConfig(buildBabelConfig, userConfig.babel, userConfig.path)} let webpackConfig = { module: { diff --git a/src/expressMiddleware.js b/src/expressMiddleware.js index 7b864813..61968f16 100644 --- a/src/expressMiddleware.js +++ b/src/expressMiddleware.js @@ -4,10 +4,10 @@ import assert from 'assert' import webpack from 'webpack' import {createServeConfig} from './appCommands' +import {getProjectType} from './config' import {INFERNO_APP, PREACT_APP, REACT_APP, WEB_APP} from './constants' import createServerWebpackConfig from './createServerWebpackConfig' import debug from './debug' -import {getProjectType} from './getUserConfig' import {deepToString, joinAnd} from './utils' const APP_TYPE_CONFIG = { diff --git a/src/getUserConfig.js b/src/getUserConfig.js deleted file mode 100644 index 3883350e..00000000 --- a/src/getUserConfig.js +++ /dev/null @@ -1,643 +0,0 @@ -import fs from 'fs' -import path from 'path' -import util from 'util' - -import chalk from 'chalk' -import figures from 'figures' -import webpack from 'webpack' - -import {CONFIG_FILE_NAME, INFERNO_APP, PREACT_APP, PROJECT_TYPES} from './constants' -import {COMPAT_CONFIGS} from './createWebpackConfig' -import debug from './debug' -import {ConfigValidationError} from './errors' -import {deepToString, joinAnd, replaceArrayMerge, typeOf} from './utils' - -const DEFAULT_REQUIRED = false - -const BABEL_RUNTIME_OPTIONS = new Set(['helpers', 'polyfill']) -const DEFAULT_STYLE_LOADERS = new Set(['css', 'postcss']) - -let s = (n, w = ',s') => w.split(',')[n === 1 ? 0 : 1] - -export class UserConfigReport { - constructor(configPath) { - this.configPath = configPath - this.deprecations = [] - this.errors = [] - this.hints = [] - } - - deprecated(path, ...messages) { - this.deprecations.push({path, messages}) - } - - error(path, value, message) { - this.errors.push({path, value, message}) - } - - hasErrors() { - return this.errors.length > 0 - } - - hasSomethingToReport() { - return this.errors.length + this.deprecations.length + this.hints.length > 0 - } - - hint(path, ...messages) { - this.hints.push({path, messages}) - } - - log() { - console.log(chalk.underline(`nwb config report for ${this.configPath}`)) - console.log() - if (!this.hasSomethingToReport()) { - console.log(chalk.green(`${figures.tick} Nothing to report!`)) - return - } - - if (this.errors.length) { - let count = this.errors.length > 1 ? `${this.errors.length} ` : '' - console.log(chalk.red.underline(`${count}Error${s(this.errors.length)}`)) - console.log() - } - this.errors.forEach(({path, value, message}) => { - console.log(`${chalk.red(`${figures.cross} ${path}`)} ${chalk.cyan('=')} ${util.inspect(value)}`) - console.log(` ${message}`) - console.log() - }) - if (this.deprecations.length) { - let count = this.deprecations.length > 1 ? `${this.deprecations.length} ` : '' - console.log(chalk.yellow.underline(`${count}Deprecation Warning${s(this.deprecations.length)}`)) - console.log() - } - this.deprecations.forEach(({path, messages}) => { - console.log(chalk.yellow(`${figures.warning} ${path}`)) - messages.forEach(message => { - console.log(` ${message}`) - }) - console.log() - }) - if (this.hints.length) { - let count = this.hints.length > 1 ? `${this.hints.length} ` : '' - console.log(chalk.cyan.underline(`${count}Hint${s(this.hints.length)}`)) - console.log() - } - this.hints.forEach(({path, messages}) => { - console.log(chalk.cyan(`${figures.info} ${path}`)) - messages.forEach(message => { - console.log(` ${message}`) - }) - console.log() - }) - } -} - -function checkForRedundantCompatAliases(projectType, aliases, configPath, report) { - if (!new Set([INFERNO_APP, PREACT_APP]).has(projectType)) return - if (!aliases) return - - let compatModule = `${projectType.split('-')[0]}-compat` - if (aliases.react && aliases.react.includes(compatModule)) { - report.hint(`${configPath}.react`, - `nwb aliases ${chalk.yellow('react')} to ${chalk.green(compatModule)} by default, so you can remove this config.` - ) - } - if (aliases['react-dom'] && aliases['react-dom'].includes(compatModule)) { - report.hint(`${configPath}.react-dom`, - `nwb aliases ${chalk.yellow('react-dom')} to ${chalk.green(compatModule)} by default, so you can remove this config.` - ) - } -} - -/** - * Load a user config file to get its project type. If we need to check the - * project type, a config file must exist. - */ -export function getProjectType(args = {}) { - // Try to load default user config, or use a config file path we were given - let userConfig = {} - let userConfigPath = path.resolve(args.config || CONFIG_FILE_NAME) - - // Bail early if a config file doesn't exist - let configFileExists = fs.existsSync(userConfigPath) - if (!configFileExists) { - throw new Error(`Couldn't find a config file at ${userConfigPath} to determine project type.`) - } - - try { - userConfig = require(userConfigPath) - // Delete the file from the require cache as it may be imported multiple - // times with a different NODE_ENV in place depending on the command. - delete require.cache[userConfigPath] - } - catch (e) { - throw new Error(`Couldn't import the config file at ${userConfigPath}: ${e.message}\n${e.stack}`) - } - - // Config modules can export a function if they need to access the current - // command or the webpack dependency nwb manages for them. - if (typeOf(userConfig) === 'function') { - userConfig = userConfig({ - args, - command: args._[0], - webpack, - }) - } - - let report = new UserConfigReport(userConfigPath) - - if (!PROJECT_TYPES.has(userConfig.type)) { - report.error('type', userConfig.type, `Must be one of: ${[...PROJECT_TYPES].join(', ')}`) - } - if (report.hasErrors()) { - throw new ConfigValidationError(report) - } - - return userConfig.type -} - -/** - * Move loader options into an options object, allowing users to provide flatter - * config. - */ -export function prepareWebpackRuleConfig(rules) { - Object.keys(rules).forEach(ruleId => { - let rule = rules[ruleId] - // XXX Special case for stylus-loader, which uses a 'use' option for plugins - if ((rule.use && !/stylus$/.test(ruleId)) || rule.options) return - let { - exclude, include, test, loader, // eslint-disable-line no-unused-vars - ...options - } = rule - if (Object.keys(options).length > 0) { - rule.options = options - Object.keys(options).forEach(prop => delete rule[prop]) - } - }) -} - -/** - * Move loader options into a loaders object, allowing users to provide flatter - * config. - */ -export function prepareWebpackStyleConfig(styles) { - Object.keys(styles).forEach(type => { - styles[type].forEach(styleConfig => { - let { - exclude, include, // eslint-disable-line no-unused-vars - ...loaderConfig - } = styleConfig - if (Object.keys(loaderConfig).length > 0) { - styleConfig.loaders = {} - Object.keys(loaderConfig).forEach(loader => { - styleConfig.loaders[loader] = {options: styleConfig[loader]} - delete styleConfig[loader] - }) - } - }) - }) -} - -// TODO Remove in a future version -let warnedAboutEnzymeCompat = false -let warnedAboutOldStyleRules = false - -/** - * Validate user config and perform any necessary validation and transformation - * to it. - */ -export function processUserConfig({ - args = {}, - check = false, - pluginConfig = {}, - required = DEFAULT_REQUIRED, - userConfig, - userConfigPath, - }) { - // Config modules can export a function if they need to access the current - // command or the webpack dependency nwb manages for them. - if (typeOf(userConfig) === 'function') { - userConfig = userConfig({ - args, - command: args._[0], - webpack, - }) - } - - let report = new UserConfigReport(userConfigPath) - - if ((required || 'type' in userConfig) && !PROJECT_TYPES.has(userConfig.type)) { - report.error('type', userConfig.type, `Must be one of: ${[...PROJECT_TYPES].join(', ')}`) - } - - let argumentOverrides = {} - void ['babel', 'devServer', 'karma', 'npm', 'webpack'].forEach(prop => { - // Set defaults for top-level config objects so we don't have to - // existence-check them everywhere. - if (!(prop in userConfig)) { - userConfig[prop] = {} - } - // Allow config overrides via --path.to.config in args - if (typeOf(args[prop]) === 'object') { - argumentOverrides[prop] = args[prop] - } - }) - - // Make it harder for the user to forget to disable the production debug build - // if they've enabled it in the config file. - if (userConfig.webpack.debug) { - report.hint('webpack.debug', - "Don't forget to disable the debug build before building for production" - ) - } - - if (Object.keys(argumentOverrides).length > 0) { - debug('user config arguments: %s', deepToString(argumentOverrides)) - userConfig = replaceArrayMerge(userConfig, argumentOverrides) - } - - // Babel config - if (!!userConfig.babel.stage || userConfig.babel.stage === 0) { - if (typeOf(userConfig.babel.stage) !== 'number') { - report.error( - 'babel.stage', - userConfig.babel.stage, - `Must be a ${chalk.cyan('Number')} between ${chalk.cyan('0')} and ${chalk.cyan('3')}, ` + - `or ${chalk.cyan('false')} to disable use of a stage preset.` - ) - } - else if (userConfig.babel.stage < 0 || userConfig.babel.stage > 3) { - report.error( - 'babel.stage', - userConfig.babel.stage, - `Must be between ${chalk.cyan(0)} and ${chalk.cyan(3)}` - ) - } - } - if (userConfig.babel.presets) { - if (typeOf(userConfig.babel.presets) === 'string') { - userConfig.babel.presets = [userConfig.babel.presets] - } - else if (typeOf(userConfig.babel.presets) !== 'array') { - report.error('babel.presets', userConfig.babel.presets, `Must be a string or an ${chalk.cyan('Array')}`) - } - } - if (userConfig.babel.plugins) { - if (typeOf(userConfig.babel.plugins) === 'string') { - userConfig.babel.plugins = [userConfig.babel.plugins] - } - else if (typeOf(userConfig.babel.plugins) !== 'array') { - report.error('babel.plugins', userConfig.babel.plugins, `Must be a string or an ${chalk.cyan('Array')}`) - } - } - if ('runtime' in userConfig.babel && - typeOf(userConfig.babel.runtime) !== 'boolean' && - !BABEL_RUNTIME_OPTIONS.has(userConfig.babel.runtime)) { - report.error( - 'babel.runtime', - userConfig.babel.runtime, - `Must be ${chalk.cyan('boolean')}, ${chalk.cyan("'helpers'")} or ${chalk.cyan("'polyfill'")})` - ) - } - - if ('loose' in userConfig.babel) { - if (typeOf(userConfig.babel.loose) !== 'boolean') { - report.error( - 'babel.loose', - userConfig.babel.loose, - `Must be ${chalk.cyan('boolean')}` - ) - } - else if (userConfig.babel.loose === true) { - report.hint('babel.loose', - 'Loose mode is enabled by default, so you can remove this config.' - ) - } - } - - // Karma config - // noop - - // npm build config - if (typeOf(userConfig.npm.umd) === 'string') { - userConfig.npm.umd = {global: userConfig.npm.umd} - } - - // Webpack config - if (typeOf(userConfig.webpack.autoprefixer) === 'string') { - userConfig.webpack.autoprefixer = {browsers: userConfig.webpack.autoprefixer} - } - - if ('copy' in userConfig.webpack) { - if (typeOf(userConfig.webpack.copy) === 'array') { - userConfig.webpack.copy = {patterns: userConfig.webpack.copy} - } - else if (typeOf(userConfig.webpack.copy) === 'object') { - if (!userConfig.webpack.copy.patterns && - !userConfig.webpack.copy.options) { - report.error( - 'webpack.copy', - userConfig.webpack.copy, - `Must include ${chalk.cyan('patterns')} or ${chalk.cyan('options')} when given as an ${chalk.cyan('Object')}` - ) - } - if (userConfig.webpack.copy.patterns && - typeOf(userConfig.webpack.copy.patterns) !== 'array') { - report.error( - 'webpack.copy.patterns', - userConfig.webpack.copy.patterns, - `Must be an ${chalk.cyan('Array')} when provided` - ) - } - if (userConfig.webpack.copy.options && - typeOf(userConfig.webpack.copy.options) !== 'object') { - report.error( - 'webpack.copy.options', - userConfig.webpack.copy.options, - `Must be an ${chalk.cyan('Object')} when provided.` - ) - } - } - else { - report.error( - 'webpack.copy', - userConfig.webpack.copy, - `Must be an ${chalk.cyan('Array')} or an ${chalk.cyan('Object')}.` - ) - } - } - - if (userConfig.webpack.compat) { - if (userConfig.webpack.compat.enzyme) { - if (!warnedAboutEnzymeCompat) { - report.deprecated( - 'webpack.compat.enzyme', - `The ${chalk.cyan('enzyme')} compat flag applies to Enzyme v2, which has been superseded by Enzyme v3.`, - 'Consider upgrading to Enzyme v3 if you can, which requires configuring Enzyme rather than Webpack.' - ) - warnedAboutEnzymeCompat = true - } - } - let compatProps = Object.keys(userConfig.webpack.compat) - let unknownCompatProps = compatProps.filter(prop => !(prop in COMPAT_CONFIGS)) - if (unknownCompatProps.length !== 0) { - report.error( - 'userConfig.webpack.compat', - compatProps, - `Unknown propert${unknownCompatProps.length === 1 ? 'y' : 'ies'} present.` + - `Valid properties are: ${Object.keys(COMPAT_CONFIGS).join(', ')}.`) - } - - if (userConfig.webpack.compat.intl) { - if (typeOf(userConfig.webpack.compat.intl.locales) === 'string') { - userConfig.webpack.compat.intl.locales = [userConfig.webpack.compat.intl.locales] - } - else if (typeOf(userConfig.webpack.compat.intl.locales) !== 'array') { - report.error( - 'webpack.compat.intl.locales', - webpack.compat.intl.locales, - 'Must be a string or an Array.' - ) - } - } - - if (userConfig.webpack.compat.moment) { - if (typeOf(userConfig.webpack.compat.moment.locales) === 'string') { - userConfig.webpack.compat.moment.locales = [userConfig.webpack.compat.moment.locales] - } - else if (typeOf(userConfig.webpack.compat.moment.locales) !== 'array') { - report.error( - 'webpack.compat.moment.locales', - webpack.compat.moment.locales, - 'Must be a string or an Array.' - ) - } - } - - if (userConfig.webpack.compat['react-intl']) { - if (typeOf(userConfig.webpack.compat['react-intl'].locales) === 'string') { - userConfig.webpack.compat['react-intl'].locales = - [userConfig.webpack.compat['react-intl'].locales] - } - else if (typeOf(userConfig.webpack.compat['react-intl'].locales) !== 'array') { - report.error( - 'webpack.compat[\'react-intl\'].locales', - webpack.compat['react-intl'].locales, - 'Must be a string or an Array.' - ) - } - } - } - - if (userConfig.webpack.vendorBundle === false) { - report.error( - 'webpack.vendorBundle', - webpack.vendorBundle, - 'No longer supported - add a --no-vendor flag to your build command instead.' - ) - } - - if ('rules' in userConfig.webpack) { - if (typeOf(userConfig.webpack.rules) !== 'object') { - report.error( - 'webpack.rules', - `type: ${typeOf(userConfig.webpack.rules)}`, - 'Must be an Object.' - ) - } - else { - let error = false - Object.keys(userConfig.webpack.rules).forEach(ruleId => { - let rule = userConfig.webpack.rules[ruleId] - if (rule.use && typeOf(rule.use) !== 'array') { - report.error( - `webpack.rules.${ruleId}.use`, - `type: ${typeOf(rule.use)}`, - 'Must be an Array.' - ) - error = true - } - }) - if (!error) { - prepareWebpackRuleConfig(userConfig.webpack.rules) - } - } - } - - if ('styles' in userConfig.webpack) { - let configType = typeOf(userConfig.webpack.styles) - if (configType === 'string' && userConfig.webpack.styles !== 'old') { - report.error( - 'webpack.styles', - userConfig.webpack.styles, - `Must be ${chalk.green("'old'")} (to use default style rules from nwb <= v0.15), ${chalk.green('false')} or an Object.` - ) - if (!warnedAboutOldStyleRules) { - report.deprecated('webpack.styles', 'Support for default style rules from nwb <= v0.15 will be removed in a future release.') - warnedAboutOldStyleRules = true - } - } - else if (configType === 'boolean' && userConfig.webpack.styles !== false) { - report.error( - 'webpack.styles', - userConfig.webpack.styles, - `Must be ${chalk.green("'old'")}, ${chalk.green('false')} (to disable default style rules) or an Object.` - ) - } - else if (configType !== 'object' && configType !== 'boolean') { - report.error( - 'webpack.styles', - `type: ${configType}`, - `Must be ${chalk.green("'old'")}, ${chalk.green('false')} or an Object (to configure custom style rules).` - ) - } - else { - let styleTypeIds = ['css'] - if (pluginConfig.cssPreprocessors) { - styleTypeIds = styleTypeIds.concat(Object.keys(pluginConfig.cssPreprocessors)) - } - let error = false - Object.keys(userConfig.webpack.styles).forEach(styleType => { - if (styleTypeIds.indexOf(styleType) === -1) { - report.error( - 'webpack.styles', - `property: ${styleType}`, - `Unknown style type - must be ${joinAnd(styleTypeIds.map(chalk.green), 'or')}` - ) - error = true - } - else if (typeOf(userConfig.webpack.styles[styleType]) !== 'array') { - report.error( - `webpack.styles.${styleType}`, - `type: ${typeOf(userConfig.webpack.styles[styleType])}`, - `Must be an Array - if you don't need multiple custom rules, configure the defaults via ${chalk.green('webpack.rules')} instead.` - ) - error = true - } - else { - userConfig.webpack.styles[styleType].forEach((styleConfig, index) => { - let { - test, include, exclude, // eslint-disable-line no-unused-vars - ...loaderConfig - } = styleConfig - Object.keys(loaderConfig).forEach(loaderId => { - if (!DEFAULT_STYLE_LOADERS.has(loaderId) && loaderId !== styleType) { - // XXX Assumption: preprocessors provide a single loader which - // is configured with the same id as the style type id. - // XXX Using Array.from() manually as babel-preset-env with a - // Node 4 target is tranpiling Array spreads to concat() - // calls without ensuring Sets are converted to Arrays. - let loaderIds = Array.from(new Set([ - ...Array.from(DEFAULT_STYLE_LOADERS), - styleType - ])).map(id => chalk.green(id)) - report.error( - `webpack.styles.${styleType}[${index}]`, - `property: ${loaderId}`, - `Must be ${chalk.green('include')}, ${chalk.green('exclude')} or a loader id: ${joinAnd(loaderIds, 'or')}` - ) - error = true - } - }) - }) - } - }) - if (!error) { - prepareWebpackStyleConfig(userConfig.webpack.styles, pluginConfig) - } - } - } - - checkForRedundantCompatAliases( - userConfig.type, - userConfig.webpack.aliases, - 'webpack.aliases', - report - ) - - if (userConfig.webpack.extra) { - if (userConfig.webpack.extra.output && - userConfig.webpack.extra.output.publicPath) { - report.hint('webpack.extra.output.publicPath', - `You can use the more convenient ${chalk.green('webpack.publicPath')} instead.` - ) - } - if (userConfig.webpack.extra.resolve && - userConfig.webpack.extra.resolve.alias) { - report.hint('webpack.extra.resolve.alias', - `You can use the more convenient ${chalk.green('webpack.aliases')} instead.` - ) - checkForRedundantCompatAliases( - userConfig.type, - userConfig.webpack.extra.resolve.alias, - 'webpack.extra.resolve.alias', - report - ) - } - } - - if ('config' in userConfig.webpack && typeOf(userConfig.webpack.config) !== 'function') { - report.error( - `webpack.config`, - `type: ${typeOf(userConfig.webpack.config)}`, - 'Must be a Function.' - ) - } - - if (report.hasErrors()) { - throw new ConfigValidationError(report) - } - if (check) { - throw report - } - if (report.hasSomethingToReport()) { - report.log() - } - - debug('user config: %s', deepToString(userConfig)) - - return userConfig -} - -/** - * Load a user config file and process it. - */ -export default function getUserConfig(args = {}, options = {}) { - let { - check = false, - pluginConfig = {}, // eslint-disable-line no-unused-vars - required = DEFAULT_REQUIRED, - } = options - // Try to load default user config, or use a config file path we were given - let userConfig = {} - let userConfigPath = path.resolve(args.config || CONFIG_FILE_NAME) - - // Bail early if a config file is required and doesn't exist - let configFileExists = fs.existsSync(userConfigPath) - if ((args.config || required) && !configFileExists) { - throw new Error(`Couldn't find a config file at ${userConfigPath}`) - } - - // If a config file exists, it should be a valid module regardless of whether - // or not it's required. - if (configFileExists) { - try { - userConfig = require(userConfigPath) - debug('imported config module from %s', userConfigPath) - // Delete the file from the require cache as some builds need to import - // it multiple times with a different NODE_ENV in place. - delete require.cache[userConfigPath] - } - catch (e) { - throw new Error(`Couldn't import the config file at ${userConfigPath}: ${e.message}\n${e.stack}`) - } - } - - userConfig = processUserConfig({args, check, pluginConfig, required, userConfig, userConfigPath}) - - if (configFileExists) { - userConfig.path = userConfigPath - } - - return userConfig -} diff --git a/src/karmaServer.js b/src/karmaServer.js index 61095bcf..ba651fe8 100644 --- a/src/karmaServer.js +++ b/src/karmaServer.js @@ -1,9 +1,8 @@ import {Server} from 'karma' +import {getPluginConfig, getUserConfig} from './config' import createKarmaConfig from './createKarmaConfig' import {KarmaExitCodeError} from './errors' -import getPluginConfig from './getPluginConfig' -import getUserConfig from './getUserConfig' export default function karmaServer(args, buildConfig, cb) { // Force the environment to test diff --git a/src/moduleBuild.js b/src/moduleBuild.js index d88b49af..7e0a5a82 100644 --- a/src/moduleBuild.js +++ b/src/moduleBuild.js @@ -7,11 +7,10 @@ import runSeries from 'run-series' import merge from 'webpack-merge' import cleanModule from './commands/clean-module' +import {getPluginConfig, getUserConfig} from './config' import createBabelConfig from './createBabelConfig' import debug from './debug' import {UserError} from './errors' -import getPluginConfig from './getPluginConfig' -import getUserConfig from './getUserConfig' import {deepToString} from './utils' import webpackBuild from './webpackBuild' import {createBanner, createExternals, logGzippedFileSizes} from './webpackUtils' @@ -29,8 +28,8 @@ const DEFAULT_BABEL_IGNORE_CONFIG = [ /** * Run Babel with generated config written to a temporary .babelrc. */ -function runBabel(name, {copyFiles, outDir, src}, buildBabelConfig, userBabelConfig, cb) { - let babelConfig = createBabelConfig(buildBabelConfig, userBabelConfig) +function runBabel(name, {copyFiles, outDir, src}, buildBabelConfig, userConfig, cb) { + let babelConfig = createBabelConfig(buildBabelConfig, userConfig.babel, userConfig.path) babelConfig.ignore = DEFAULT_BABEL_IGNORE_CONFIG debug('babel config: %s', deepToString(babelConfig)) @@ -150,7 +149,7 @@ export default function moduleBuild(args, buildConfig = {}, cb) { // Don't enable webpack-specific plugins webpack: false, }), - userConfig.babel, + userConfig, cb )) } @@ -169,7 +168,7 @@ export default function moduleBuild(args, buildConfig = {}, cb) { // Don't enable webpack-specific plugins webpack: false, }), - userConfig.babel, + userConfig, cb )) } diff --git a/src/utils.js b/src/utils.js index cff11fd8..58908310 100644 --- a/src/utils.js +++ b/src/utils.js @@ -199,6 +199,10 @@ export function modulePath(module: string, basedir: string = process.cwd()): str return path.dirname(resolve.sync(`${module}/package.json`, {basedir})) } +export function pluralise(count: number, suffixes : string = ',s'): string { + return suffixes.split(',')[count === 1 ? 0 : 1] +} + /** * Custom merge which replaces arrays instead of concatenating them. */ diff --git a/src/webpackBuild.js b/src/webpackBuild.js index 3c37748f..7c5a85af 100644 --- a/src/webpackBuild.js +++ b/src/webpackBuild.js @@ -1,10 +1,9 @@ import ora from 'ora' import webpack from 'webpack' +import {getPluginConfig, getUserConfig} from './config' import createWebpackConfig from './createWebpackConfig' import debug from './debug' -import getPluginConfig from './getPluginConfig' -import getUserConfig from './getUserConfig' import {deepToString} from './utils' import {logBuildResults} from './webpackUtils' diff --git a/src/webpackServer.js b/src/webpackServer.js index df2fa633..fc26aaca 100644 --- a/src/webpackServer.js +++ b/src/webpackServer.js @@ -2,12 +2,11 @@ import {yellow} from 'chalk' import detect from 'detect-port' import inquirer from 'inquirer' +import {getPluginConfig, getUserConfig} from './config' import {DEFAULT_PORT} from './constants' import createServerWebpackConfig from './createServerWebpackConfig' import debug from './debug' import devServer from './devServer' -import getPluginConfig from './getPluginConfig' -import getUserConfig from './getUserConfig' import {clearConsole, deepToString} from './utils' /** diff --git a/tests/getUserConfig-test.js b/tests/config-test.js similarity index 88% rename from tests/getUserConfig-test.js rename to tests/config-test.js index 689edda8..025c4d84 100644 --- a/tests/getUserConfig-test.js +++ b/tests/config-test.js @@ -1,8 +1,12 @@ +import path from 'path' + import expect from 'expect' import webpack from 'webpack' import {ConfigValidationError} from '../src/errors' -import getUserConfig, {getProjectType, prepareWebpackRuleConfig, prepareWebpackStyleConfig, processUserConfig} from '../src/getUserConfig' +import {getPluginConfig, getUserConfig, getProjectType} from '../src/config' +import {processUserConfig} from '../src/config/user' +import {prepareWebpackRuleConfig, prepareWebpackStyleConfig} from '../src/config/webpack' describe('getProjectType()', () => { it("throws an error when a config file can't be found", () => { @@ -144,19 +148,24 @@ describe('processUserConfig()', () => { it('passes command and webpack arguments when a config function is provided', () => { let args = {_: ['abc123']} + // Using the webpack.extra escape hatch to pass arguments back out let config = processUserConfig({ args, userConfig(args) { return { - args: args.args, - command: args.command, - webpack: args.webpack, + webpack: { + extra: { + args: args.args, + command: args.command, + webpack: args.webpack, + } + } } } }) - expect(config.args).toEqual(args) - expect(config.command).toEqual('abc123') - expect(config.webpack).toEqual(webpack) + expect(config.webpack.extra.args).toEqual(args) + expect(config.webpack.extra.command).toEqual('abc123') + expect(config.webpack.extra.webpack).toEqual(webpack) }) it('defaults top-level config when none is provided', () => { @@ -260,3 +269,17 @@ describe('prepareWebpackStyleConfig()', () => { }) }) }) + +describe('getPluginConfig()', () => { + it('scans package.json for nwb-* dependencies and imports them', () => { + let config = getPluginConfig({}, {cwd: path.join(__dirname, 'fixtures/plugins')}) + expect(config).toEqual({ + cssPreprocessors: { + fake: { + loader: 'path/to/fake.js', + test: /\.fake$/, + } + } + }) + }) +}) diff --git a/tests/fixtures/bad-config.js b/tests/fixtures/bad-config.js new file mode 100644 index 00000000..97ffa632 --- /dev/null +++ b/tests/fixtures/bad-config.js @@ -0,0 +1,8 @@ +// A config file in which everything is wrong +module.exports = { + babel: 45, + devServer: 45, + karma: 45, + npm: 45, + webpack: 45 +} diff --git a/tests/fixtures/worst-config.js b/tests/fixtures/worst-config.js new file mode 100644 index 00000000..edfe1629 --- /dev/null +++ b/tests/fixtures/worst-config.js @@ -0,0 +1,68 @@ +// A config file in which everything is wrong +module.exports = { + type: 'invalid', + polyfill: 45, + invalidTopLevelConfig: true, + moreInvalidTopLevelConfig: true, + babel: { + invalidBabelConfig: true, + cherryPick: 45, + env: 45, + loose: 45, + plugins: 45, + presets: 45, + removePropTypes: 45, + reactConstantElements: 45, + runtime: 45, + stage: 45, + config: 45 + }, + devServer: 45, + karma: { + invalidKarmaConfig: true, + browsers: 45, + excludeFromCoverage: 45, + frameworks: 45, + plugins: 45, + reporters: 45, + testContext: 45, + testFiles: 45, + extra: 45, + config: 45 + }, + npm: { + invalidNpmBuildConfig: true, + cjs: 45, + esModules: 45, + umd: { + invalidUMDConfig: true, + global: 45, + externals: 45, + } + }, + webpack: { + invalidWebpackConfig: true, + moreInvalidWebpackConfig: true, + aliases: 45, + autoprefixer: 45, + compat: { + invalidCompatConfig: true, + intl: 45, + moment: 45, + 'react-intl': 45, + }, + copy: 45, + debug: 45, + define: 45, + extractText: 45, + hoisting: 45, + html: 45, + install: 45, + publicPath: 45, + rules: 45, + styles: 45, + uglify: 45, + extra: 45, + config: 45, + } +} diff --git a/tests/getPluginConfig-test.js b/tests/getPluginConfig-test.js deleted file mode 100644 index 65c467a2..00000000 --- a/tests/getPluginConfig-test.js +++ /dev/null @@ -1,19 +0,0 @@ -import path from 'path' - -import expect from 'expect' - -import getPluginConfig from '../src/getPluginConfig' - -describe('getPluginConfig()', () => { - it('scans package.json for nwb-* dependencies and imports them', () => { - let config = getPluginConfig({}, {cwd: path.join(__dirname, 'fixtures/plugins')}) - expect(config).toEqual({ - cssPreprocessors: { - fake: { - loader: 'path/to/fake.js', - test: /\.fake$/, - } - } - }) - }) -})