diff --git a/packages/react-dev-utils/package.json b/packages/react-dev-utils/package.json index 48e1dc49d6a..5cdc2da9959 100644 --- a/packages/react-dev-utils/package.json +++ b/packages/react-dev-utils/package.json @@ -29,6 +29,7 @@ "noopServiceWorkerMiddleware.js", "openBrowser.js", "openChrome.applescript", + "plugins.js", "printHostingInstructions.js", "WatchMissingNodeModulesPlugin.js", "WebpackDevServerUtils.js", @@ -37,6 +38,11 @@ "dependencies": { "address": "1.0.2", "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": "5.1.0", "detect-port-alt": "1.1.3", @@ -47,8 +53,11 @@ "inquirer": "3.2.1", "is-root": "1.0.0", "opn": "5.1.0", + "prettier": "^1.5.2", "react-error-overlay": "^2.0.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..fb7337aa9b4 --- /dev/null +++ b/packages/react-dev-utils/plugins.js @@ -0,0 +1,272 @@ +/** + * 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, { path, 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, { path, paths }); + } + return config; +} + +function hasPlugin(plugin) { + try { + require.resolve(`react-scripts-plugin-${plugin}`); + return true; + } catch (e) { + return false; + } +} + +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, + hasPlugin, + 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..a912e382084 --- /dev/null +++ b/packages/react-scripts-plugin-typescript/package.json @@ -0,0 +1,30 @@ +{ + "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": { + "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..c316d3f29fb --- /dev/null +++ b/packages/react-scripts-plugin-typescript/src/index.js @@ -0,0 +1,35 @@ +'use strict'; + +const { + pushExtensions, + pushExclusiveLoader, +} = require('react-dev-utils/plugins'); + +function apply(config, { path, 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, + forceConsistentCasingInFileNames: true, + module: 'esnext', + moduleResolution: 'node', + downlevelIteration: true, + sourceMap: true, + target: 'es5', + // @remove-on-eject-begin + configFileName: path.join(paths.appSrc, 'tsconfig.json'), + // @remove-on-eject-end + }, + }); + return config; +} + +function eject() { + // TODO: remove defaults above and inject into their file +} + +module.exports = { apply, eject, tsc: require('typescript') }; diff --git a/packages/react-scripts/config/jest/typescriptTransform.js b/packages/react-scripts/config/jest/typescriptTransform.js new file mode 100644 index 00000000000..1d22ca9ad0e --- /dev/null +++ b/packages/react-scripts/config/jest/typescriptTransform.js @@ -0,0 +1,89 @@ +// @remove-on-eject-begin +// @remove-file-on-eject typescript +/** + * Copyright (c) 2014-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. + */ +// @remove-on-eject-end +'use strict'; + +const fs = require('fs'); +const crypto = require('crypto'); +const path = require('path'); +const paths = require('../paths'); +const tsc = (function() { + try { + return require('react-scripts-plugin-typescript').tsc; + } catch (e) { + return require('typescript'); + } +})(); +const THIS_FILE = fs.readFileSync(__filename); + +const strictCompilerConfig = { + module: tsc.ModuleKind.CommonJS, +}; + +let compilerConfig = Object.assign({}, strictCompilerConfig, { + jsx: tsc.JsxEmit.React, +}); + +const tsconfigPath = path.join(paths.appSrc, 'tsconfig.json'); + +if (fs.existsSync(tsconfigPath)) { + try { + const tsconfig = tsc.readConfigFile(tsconfigPath).config; + + if (tsconfig && tsconfig.compilerOptions) { + compilerConfig = Object.assign( + {}, + tsconfig.compilerOptions, + strictCompilerConfig + ); + } + } catch (e) { + /* Do nothing - default is set */ + } +} + +module.exports = { + process(src, path, config, options) { + if (path.endsWith('.ts') || path.endsWith('.tsx')) { + let compilerOptions = compilerConfig; + if (options.instrument) { + // inline source with source map for remapping coverage + compilerOptions = Object.assign({}, compilerConfig); + delete compilerOptions.sourceMap; + compilerOptions.inlineSourceMap = true; + compilerOptions.inlineSources = true; + delete compilerOptions.outDir; + } + + const tsTranspiled = tsc.transpileModule(src, { + compilerOptions: compilerOptions, + fileName: path, + }); + return tsTranspiled.outputText; + } + return src; + }, + getCacheKey(fileData, filePath, configStr, options) { + return crypto + .createHash('md5') + .update(THIS_FILE) + .update('\0', 'utf8') + .update(fileData) + .update('\0', 'utf8') + .update(filePath) + .update('\0', 'utf8') + .update(configStr) + .update('\0', 'utf8') + .update(JSON.stringify(compilerConfig)) + .update('\0', 'utf8') + .update(options.instrument ? 'instrument' : '') + .digest('hex'); + }, +}; diff --git a/packages/react-scripts/config/webpack.config.dev.js b/packages/react-scripts/config/webpack.config.dev.js index 44669e6059f..a1134677191 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. * @@ -19,6 +20,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. @@ -33,7 +35,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', @@ -287,3 +289,5 @@ module.exports = { hints: false, }, }; + +module.exports = applyPlugins(base, ['typescript'], { path, paths }); diff --git a/packages/react-scripts/config/webpack.config.prod.js b/packages/react-scripts/config/webpack.config.prod.js index 124d5b3cbeb..8dd687986ee 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. * @@ -20,6 +21,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. @@ -364,3 +366,5 @@ module.exports = { child_process: 'empty', }, }; + +module.exports = applyPlugins(base, ['typescript'], { path, paths }); diff --git a/packages/react-scripts/scripts/eject.js b/packages/react-scripts/scripts/eject.js index 2b859694fce..e447e7cd5b8 100644 --- a/packages/react-scripts/scripts/eject.js +++ b/packages/react-scripts/scripts/eject.js @@ -22,6 +22,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, hasPlugin } = require('react-dev-utils/plugins'); const green = chalk.green; const cyan = chalk.cyan; @@ -37,6 +38,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', @@ -112,13 +144,25 @@ inquirer fs.mkdirSync(path.join(appPath, folder)); }); + const additionalDeps = new Map(), + pluginPaths = new Set(); files.forEach(file => { let content = fs.readFileSync(file, 'utf8'); // Skip flagged files - if (content.match(/\/\/ @remove-file-on-eject/)) { + const pluginMatch = content.match(/\/\/ @remove-file-on-eject (\w+-?)+/); + if (pluginMatch) { + if (!hasPlugin(pluginMatch.pop())) { + return; + } + } else 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 @@ -137,6 +181,8 @@ inquirer }); console.log(); + addPlugins(pluginPaths); + const ownPackage = require(path.join(ownPath, 'package.json')); const appPackage = require(path.join(appPath, 'package.json')); @@ -154,13 +200,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; diff --git a/packages/react-scripts/scripts/utils/createJestConfig.js b/packages/react-scripts/scripts/utils/createJestConfig.js index 3e819b68e73..ee8c41a521c 100644 --- a/packages/react-scripts/scripts/utils/createJestConfig.js +++ b/packages/react-scripts/scripts/utils/createJestConfig.js @@ -10,8 +10,11 @@ const fs = require('fs'); const chalk = require('chalk'); const paths = require('../../config/paths'); +const { hasPlugin } = require('react-dev-utils/plugins'); module.exports = (resolve, rootDir, isEjecting) => { + const hasTypeScript = hasPlugin('typescript'); + // Use this instead of `paths.testsSetup` to avoid putting // an absolute filename into configuration after ejecting. const setupTestsFile = fs.existsSync(paths.testsSetup) @@ -21,27 +24,54 @@ module.exports = (resolve, rootDir, isEjecting) => { // TODO: I don't know if it's safe or not to just use / as path separator // in Jest configs. We need help from somebody with Windows to determine this. const config = { - collectCoverageFrom: ['src/**/*.{js,jsx}'], + collectCoverageFrom: [ + 'src/**/*.{js,jsx}', + ...(hasTypeScript ? ['src/**/*.{ts,tsx}'] : []), + ], setupFiles: [resolve('config/polyfills.js')], setupTestFrameworkScriptFile: setupTestsFile, testMatch: [ '/src/**/__tests__/**/*.js?(x)', '/src/**/?(*.)(spec|test).js?(x)', + ...(hasTypeScript + ? [ + '/src/**/__tests__/**/*.ts?(x)', + '/src/**/?(*.)(spec|test).ts?(x)', + ] + : []), ], testEnvironment: 'node', testURL: 'http://localhost', - transform: { - '^.+\\.(js|jsx)$': isEjecting - ? '/node_modules/babel-jest' - : resolve('config/jest/babelTransform.js'), - '^.+\\.css$': resolve('config/jest/cssTransform.js'), - '^(?!.*\\.(js|jsx|css|json)$)': resolve('config/jest/fileTransform.js'), - }, - transformIgnorePatterns: ['[/\\\\]node_modules[/\\\\].+\\.(js|jsx)$'], + transform: Object.assign( + { + '^.+\\.(js|jsx)$': isEjecting + ? '/node_modules/babel-jest' + : resolve('config/jest/babelTransform.js'), + '^.+\\.css$': resolve('config/jest/cssTransform.js'), + }, + hasTypeScript + ? { '^.+\\.(ts|tsx)$': resolve('config/jest/typescriptTransform.js') } + : {}, + { + '^(?!.*\\.(js|jsx|css|json)$)': resolve('config/jest/fileTransform.js'), + } + ), + transformIgnorePatterns: [ + '[/\\\\]node_modules[/\\\\].+\\.(js|jsx)$', + ...(hasTypeScript ? ['[/\\\\]node_modules[/\\\\].+\\.(ts|tsx)$'] : []), + ], moduleNameMapper: { '^react-native$': 'react-native-web', }, - moduleFileExtensions: ['web.js', 'js', 'json', 'web.jsx', 'jsx', 'node'], + moduleFileExtensions: [ + 'web.js', + 'js', + ...(hasTypeScript ? ['tsx', 'ts'] : []), + 'json', + 'web.jsx', + 'jsx', + 'node', + ], }; if (rootDir) { config.rootDir = rootDir;