rsnext/packages/next-codemod/bin/cli.ts
Tim Neutkens 489e65ed98
Rework <Link> behavior (backwards compatible) (#36436)
Fixes https://github.com/vercel/next.js/discussions/32233

⚠️ If you're looking at this PR please read the complete description including the part about incremental adoption.

## TLDR:

Official support for `<Link href="/about">About</Link>` / `<Link href="/about"><CustomComponent /></Link>` / `<Link href="/about"><strong>About</strong></Link>` where `<Link>` always renders `<a>` without edge cases where it doesn’t render `<a>`. You'll no longer have to put an empty `<a>` in `<Link>` with this enabled.

## Full context

### Changes to `<Link>`

- Added an `legacyBehavior` prop that defaults to `true` to preserve the defaults we have today, this will allow to run a codemod on existing codebases to move them to the version where `legacyBehavior` becomes `false` by default
- When using the new behavior `<Link>` always renders an `<a>` instead of having `React.cloneElement` and passing props onto a child element
- When using the new behavior props that can be passed to `<a>` can be passed to `<Link>`. Previously you could do something like `<Link href="/somewhere"><a target="_blank">Download</a></Link>` but with `<Link>` rendering `<a>` it now allows these props to be set on link. E.g. `<Link href="/somewhere" target="_blank"></Link>` / `<Link href="/somewhere" className="link"></Link>`

### Incremental Adoption / Codemod

The main reason we haven't made these changes before is that it breaks pretty much all Next.js apps, which is why I've been hesitant to make this change in the past. I've spent a bunch of time figuring out what the right approach is to rolling this out and ended up with an approach that requires existing apps to run a codemod that automatically opts their `<Link>` usage into the old behavior in order to keep the app functioning.

This codemod will auto-fix the usage where possible. For example: 

- When you have `<Link href="/about"><a>About</a></Link>` it'll auto-fix to `<Link href="/about">About</Link>`
- When you have `<Link href="/about"><a onClick={() => console.log('clicked')}>About</a></Link>` it'll auto-fix to `<Link href="/about" onClick={() => console.log('clicked')}>About</Link>`
- For cases where auto-fixing can't be applied the `legacyBehavior` prop is added. When you have `<Link href="/about"><Component /></Link>` it'll transform to `<Link href="/about" legacyBehavior><Component /></Link>` so that your app keeps functioning using the old behavior for that particular link. It's then up to the dev to move that case out of the `legacyBehavior` prop.


**This default will be changed in Next.js 13, it does not affect existing apps in Next.js 12 unless opted in via `experimental.newLinkBehavior` and running the codemod.**

Some code samples of what changed:

```jsx
const CustomComponent = () => <strong>Hello</strong>

// Legacy behavior: `<a>` has to be nested otherwise it's excluded

// Renders: <a href="/about">About</a>. `<a>` has to be nested.
<Link href="/about">
  <a>About</a>  
</Link>

// Renders: <strong onClick={nextLinkClickHandler}>Hello</strong>. No `<a>` is included.
<Link href="/about">
  <strong>Hello</strong>
</Link>


// Renders: <strong onClick={nextLinkClickHandler}>Hello</strong>. No `<a>` is included.
<Link href="/about">
  <CustomComponent />
</Link>

// --------------------------------------------------
// New behavior: `<Link>` always renders `<a>`

// Renders: <a href="/about">About</a>. `<a>` no longer has to be nested.
<Link href="/about">
  About
</Link>

// Renders: <a href="/about"><strong>Hello</strong></a>. `<a>` is included.
<Link href="/about">
  <strong>Hello</strong>
</Link>

// Renders: <a href="/about"><strong>Hello</strong></a>. `<a>` is included.
<Link href="/about">
  <CustomComponent />
</Link>
```

---


## Feature

- [x] Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR.
- [x] Related issues linked using `fixes #number`
- [x] Integration tests added
- [x] Errors have helpful link attached, see `contributing.md`


Co-authored-by: JJ Kasper <22380829+ijjk@users.noreply.github.com>
2022-04-25 22:01:30 +00:00

215 lines
6.1 KiB
TypeScript

/**
* Copyright 2015-present, Facebook, Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
// Based on https://github.com/reactjs/react-codemod/blob/dd8671c9a470a2c342b221ec903c574cf31e9f57/bin/cli.js
// @next/codemod optional-name-of-transform optional/path/to/src [...options]
import globby from 'globby'
import inquirer from 'inquirer'
import meow from 'meow'
import path from 'path'
import execa from 'execa'
import chalk from 'chalk'
import isGitClean from 'is-git-clean'
export const jscodeshiftExecutable = require.resolve('.bin/jscodeshift')
export const transformerDirectory = path.join(__dirname, '../', 'transforms')
export function checkGitStatus(force) {
let clean = false
let errorMessage = 'Unable to determine if git directory is clean'
try {
clean = isGitClean.sync(process.cwd())
errorMessage = 'Git directory is not clean'
} catch (err) {
if (err && err.stderr && err.stderr.indexOf('Not a git repository') >= 0) {
clean = true
}
}
if (!clean) {
if (force) {
console.log(`WARNING: ${errorMessage}. Forcibly continuing.`)
} else {
console.log('Thank you for using @next/codemod!')
console.log(
chalk.yellow(
'\nBut before we continue, please stash or commit your git changes.'
)
)
console.log(
'\nYou may use the --force flag to override this safety check.'
)
process.exit(1)
}
}
}
export function runTransform({ files, flags, transformer }) {
const transformerPath = path.join(transformerDirectory, `${transformer}.js`)
if (transformer === 'cra-to-next') {
// cra-to-next transform doesn't use jscodeshift directly
return require(transformerPath).default(files, flags)
}
let args = []
const { dry, print, runInBand } = flags
if (dry) {
args.push('--dry')
}
if (print) {
args.push('--print')
}
if (runInBand) {
args.push('--run-in-band')
}
args.push('--verbose=2')
args.push('--ignore-pattern=**/node_modules/**')
args.push('--ignore-pattern=**/.next/**')
args.push('--extensions=tsx,ts,jsx,js')
args.push('--parser=tsx')
args = args.concat(['--transform', transformerPath])
if (flags.jscodeshift) {
args = args.concat(flags.jscodeshift)
}
args = args.concat(files)
console.log(`Executing command: jscodeshift ${args.join(' ')}`)
const result = execa.sync(jscodeshiftExecutable, args, {
stdio: 'inherit',
stripFinalNewline: false,
})
if (result.failed) {
throw new Error(`jscodeshift exited with code ${result.exitCode}`)
}
}
const TRANSFORMER_INQUIRER_CHOICES = [
{
name: 'name-default-component: Transforms anonymous components into named components to make sure they work with Fast Refresh',
value: 'name-default-component',
},
{
name: 'add-missing-react-import: Transforms files that do not import `React` to include the import in order for the new React JSX transform',
value: 'add-missing-react-import',
},
{
name: 'withamp-to-config: Transforms the withAmp HOC into Next.js 9 page configuration',
value: 'withamp-to-config',
},
{
name: 'url-to-withrouter: Transforms the deprecated automatically injected url property on top level pages to using withRouter',
value: 'url-to-withrouter',
},
{
name: 'cra-to-next (experimental): automatically migrates a Create React App project to Next.js',
value: 'cra-to-next',
},
{
name: 'new-link: Ensures your <Link> usage is backwards compatible. Used in combination with experimental newNextLinkBehavior',
value: 'new-link',
},
]
function expandFilePathsIfNeeded(filesBeforeExpansion) {
const shouldExpandFiles = filesBeforeExpansion.some((file) =>
file.includes('*')
)
return shouldExpandFiles
? globby.sync(filesBeforeExpansion)
: filesBeforeExpansion
}
export function run() {
const cli = meow({
description: 'Codemods for updating Next.js apps.',
help: `
Usage
$ npx @next/codemod <transform> <path> <...options>
transform One of the choices from https://github.com/vercel/next.js/tree/canary/packages/next-codemod
path Files or directory to transform. Can be a glob like pages/**.js
Options
--force Bypass Git safety checks and forcibly run codemods
--dry Dry run (no changes are made to files)
--print Print transformed files to your terminal
--jscodeshift (Advanced) Pass options directly to jscodeshift
`,
flags: {
boolean: ['force', 'dry', 'print', 'help'],
string: ['_'],
alias: {
h: 'help',
},
},
} as meow.Options<meow.AnyFlags>)
if (!cli.flags.dry) {
checkGitStatus(cli.flags.force)
}
if (
cli.input[0] &&
!TRANSFORMER_INQUIRER_CHOICES.find((x) => x.value === cli.input[0])
) {
console.error('Invalid transform choice, pick one of:')
console.error(
TRANSFORMER_INQUIRER_CHOICES.map((x) => '- ' + x.value).join('\n')
)
process.exit(1)
}
inquirer
.prompt([
{
type: 'input',
name: 'files',
message: 'On which files or directory should the codemods be applied?',
when: !cli.input[1],
default: '.',
// validate: () =>
filter: (files) => files.trim(),
},
{
type: 'list',
name: 'transformer',
message: 'Which transform would you like to apply?',
when: !cli.input[0],
pageSize: TRANSFORMER_INQUIRER_CHOICES.length,
choices: TRANSFORMER_INQUIRER_CHOICES,
},
])
.then((answers) => {
const { files, transformer } = answers
const filesBeforeExpansion = cli.input[1] || files
const filesExpanded = expandFilePathsIfNeeded([filesBeforeExpansion])
const selectedTransformer = cli.input[0] || transformer
if (!filesExpanded.length) {
console.log(`No files found matching ${filesBeforeExpansion.join(' ')}`)
return null
}
return runTransform({
files: filesExpanded,
flags: cli.flags,
transformer: selectedTransformer,
})
})
}