From f8a1faa497f6fd33e1c1408fe61dd7c4ea52090d Mon Sep 17 00:00:00 2001 From: Joe Haddad Date: Sun, 9 Jul 2017 17:13:29 -0400 Subject: [PATCH] Basic plugin support --- packages/react-dev-utils/package.json | 9 + packages/react-dev-utils/plugins.js | 262 ++++++++++++++++++ .../config/webpack.config.dev.js | 5 +- .../config/webpack.config.prod.js | 5 +- packages/react-scripts/scripts/eject.js | 58 +++- 5 files changed, 330 insertions(+), 9 deletions(-) create mode 100644 packages/react-dev-utils/plugins.js 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..34e08686c59 100644 --- a/packages/react-scripts/config/webpack.config.dev.js +++ b/packages/react-scripts/config/webpack.config.dev.js @@ -21,6 +21,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 +36,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 +291,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..8bac8280144 100644 --- a/packages/react-scripts/config/webpack.config.prod.js +++ b/packages/react-scripts/config/webpack.config.prod.js @@ -22,6 +22,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 +58,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 +359,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..3913e42b1e3 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; @@ -114,6 +115,8 @@ inquirer fs.mkdirSync(path.join(appPath, folder)); }); + let addtlDeps = new Map(); + let pluginPaths = new Set(); files.forEach(file => { let content = fs.readFileSync(file, 'utf8'); @@ -121,6 +124,19 @@ inquirer if (content.match(/\/\/ @remove-file-on-eject/)) { return; } + // Inline plugins + if ( + file.endsWith('webpack.config.dev.js') || + file.endsWith('webpack.config.prod.js') + ) { + const { code, dependencies, paths: newPaths } = ejectFile({ + code: content, + existingDependencies: addtlDeps, + }); + content = code; + addtlDeps = new Map([...addtlDeps, ...dependencies]); + pluginPaths = new Set([...pluginPaths, ...newPaths]); + } content = content // Remove dead code from .js files on eject @@ -139,11 +155,39 @@ inquirer }); console.log(); - const ownPackage = require(path.join(ownPath, 'package.json')); + if (pluginPaths.size > 0) { + 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 }); + } + if (pluginPaths.size > 0) { + console.log(); + } + + const { + name: ownPackageName, + dependencies: _ownDependencies, + optionalDependencies: ownOptionalDependencies, + bin: ownBin, + } = require(path.join(ownPath, 'package.json')); const appPackage = require(path.join(appPath, 'package.json')); + const ownDependencies = Object.assign( + {}, + _ownDependencies, + Array.from(addtlDeps).reduce( + (prev, [pkg, version]) => Object.assign(prev, { [pkg]: version }), + {} + ) + ); + console.log(cyan('Updating the dependencies')); - const ownPackageName = ownPackage.name; if (appPackage.devDependencies) { // We used to put react-scripts in devDependencies if (appPackage.devDependencies[ownPackageName]) { @@ -156,13 +200,13 @@ inquirer console.log(` Removing ${cyan(ownPackageName)} from dependencies`); delete appPackage.dependencies[ownPackageName]; } - Object.keys(ownPackage.dependencies).forEach(key => { + Object.keys(ownDependencies).forEach(key => { // For some reason optionalDependencies end up in dependencies after install - if (ownPackage.optionalDependencies[key]) { + if (ownOptionalDependencies[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; @@ -175,7 +219,7 @@ inquirer console.log(cyan('Updating the scripts')); delete appPackage.scripts['eject']; Object.keys(appPackage.scripts).forEach(key => { - Object.keys(ownPackage.bin).forEach(binKey => { + Object.keys(ownBin).forEach(binKey => { const regex = new RegExp(binKey + ' (\\w+)', 'g'); if (!regex.test(appPackage.scripts[key])) { return; @@ -220,7 +264,7 @@ inquirer if (ownPath.indexOf(appPath) === 0) { try { // remove react-scripts and react-scripts binaries from app node_modules - Object.keys(ownPackage.bin).forEach(binKey => { + Object.keys(ownBin).forEach(binKey => { fs.removeSync(path.join(appPath, 'node_modules', '.bin', binKey)); }); fs.removeSync(ownPath);