diff --git a/packages/react-dev-utils/package.json b/packages/react-dev-utils/package.json index b4539788d26..a03aeda91b5 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", @@ -46,7 +52,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..1c0906fdfed --- /dev/null +++ b/packages/react-dev-utils/plugins.js @@ -0,0 +1,263 @@ +/** + * 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.'); + } + console.log('holy shit it works'); + 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-plugin-typescript/package.json b/packages/react-scripts-plugin-typescript/package.json new file mode 100644 index 00000000000..39ee4c824e2 --- /dev/null +++ b/packages/react-scripts-plugin-typescript/package.json @@ -0,0 +1,31 @@ +{ + "name": "react-scripts-plugin-typescript", + "version": "0.1.0", + "description": "A plugin for react-scripts which enables TypeScript support.", + "main": "src/index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [ + "react-scripts", + "typescript", + "cra", + "create", + "react", + "app", + "plugin" + ], + "license": "BSD-3-Clause", + "dependencies": { + "@types/node": "^8.0.11", + "awesome-typescript-loader": "^3.2.1", + "tsconfig-react-app": "^1.0.0", + "typescript": "^2.4.1" + }, + "devDependencies": { + "react-dev-utils": "^3.0.2" + }, + "peerDependencies": { + "react-dev-utils": "^3.0.2" + } +} diff --git a/packages/react-scripts-plugin-typescript/src/index.js b/packages/react-scripts-plugin-typescript/src/index.js new file mode 100644 index 00000000000..f8717fdb9e9 --- /dev/null +++ b/packages/react-scripts-plugin-typescript/src/index.js @@ -0,0 +1,36 @@ +'use strict'; + +const { + pushExtensions, + pushExclusiveLoader, +} = require('react-dev-utils/plugins'); + +function apply(config, { paths }) { + pushExtensions({ config }, [['.js', '.tsx', '.ts']]); + pushExclusiveLoader({ config }, '/\\.(js|jsx)$/', { + // Process TypeScript with `at-loader` + test: /\.(ts|tsx)$/, + include: paths.appSrc, + loader: require.resolve('awesome-typescript-loader'), + options: { + silent: true, + // @remove-on-eject-begin + configFileName: require.resolve('tsconfig-react-app'), + // @remove-on-eject-end + }, + }); + return config; +} + +const { readFileSync, writeFileSync } = require('fs'); +const { join } = require('path'); + +function eject({ paths }) { + const configFileName = require.resolve('tsconfig-react-app'); + const tsconfigContent = readFileSync(configFileName, 'utf8'); + + const { appSrc } = paths; + writeFileSync(join(appSrc, 'tsconfig.json'), tsconfigContent); +} + +module.exports = { apply, eject }; diff --git a/packages/react-scripts/config/webpack.config.dev.js b/packages/react-scripts/config/webpack.config.dev.js index 253bc34f062..474a6f51359 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, ['typescript'], { paths }); diff --git a/packages/react-scripts/config/webpack.config.prod.js b/packages/react-scripts/config/webpack.config.prod.js index 0077c34a3f6..966a75fea28 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, ['typescript'], { 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); diff --git a/packages/react-scripts/template/src/App.js b/packages/react-scripts/template/src/App.tsx similarity index 54% rename from packages/react-scripts/template/src/App.js rename to packages/react-scripts/template/src/App.tsx index d7d52a7f38a..63220ae99e8 100644 --- a/packages/react-scripts/template/src/App.js +++ b/packages/react-scripts/template/src/App.tsx @@ -1,8 +1,9 @@ -import React, { Component } from 'react'; -import logo from './logo.svg'; -import './App.css'; +import React from 'react' +import './App.css' -class App extends Component { +const logo = require('./logo.svg') + +class App extends React.Component<{}, {}> { render() { return (
@@ -11,11 +12,11 @@ class App extends Component {

Welcome to React

- To get started, edit src/App.js and save to reload. + To get started, edit src/App.tsx and save to reload.

- ); + ) } } -export default App; +export default App diff --git a/packages/react-scripts/template/tsconfig.json b/packages/react-scripts/template/tsconfig.json new file mode 100644 index 00000000000..4ba837675cf --- /dev/null +++ b/packages/react-scripts/template/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig-react-app/tsconfig.json" +} diff --git a/packages/tsconfig-react-app/package.json b/packages/tsconfig-react-app/package.json new file mode 100644 index 00000000000..54b62450d76 --- /dev/null +++ b/packages/tsconfig-react-app/package.json @@ -0,0 +1,14 @@ +{ + "name": "tsconfig-react-app", + "version": "1.0.0", + "description": "", + "main": "tsconfig.json", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "BSD-3-Clause", + "peerDependencies": { + "@types/node": "^8.0.9" + } +} diff --git a/packages/tsconfig-react-app/tsconfig.json b/packages/tsconfig-react-app/tsconfig.json new file mode 100644 index 00000000000..244feea0c2b --- /dev/null +++ b/packages/tsconfig-react-app/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "allowJs": true, + "allowSyntheticDefaultImports": true, + "allowUnreachableCode": true, + "allowUnusedLabels": true, + "alwaysStrict": true, + "checkJs": false, + "diagnostics": false, + "downlevelIteration": true, + "emitBOM": false, + "emitDecoratorMetadata": false, + "experimentalDecorators": false, + "forceConsistentCasingInFileNames": true, + "jsx": "react", + "lib": ["es5", "es2015.promise", "dom"], + "types": ["node"], + "module": "es2015", + "moduleResolution": "node", + "sourceMap": true, + "strict": true, + "noImplicitAny": false, + "target": "es5" + } +} diff --git a/packages/tsconfig-react-app/webpack.d.ts b/packages/tsconfig-react-app/webpack.d.ts new file mode 100644 index 00000000000..5da4283cdeb --- /dev/null +++ b/packages/tsconfig-react-app/webpack.d.ts @@ -0,0 +1 @@ +declare var __webpack_public_path__: string