import fs from 'fs' import path from 'path' import execa from 'execa' import globby from 'globby' import cheerio from 'cheerio' import { install } from '../lib/install' import runJscodeshift from '../lib/run-jscodeshift' import htmlToReactAttributes from '../lib/html-to-react-attributes' import { indexContext } from '../lib/cra-to-next/index-to-component' import { globalCssContext } from '../lib/cra-to-next/global-css-transform' const feedbackMessage = `Please share any feedback on the migration here: https://github.com/vercel/next.js/discussions/25858` // log error and exit without new stacktrace function fatalMessage(...logs) { console.error(...logs, `\n${feedbackMessage}`) process.exit(1) } const craTransformsPath = path.join('../lib/cra-to-next') const globalCssTransformPath = require.resolve( path.join(craTransformsPath, 'global-css-transform.js') ) const indexTransformPath = require.resolve( path.join(craTransformsPath, 'index-to-component.js') ) class CraTransform { private appDir: string private pagesDir: string private isVite: boolean private isCra: boolean private isDryRun: boolean private indexPage: string private installClient: string private shouldLogInfo: boolean private packageJsonPath: string private shouldUseTypeScript: boolean private packageJsonData: { [key: string]: any } private jscodeShiftFlags: { [key: string]: boolean } constructor(files: string[], flags: { [key: string]: boolean }) { this.isDryRun = flags.dry this.jscodeShiftFlags = flags this.appDir = this.validateAppDir(files) this.packageJsonPath = path.join(this.appDir, 'package.json') this.packageJsonData = this.loadPackageJson() this.shouldLogInfo = flags.print || flags.dry this.pagesDir = this.getPagesDir() this.installClient = this.checkForYarn() ? 'yarn' : 'npm' const { dependencies, devDependencies } = this.packageJsonData const hasDep = (dep) => dependencies?.[dep] || devDependencies?.[dep] this.isCra = hasDep('react-scripts') this.isVite = !this.isCra && hasDep('vite') if (!this.isCra && !this.isVite) { fatalMessage( `Error: react-scripts was not detected, is this a CRA project?` ) } this.shouldUseTypeScript = fs.existsSync(path.join(this.appDir, 'tsconfig.json')) || globby.sync('src/**/*.{ts,tsx}', { cwd: path.join(this.appDir, 'src'), }).length > 0 this.indexPage = globby.sync( [`${this.isCra ? 'index' : 'main'}.{js,jsx,ts,tsx}`], { cwd: path.join(this.appDir, 'src'), } )[0] if (!this.indexPage) { fatalMessage('Error: unable to find `src/index`') } } public async transform() { console.log('Transforming CRA project at:', this.appDir) // convert src/index.js to a react component to render // inside of Next.js instead of the custom render root const indexTransformRes = await runJscodeshift( indexTransformPath, { ...this.jscodeShiftFlags, silent: true, verbose: 0 }, [path.join(this.appDir, 'src', this.indexPage)] ) if (indexTransformRes.error > 0) { fatalMessage( `Error: failed to apply transforms for src/${this.indexPage}, please check for syntax errors to continue` ) } if (indexContext.multipleRenderRoots) { fatalMessage( `Error: multiple ReactDOM.render roots in src/${this.indexPage}, migrate additional render roots to use portals instead to continue.\n` + `See here for more info: https://reactjs.org/docs/portals.html` ) } if (indexContext.nestedRender) { fatalMessage( `Error: nested ReactDOM.render found in src/${this.indexPage}, please migrate this to a top-level render (no wrapping functions) to continue` ) } // comment out global style imports and collect them // so that we can add them to _app const globalCssRes = await runJscodeshift( globalCssTransformPath, { ...this.jscodeShiftFlags }, [this.appDir] ) if (globalCssRes.error > 0) { fatalMessage( `Error: failed to apply transforms for src/${this.indexPage}, please check for syntax errors to continue` ) } if (!this.isDryRun) { await fs.promises.mkdir(path.join(this.appDir, this.pagesDir)) } this.logCreate(this.pagesDir) if (globalCssContext.reactSvgImports.size > 0) { // This de-opts webpack 5 since svg/webpack doesn't support webpack 5 yet, // so we don't support this automatically fatalMessage( `Error: import {ReactComponent} from './logo.svg' is not supported, please use normal SVG imports to continue.\n` + `React SVG imports found in:\n${[ ...globalCssContext.reactSvgImports, ].join('\n')}` ) } await this.updatePackageJson() await this.createNextConfig() await this.updateGitIgnore() await this.createPages() } private checkForYarn() { try { const userAgent = process.env.npm_config_user_agent if (userAgent) { return Boolean(userAgent && userAgent.startsWith('yarn')) } execa.sync('yarnpkg', ['--version'], { stdio: 'ignore' }) return true } catch (e) { console.log('error', e) return false } } private logCreate(...args: any[]) { if (this.shouldLogInfo) { console.log('Created:', ...args) } } private logModify(...args: any[]) { if (this.shouldLogInfo) { console.log('Modified:', ...args) } } private logInfo(...args: any[]) { if (this.shouldLogInfo) { console.log(...args) } } private async createPages() { // load public/index.html and add tags to _document const htmlContent = await fs.promises.readFile( path.join(this.appDir, `${this.isCra ? 'public/' : ''}index.html`), 'utf8' ) const $ = cheerio.load(htmlContent) // note: title tag and meta[viewport] needs to be placed in _app // not _document const titleTag = $('title')[0] const metaViewport = $('meta[name="viewport"]')[0] const headTags = $('head').children() const bodyTags = $('body').children() const pageExt = this.shouldUseTypeScript ? 'tsx' : 'js' const appPage = path.join(this.pagesDir, `_app.${pageExt}`) const documentPage = path.join(this.pagesDir, `_document.${pageExt}`) const catchAllPage = path.join(this.pagesDir, `[[...slug]].${pageExt}`) const gatherTextChildren = (children: CheerioElement[]) => { return children .map((child) => { if (child.type === 'text') { return child.data } return '' }) .join('') } const serializeAttrs = (attrs: CheerioElement['attribs']) => { const attrStr = Object.keys(attrs || {}) .map((name) => { const reactName = htmlToReactAttributes[name] || name const value = attrs[name] // allow process.env access to work dynamically still if (value.match(/%([a-zA-Z0-9_]{0,})%/)) { return `${reactName}={\`${value.replace( /%([a-zA-Z0-9_]{0,})%/g, (subStr) => { return `\${process.env.${subStr.substr(1, subStr.length - 2)}}` } )}\`}` } return `${reactName}="${value}"` }) .join(' ') return attrStr.length > 0 ? ` ${attrStr}` : '' } const serializedHeadTags: string[] = [] const serializedBodyTags: string[] = [] headTags.map((_index, element) => { if ( element.tagName === 'title' || (element.tagName === 'meta' && element.attribs.name === 'viewport') ) { return element } let hasChildren = element.children.length > 0 let serializedAttrs = serializeAttrs(element.attribs) if (element.tagName === 'script' || element.tagName === 'style') { hasChildren = false serializedAttrs += ` dangerouslySetInnerHTML={{ __html: \`${gatherTextChildren( element.children ).replace(/`/g, '\\`')}\` }}` } serializedHeadTags.push( hasChildren ? `<${element.tagName}${serializedAttrs}>${gatherTextChildren( element.children )}${element.tagName}>` : `<${element.tagName}${serializedAttrs} />` ) return element }) bodyTags.map((_index, element) => { if (element.tagName === 'div' && element.attribs.id === 'root') { return element } let hasChildren = element.children.length > 0 let serializedAttrs = serializeAttrs(element.attribs) if (element.tagName === 'script' || element.tagName === 'style') { hasChildren = false serializedAttrs += ` dangerouslySetInnerHTML={{ __html: \`${gatherTextChildren( element.children ).replace(/`/g, '\\`')}\` }}` } serializedHeadTags.push( hasChildren ? `<${element.tagName}${serializedAttrs}>${gatherTextChildren( element.children )}${element.tagName}>` : `<${element.tagName}${serializedAttrs} />` ) return element }) if (!this.isDryRun) { await fs.promises.writeFile( path.join(this.appDir, appPage), `${ globalCssContext.cssImports.size === 0 ? '' : [...globalCssContext.cssImports] .map((file) => { if (!this.isCra) { file = file.startsWith('/') ? file.substr(1) : file } return `import '${ file.startsWith('/') ? path.relative( path.join(this.appDir, this.pagesDir), file ) : file }'` }) .join('\n') + '\n' }${titleTag ? `import Head from 'next/head'` : ''} export default function MyApp({ Component, pageProps}) { ${ titleTag || metaViewport ? `return ( <>
${ titleTag ? `