Skip to content

Commit

Permalink
Basic plugin support
Browse files Browse the repository at this point in the history
  • Loading branch information
Timer committed Jul 19, 2017
1 parent 0482058 commit f8a1faa
Show file tree
Hide file tree
Showing 5 changed files with 330 additions and 9 deletions.
9 changes: 9 additions & 0 deletions packages/react-dev-utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"ModuleScopePlugin.js",
"openBrowser.js",
"openChrome.applescript",
"plugins.js",
"printHostingInstructions.js",
"WatchMissingNodeModulesPlugin.js",
"WebpackDevServerUtils.js",
Expand All @@ -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",
Expand All @@ -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",
Expand Down
262 changes: 262 additions & 0 deletions packages/react-dev-utils/plugins.js
Original file line number Diff line number Diff line change
@@ -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,
};
5 changes: 4 additions & 1 deletion packages/react-scripts/config/webpack.config.dev.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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',
Expand Down Expand Up @@ -290,3 +291,5 @@ module.exports = {
hints: false,
},
};

module.exports = applyPlugins(base, [], { paths });
5 changes: 4 additions & 1 deletion packages/react-scripts/config/webpack.config.prod.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -358,3 +359,5 @@ module.exports = {
tls: 'empty',
},
};

module.exports = applyPlugins(base, [], { paths });
Loading

0 comments on commit f8a1faa

Please sign in to comment.