diff --git a/packages/react-dev-utils/package.json b/packages/react-dev-utils/package.json index e09e990d0b7..2509c7ed9db 100644 --- a/packages/react-dev-utils/package.json +++ b/packages/react-dev-utils/package.json @@ -27,6 +27,7 @@ "ModuleScopePlugin.js", "openBrowser.js", "openChrome.applescript", + "plugins.js", "printHostingInstructions.js", "WatchMissingNodeModulesPlugin.js", "WebpackDevServerUtils.js", @@ -36,6 +37,11 @@ "address": "1.0.2", "anser": "1.4.1", "babel-code-frame": "6.22.0", + "babel-generator": "^6.25.0", + "babel-template": "^6.25.0", + "babel-traverse": "^6.25.0", + "babel-types": "^6.25.0", + "babylon": "^6.17.4", "chalk": "1.1.3", "cross-spawn": "4.0.2", "detect-port-alt": "1.1.3", @@ -47,7 +53,10 @@ "inquirer": "3.1.1", "is-root": "1.0.0", "opn": "5.1.0", + "prettier": "^1.5.2", + "read-pkg-up": "^2.0.0", "recursive-readdir": "2.2.1", + "semver": "^5.3.0", "shell-quote": "1.6.1", "sockjs-client": "1.1.4", "strip-ansi": "3.0.1", diff --git a/packages/react-dev-utils/plugins.js b/packages/react-dev-utils/plugins.js new file mode 100644 index 00000000000..3531c1a689b --- /dev/null +++ b/packages/react-dev-utils/plugins.js @@ -0,0 +1,262 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +'use strict'; + +const babylon = require('babylon'); +const traverse = require('babel-traverse').default; +const template = require('babel-template'); +const generator = require('babel-generator').default; +const t = require('babel-types'); +const { readFileSync } = require('fs'); +const prettier = require('prettier'); +const getPackageJson = require('read-pkg-up').sync; +const { dirname, isAbsolute } = require('path'); +const semver = require('semver'); + +function applyPlugins(config, plugins, { paths }) { + const pluginPaths = plugins + .map(p => { + try { + return require.resolve(`react-scripts-plugin-${p}`); + } catch (e) { + return null; + } + }) + .filter(e => e != null); + for (const pluginPath of pluginPaths) { + const { apply } = require(pluginPath); + config = apply(config, { paths }); + } + return config; +} + +function _getArrayValues(arr) { + const { elements } = arr; + return elements.map(e => { + if (e.type === 'StringLiteral') { + return e.value; + } + return e; + }); +} + +// arr: [[afterExt, strExt1, strExt2, ...], ...] +function pushExtensions({ config, ast }, arr) { + if (ast != null) { + traverse(ast, { + enter(path) { + const { type } = path; + if (type !== 'ArrayExpression') { + return; + } + const { key } = path.parent; + if (key == null || key.name !== 'extensions') { + return; + } + const { elements } = path.node; + const extensions = _getArrayValues(path.node); + for (const [after, ...exts] of arr) { + // Find the extension we want to add after + const index = extensions.findIndex(s => s === after); + if (index === -1) { + throw new Error( + `Unable to find extension ${after} in configuration.` + ); + } + // Push the extensions into array in the order we specify + elements.splice( + index + 1, + 0, + ...exts.map(ext => t.stringLiteral(ext)) + ); + // Simulate into our local copy of the array to keep proper indices + extensions.splice(index + 1, 0, ...exts); + } + }, + }); + } else if (config != null) { + const { resolve: { extensions } } = config; + + for (const [after, ...exts] of arr) { + // Find the extension we want to add after + const index = extensions.findIndex(s => s === after); + if (index === -1) { + throw new Error(`Unable to find extension ${after} in configuration.`); + } + // Push the extensions into array in the order we specify + extensions.splice(index + 1, 0, ...exts); + } + } +} + +function pushExclusiveLoader({ config, ast }, testStr, loader) { + if (ast != null) { + traverse(ast, { + enter(path) { + const { type } = path; + if (type !== 'ArrayExpression') { + return; + } + const { key } = path.parent; + if (key == null || key.name !== 'oneOf') { + return; + } + const entries = _getArrayValues(path.node); + const afterIndex = entries.findIndex(entry => { + const { properties } = entry; + return ( + properties.find(property => { + if (property.value.type !== 'RegExpLiteral') { + return false; + } + return property.value.pattern === testStr.slice(1, -1); + }) != null + ); + }); + if (afterIndex === -1) { + throw new Error('Unable to match pre-loader.'); + } + path.node.elements.splice(afterIndex + 1, 0, loader); + }, + }); + } else if (config != null) { + const { module: { rules: [, { oneOf: rules }] } } = config; + const loaderIndex = rules.findIndex( + rule => rule.test.toString() === testStr + ); + if (loaderIndex === -1) { + throw new Error('Unable to match pre-loader.'); + } + rules.splice(loaderIndex + 1, 0, loader); + } +} + +function ejectFile({ filename, code, existingDependencies }) { + if (filename != null) { + code = readFileSync(filename, 'utf8'); + } + let ast = babylon.parse(code); + + let plugins = []; + traverse(ast, { + enter(path) { + const { type } = path; + if (type === 'VariableDeclaration') { + const { node: { declarations: [{ id: { name }, init }] } } = path; + if (name !== 'base') { + return; + } + path.replaceWith(template('module.exports = RIGHT;')({ RIGHT: init })); + } else if (type === 'AssignmentExpression') { + const { node: { left, right } } = path; + if (left.type !== 'MemberExpression') { + return; + } + if (right.type !== 'CallExpression') { + return; + } + const { callee: { name }, arguments: args } = right; + if (name !== 'applyPlugins') { + return; + } + plugins = _getArrayValues(args[1]); + path.parentPath.remove(); + } + }, + }); + let deferredTransforms = []; + const dependencies = new Map([...existingDependencies]); + const paths = new Set(); + plugins.forEach(p => { + let path; + try { + path = require.resolve(`react-scripts-plugin-${p}`); + } catch (e) { + return; + } + paths.add(path); + + const { pkg: pluginPackage } = getPackageJson({ cwd: dirname(path) }); + for (const pkg of Object.keys(pluginPackage.dependencies)) { + const version = pluginPackage.dependencies[pkg]; + if (dependencies.has(pkg)) { + const prev = dependencies.get(pkg); + if ( + isAbsolute(version) || + semver.satisfies(version.replace(/[\^~]/g, ''), prev) + ) { + continue; + } else if (!semver.satisfies(prev.replace(/[\^~]/g, ''), version)) { + throw new Error( + `Dependency ${pkg}@${version} cannot be satisfied by colliding range ${pkg}@${prev}.` + ); + } + } + dependencies.set(pkg, pluginPackage.dependencies[pkg]); + } + + const pluginCode = readFileSync(path, 'utf8'); + const pluginAst = babylon.parse(pluginCode); + traverse(pluginAst, { + enter(path) { + const { type } = path; + if (type !== 'CallExpression') { + return; + } + const { node: { callee: { name }, arguments: pluginArgs } } = path; + switch (name) { + case 'pushExtensions': { + const [, _exts] = pluginArgs; + const exts = _getArrayValues(_exts).map(entry => + _getArrayValues(entry) + ); + deferredTransforms.push( + pushExtensions.bind(undefined, { ast }, exts) + ); + break; + } + case 'pushExclusiveLoader': { + const [, { value: testStr }, _loader] = pluginArgs; + deferredTransforms.push( + pushExclusiveLoader.bind(undefined, { ast }, testStr, _loader) + ); + break; + } + default: { + // Not a call we care about + break; + } + } + }, + }); + }); + // Execute 'em! + for (const transform of deferredTransforms) { + transform(); + } + let { code: outCode } = generator( + ast, + { sourceMaps: false, comments: true, retainLines: false }, + code + ); + outCode = prettier.format(outCode, { + singleQuote: true, + trailingComma: 'es5', + }); + + return { code: outCode, dependencies, paths }; +} + +module.exports = { + applyPlugins, + pushExtensions, + pushExclusiveLoader, + ejectFile, +}; diff --git a/packages/react-scripts/config/webpack.config.dev.js b/packages/react-scripts/config/webpack.config.dev.js index 253bc34f062..61550ce84a6 100644 --- a/packages/react-scripts/config/webpack.config.dev.js +++ b/packages/react-scripts/config/webpack.config.dev.js @@ -1,4 +1,5 @@ // @remove-on-eject-begin +// @remove-plugins-on-eject /** * Copyright (c) 2015-present, Facebook, Inc. * All rights reserved. @@ -21,6 +22,7 @@ const eslintFormatter = require('react-dev-utils/eslintFormatter'); const ModuleScopePlugin = require('react-dev-utils/ModuleScopePlugin'); const getClientEnvironment = require('./env'); const paths = require('./paths'); +const { applyPlugins } = require('react-dev-utils/plugins'); // Webpack uses `publicPath` to determine where the app is being served from. // In development, we always serve from the root. This makes config easier. @@ -35,7 +37,7 @@ const env = getClientEnvironment(publicUrl); // This is the development configuration. // It is focused on developer experience and fast rebuilds. // The production configuration is different and lives in a separate file. -module.exports = { +const base = { // You may want 'eval' instead if you prefer to see the compiled output in DevTools. // See the discussion in https://github.com/facebookincubator/create-react-app/issues/343. devtool: 'cheap-module-source-map', @@ -290,3 +292,5 @@ module.exports = { hints: false, }, }; + +module.exports = applyPlugins(base, [], { paths }); diff --git a/packages/react-scripts/config/webpack.config.prod.js b/packages/react-scripts/config/webpack.config.prod.js index 0077c34a3f6..9995ec8a1fa 100644 --- a/packages/react-scripts/config/webpack.config.prod.js +++ b/packages/react-scripts/config/webpack.config.prod.js @@ -1,4 +1,5 @@ // @remove-on-eject-begin +// @remove-plugins-on-eject /** * Copyright (c) 2015-present, Facebook, Inc. * All rights reserved. @@ -22,6 +23,7 @@ const eslintFormatter = require('react-dev-utils/eslintFormatter'); const ModuleScopePlugin = require('react-dev-utils/ModuleScopePlugin'); const paths = require('./paths'); const getClientEnvironment = require('./env'); +const { applyPlugins } = require('react-dev-utils/plugins'); // Webpack uses `publicPath` to determine where the app is being served from. // It requires a trailing slash, or the file assets will get an incorrect path. @@ -57,7 +59,7 @@ const extractTextPluginOptions = shouldUseRelativeAssetPaths // This is the production configuration. // It compiles slowly and is focused on producing a fast and minimal bundle. // The development configuration is different and lives in a separate file. -module.exports = { +const base = { // Don't attempt to continue if there are any errors. bail: true, // We generate sourcemaps in production. This is slow but gives good results. @@ -358,3 +360,5 @@ module.exports = { tls: 'empty', }, }; + +module.exports = applyPlugins(base, [], { paths }); diff --git a/packages/react-scripts/scripts/eject.js b/packages/react-scripts/scripts/eject.js index 3d8d258cc67..394cabd9aea 100644 --- a/packages/react-scripts/scripts/eject.js +++ b/packages/react-scripts/scripts/eject.js @@ -24,6 +24,7 @@ const paths = require('../config/paths'); const createJestConfig = require('./utils/createJestConfig'); const inquirer = require('react-dev-utils/inquirer'); const spawnSync = require('react-dev-utils/crossSpawn').sync; +const { ejectFile } = require('react-dev-utils/plugins'); const green = chalk.green; const cyan = chalk.cyan; @@ -39,6 +40,37 @@ function getGitStatus() { } } +function ejectContent(content, { additionalDeps, pluginPaths }) { + const { code, dependencies, paths: newPaths } = ejectFile({ + code: content, + existingDependencies: additionalDeps, + }); + for (const [key, value] of dependencies) { + additionalDeps.set(key, value); + } + for (const newPath of newPaths) { + pluginPaths.add(newPath); + } + return code; +} + +function addPlugins(pluginPaths) { + if (pluginPaths.size < 1) { + return; + } + + console.log(cyan('Adding plugins')); + + for (const pluginPath of pluginPaths) { + const pluginName = /.*react-scripts-plugin-([\w-]+)/.exec(pluginPath).pop(); + console.log(` Applying ${cyan(pluginName)}`); + const { eject } = require(pluginPath); + eject({ paths }); + } + + console.log(); +} + inquirer .prompt({ type: 'confirm', @@ -114,6 +146,8 @@ inquirer fs.mkdirSync(path.join(appPath, folder)); }); + const additionalDeps = new Map(), + pluginPaths = new Set(); files.forEach(file => { let content = fs.readFileSync(file, 'utf8'); @@ -121,6 +155,11 @@ inquirer if (content.match(/\/\/ @remove-file-on-eject/)) { return; } + // Remove plugins + if (content.match(/\/\/ @remove-plugins-on-eject/)) { + content = ejectContent(content, { additionalDeps, pluginPaths }); + } + content = content // Remove dead code from .js files on eject @@ -139,6 +178,8 @@ inquirer }); console.log(); + addPlugins(pluginPaths); + const ownPackage = require(path.join(ownPath, 'package.json')); const appPackage = require(path.join(appPath, 'package.json')); @@ -156,13 +197,22 @@ inquirer console.log(` Removing ${cyan(ownPackageName)} from dependencies`); delete appPackage.dependencies[ownPackageName]; } - Object.keys(ownPackage.dependencies).forEach(key => { + // Combine `react-scripts` dependencies with additional dependencies + const ownDependencies = Object.assign( + {}, + ownPackage.dependencies, + Array.from(additionalDeps).reduce( + (prev, [pkg, version]) => Object.assign(prev, { [pkg]: version }), + {} + ) + ); + Object.keys(ownDependencies).forEach(key => { // For some reason optionalDependencies end up in dependencies after install if (ownPackage.optionalDependencies[key]) { return; } console.log(` Adding ${cyan(key)} to dependencies`); - appPackage.dependencies[key] = ownPackage.dependencies[key]; + appPackage.dependencies[key] = ownDependencies[key]; }); // Sort the deps const unsortedDependencies = appPackage.dependencies;