rsnext/packages/next-codemod/transforms/cra-to-next.ts
CommanderRoot db2567b01b
chore: replace deprecated String.prototype.substr() (#35421)
.substr() is deprecated so we replace it with .slice() which works similarily but isn't deprecated
Signed-off-by: Tobias Speicher <rootcommander@gmail.com>

Co-authored-by: Steven <steven@ceriously.com>
2022-03-24 17:49:38 -04:00

611 lines
18 KiB
TypeScript

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.slice(1, -1)}}`
}
)}\`}`
}
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.slice(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 (
<>
<Head>
${
titleTag
? `<title${serializeAttrs(titleTag.attribs)}>${gatherTextChildren(
titleTag.children
)}</title>`
: ''
}
${metaViewport ? `<meta${serializeAttrs(metaViewport.attribs)} />` : ''}
</Head>
<Component {...pageProps} />
</>
)`
: 'return <Component {...pageProps} />'
}
}
`
)
await fs.promises.writeFile(
path.join(this.appDir, documentPage),
`import Document, { Html, Head, Main, NextScript } from 'next/document'
class MyDocument extends Document {
render() {
return (
<Html${serializeAttrs($('html').attr())}>
<Head>
${serializedHeadTags.join('\n ')}
</Head>
<body${serializeAttrs($('body').attr())}>
<Main />
<NextScript />
${serializedBodyTags.join('\n ')}
</body>
</Html>
)
}
}
export default MyDocument
`
)
const relativeIndexPath = path.relative(
path.join(this.appDir, this.pagesDir),
path.join(this.appDir, 'src', this.isCra ? '' : 'main')
)
// TODO: should we default to ssr: true below and recommend they
// set it to false if they encounter errors or prefer the more safe
// option to prevent their first start from having any errors?
await fs.promises.writeFile(
path.join(this.appDir, catchAllPage),
`// import NextIndexWrapper from '${relativeIndexPath}'
// next/dynamic is used to prevent breaking incompatibilities
// with SSR from window.SOME_VAR usage, if this is not used
// next/dynamic can be removed to take advantage of SSR/prerendering
import dynamic from 'next/dynamic'
// try changing "ssr" to true below to test for incompatibilities, if
// no errors occur the above static import can be used instead and the
// below removed
const NextIndexWrapper = dynamic(() => import('${relativeIndexPath}'), { ssr: false })
export default function Page(props) {
return <NextIndexWrapper {...props} />
}
`
)
}
this.logCreate(appPage)
this.logCreate(documentPage)
this.logCreate(catchAllPage)
}
private async updatePackageJson() {
// rename react-scripts -> next and react-scripts test -> jest
// add needed dependencies for webpack compatibility
const newDependencies: Array<{
name: string
version: string
}> = [
// TODO: do we want to install jest automatically?
{
name: 'next',
version: 'latest',
},
]
const packageName = this.isCra ? 'react-scripts' : 'vite'
const packagesToRemove = {
[packageName]: undefined,
}
const neededDependencies: string[] = []
const { devDependencies, dependencies, scripts } = this.packageJsonData
for (const dep of newDependencies) {
if (!devDependencies?.[dep.name] && !dependencies?.[dep.name]) {
neededDependencies.push(`${dep.name}@${dep.version}`)
}
}
this.logInfo(
`Installing ${neededDependencies.join(' ')} with ${this.installClient}`
)
if (!this.isDryRun) {
await fs.promises.writeFile(
this.packageJsonPath,
JSON.stringify(
{
...this.packageJsonData,
scripts: Object.keys(scripts).reduce((prev, cur) => {
const command = scripts[cur]
prev[cur] = command
if (command === packageName) {
prev[cur] = 'next dev'
}
if (command.includes(`${packageName} `)) {
prev[cur] = command.replace(
`${packageName} `,
command.includes(`${packageName} test`) ? 'jest ' : 'next '
)
}
if (cur === 'eject') {
prev[cur] = undefined
}
// TODO: do we want to map start -> next start instead of CRA's
// default of mapping starting to dev mode?
if (cur === 'start') {
prev[cur] = prev[cur].replace('next start', 'next dev')
prev['start-production'] = 'next start'
}
return prev
}, {} as { [key: string]: string }),
dependencies: {
...dependencies,
...packagesToRemove,
},
devDependencies: {
...devDependencies,
...packagesToRemove,
},
},
null,
2
)
)
await install(this.appDir, neededDependencies, {
useYarn: this.installClient === 'yarn',
// do we want to detect offline as well? they might not
// have next in the local cache already
isOnline: true,
})
}
}
private async updateGitIgnore() {
// add Next.js specific items to .gitignore e.g. '.next'
const gitignorePath = path.join(this.appDir, '.gitignore')
let ignoreContent = await fs.promises.readFile(gitignorePath, 'utf8')
const nextIgnores = (
await fs.promises.readFile(
path.join(path.dirname(globalCssTransformPath), 'gitignore'),
'utf8'
)
).split('\n')
if (!this.isDryRun) {
for (const ignore of nextIgnores) {
if (!ignoreContent.includes(ignore)) {
ignoreContent += `\n${ignore}`
}
}
await fs.promises.writeFile(gitignorePath, ignoreContent)
}
this.logModify('.gitignore')
}
private async createNextConfig() {
if (!this.isDryRun) {
const { proxy, homepage } = this.packageJsonData
const homepagePath = new URL(homepage || '/', 'http://example.com')
.pathname
await fs.promises.writeFile(
path.join(this.appDir, 'next.config.js'),
`module.exports = {${
proxy
? `
async rewrites() {
return {
fallback: [
{
source: '/:path*',
destination: '${proxy}'
}
]
}
},`
: ''
}
env: {
PUBLIC_URL: '${homepagePath === '/' ? '' : homepagePath || ''}'
},
experimental: {
craCompat: true,
},
// Remove this to leverage Next.js' static image handling
// read more here: https://nextjs.org/docs/api-reference/next/image
images: {
disableStaticImages: true
}
}
`
)
}
this.logCreate('next.config.js')
}
private getPagesDir() {
// prefer src/pages as CRA uses the src dir by default
// and attempt falling back to top-level pages dir
let pagesDir = 'src/pages'
if (fs.existsSync(path.join(this.appDir, pagesDir))) {
pagesDir = 'pages'
}
if (fs.existsSync(path.join(this.appDir, pagesDir))) {
fatalMessage(
`Error: a "./pages" directory already exists, please rename to continue`
)
}
return pagesDir
}
private loadPackageJson() {
let packageJsonData
try {
packageJsonData = JSON.parse(
fs.readFileSync(this.packageJsonPath, 'utf8')
)
} catch (err) {
fatalMessage(
`Error: failed to load package.json from ${this.packageJsonPath}, ensure provided directory is root of CRA project`
)
}
return packageJsonData
}
private validateAppDir(files: string[]) {
if (files.length > 1) {
fatalMessage(
`Error: only one directory should be provided for the cra-to-next transform, received ${files.join(
', '
)}`
)
}
const appDir = path.join(process.cwd(), files[0])
let isValidDirectory = false
try {
isValidDirectory = fs.lstatSync(appDir).isDirectory()
} catch (err) {
// not a valid directory
}
if (!isValidDirectory) {
fatalMessage(
`Error: invalid directory provided for the cra-to-next transform, received ${appDir}`
)
}
return appDir
}
}
export default async function transformer(files, flags) {
try {
const craTransform = new CraTransform(files, flags)
await craTransform.transform()
console.log(`CRA to Next.js migration complete`, `\n${feedbackMessage}`)
} catch (err) {
fatalMessage(`Error: failed to complete transform`, err)
}
}