From 79af273bc6c5c4add8736f44dcf80f25c94d40a7 Mon Sep 17 00:00:00 2001 From: Steven Date: Tue, 11 Oct 2022 16:44:36 -0400 Subject: [PATCH] Add codemods for `next/image` (#41004) This adds two codemods for `next/image`. The first codemod a safe solution to upgrade from 12 to 13 by swapping to `next/legacy/image`. The second codemod is an experimental solution that attempts to automate the [migration guide](https://nextjs.org/docs/api-reference/next/future/image#migration). --- docs/advanced-features/codemods.md | 49 +++++ docs/upgrading.md | 5 + .../next-image-experimental/general.input.js | 33 ++++ .../next-image-experimental/general.output.js | 95 +++++++++ .../dynamic.input.js | 14 ++ .../dynamic.output.js | 14 ++ .../general.input.js | 15 ++ .../general.output.js | 15 ++ .../__tests__/next-image-experimental.test.js | 16 ++ .../next-image-to-legacy-image.test.js | 16 ++ .../transforms/next-image-experimental.ts | 180 ++++++++++++++++++ .../transforms/next-image-to-legacy-image.ts | 68 +++++++ 12 files changed, 520 insertions(+) create mode 100644 packages/next-codemod/transforms/__testfixtures__/next-image-experimental/general.input.js create mode 100644 packages/next-codemod/transforms/__testfixtures__/next-image-experimental/general.output.js create mode 100644 packages/next-codemod/transforms/__testfixtures__/next-image-to-legacy-image/dynamic.input.js create mode 100644 packages/next-codemod/transforms/__testfixtures__/next-image-to-legacy-image/dynamic.output.js create mode 100644 packages/next-codemod/transforms/__testfixtures__/next-image-to-legacy-image/general.input.js create mode 100644 packages/next-codemod/transforms/__testfixtures__/next-image-to-legacy-image/general.output.js create mode 100644 packages/next-codemod/transforms/__tests__/next-image-experimental.test.js create mode 100644 packages/next-codemod/transforms/__tests__/next-image-to-legacy-image.test.js create mode 100644 packages/next-codemod/transforms/next-image-experimental.ts create mode 100644 packages/next-codemod/transforms/next-image-to-legacy-image.ts diff --git a/docs/advanced-features/codemods.md b/docs/advanced-features/codemods.md index f897c89f206ea..ced2bbf5cc5ca 100644 --- a/docs/advanced-features/codemods.md +++ b/docs/advanced-features/codemods.md @@ -17,6 +17,55 @@ Codemods are transformations that run on your codebase programmatically. This al - `--dry` Do a dry-run, no code will be edited - `--print` Prints the changed output for comparison +## Next.js 13 + +### `next-image-to-legacy-image` + +Safely migrates existing Next.js 10, 11, 12 applications importing `next/image` to the renamed `next/legacy/image` import in Next.js 13. + +For example: + +```jsx +import Image1 from 'next/image' +import Image2 from 'next/future/image' + +export default function Home() { + return ( +
+ + +
+ ) +} +``` + +Transforms into: + +```jsx +import Image1 from 'next/legacy/image' +import Image2 from 'next/image' + +export default function Home() { + return ( +
+ + +
+ ) +} +``` + +### `next-image-experimental` (experimental) + +Dangerously migrates from `next/legacy/image` to the new `next/image` by adding inline styles and removing unused props. + +- Removes `layout` prop and adds `style` +- Removes `objectFit` prop and adds `style` +- Removes `objectPosition` prop and adds `style` +- Removes `lazyBoundary` prop +- Removes `lazyRoot` prop +- TODO: handle `loader` + ## Next.js 11 ### `cra-to-next` (experimental) diff --git a/docs/upgrading.md b/docs/upgrading.md index e8c4f5b6cb8cc..97b55cb296e98 100644 --- a/docs/upgrading.md +++ b/docs/upgrading.md @@ -4,6 +4,11 @@ description: Learn how to upgrade Next.js. # Upgrade Guide +## Upgrading from 12 to 13 + +The `next/image` import was renamed to `next/legacy/image`. The `next/future/image` import was renamed to `next/image`. +A [codemod is available](/docs/advanced-features/codemods.md#next-image-to-legacy-image) to safely and automatically rename your imports. + ## Upgrading to 12.2 If you were using Middleware prior to `12.2`, please see the [upgrade guide](https://nextjs.org/docs/messages/middleware-upgrade-guide) for more information. diff --git a/packages/next-codemod/transforms/__testfixtures__/next-image-experimental/general.input.js b/packages/next-codemod/transforms/__testfixtures__/next-image-experimental/general.input.js new file mode 100644 index 0000000000000..125afd7a0a5d9 --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/next-image-experimental/general.input.js @@ -0,0 +1,33 @@ +import Image from "next/legacy/image"; +import Named, { Blarg } from "next/legacy/image"; +import Foo from "foo"; +import img from "../public/img.jpg"; + +export default function Home() { + const myStyle = { color: 'black' }; + return ( +
+

Upgrade

+ + + + +
+ example alt text +
+ + + + + + + + +
+ ); +} \ No newline at end of file diff --git a/packages/next-codemod/transforms/__testfixtures__/next-image-experimental/general.output.js b/packages/next-codemod/transforms/__testfixtures__/next-image-experimental/general.output.js new file mode 100644 index 0000000000000..cd0f66e92011b --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/next-image-experimental/general.output.js @@ -0,0 +1,95 @@ +import Image from "next/image"; +import Named, { Blarg } from "next/image"; +import Foo from "foo"; +import img from "../public/img.jpg"; + +export default function Home() { + const myStyle = { color: 'black' }; + return ( +
+

Upgrade

+ + + + +
+ example alt text +
+ + + + + + + + +
+ ); +} \ No newline at end of file diff --git a/packages/next-codemod/transforms/__testfixtures__/next-image-to-legacy-image/dynamic.input.js b/packages/next-codemod/transforms/__testfixtures__/next-image-to-legacy-image/dynamic.input.js new file mode 100644 index 0000000000000..37ddd93dae0a0 --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/next-image-to-legacy-image/dynamic.input.js @@ -0,0 +1,14 @@ +export async function Home() { + const Image = await import("next/image"); + const Named = await import("next/image"); + const Foo = await import("foo"); + const Future1 = await import("next/future/image"); + const Future2 = await import("next/future/image"); + return (
+

Both

+ + + + +
) +} \ No newline at end of file diff --git a/packages/next-codemod/transforms/__testfixtures__/next-image-to-legacy-image/dynamic.output.js b/packages/next-codemod/transforms/__testfixtures__/next-image-to-legacy-image/dynamic.output.js new file mode 100644 index 0000000000000..c3e96f4d08951 --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/next-image-to-legacy-image/dynamic.output.js @@ -0,0 +1,14 @@ +export async function Home() { + const Image = await import("next/legacy/image"); + const Named = await import("next/legacy/image"); + const Foo = await import("foo"); + const Future1 = await import("next/image"); + const Future2 = await import("next/image"); + return (
+

Both

+ + + + +
) +} \ No newline at end of file diff --git a/packages/next-codemod/transforms/__testfixtures__/next-image-to-legacy-image/general.input.js b/packages/next-codemod/transforms/__testfixtures__/next-image-to-legacy-image/general.input.js new file mode 100644 index 0000000000000..b5648fd6c9c94 --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/next-image-to-legacy-image/general.input.js @@ -0,0 +1,15 @@ +import Image from "next/image"; +import Named from "next/image"; +import Foo from "foo"; +import Future1 from "next/future/image"; +import Future2 from "next/future/image"; + +export default function Home() { + return (
+

Both

+ + + + +
) +} \ No newline at end of file diff --git a/packages/next-codemod/transforms/__testfixtures__/next-image-to-legacy-image/general.output.js b/packages/next-codemod/transforms/__testfixtures__/next-image-to-legacy-image/general.output.js new file mode 100644 index 0000000000000..0dba37efd5342 --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/next-image-to-legacy-image/general.output.js @@ -0,0 +1,15 @@ +import Image from "next/legacy/image"; +import Named from "next/legacy/image"; +import Foo from "foo"; +import Future1 from "next/image"; +import Future2 from "next/image"; + +export default function Home() { + return (
+

Both

+ + + + +
) +} \ No newline at end of file diff --git a/packages/next-codemod/transforms/__tests__/next-image-experimental.test.js b/packages/next-codemod/transforms/__tests__/next-image-experimental.test.js new file mode 100644 index 0000000000000..4da9cb5772871 --- /dev/null +++ b/packages/next-codemod/transforms/__tests__/next-image-experimental.test.js @@ -0,0 +1,16 @@ +/* global jest */ +jest.autoMockOff() +const defineTest = require('jscodeshift/dist/testUtils').defineTest +const { readdirSync } = require('fs') +const { join } = require('path') + +const fixtureDir = 'next-image-experimental' +const fixtureDirPath = join(__dirname, '..', '__testfixtures__', fixtureDir) +const fixtures = readdirSync(fixtureDirPath) + .filter(file => file.endsWith('.input.js')) + .map(file => file.replace('.input.js', '')) + +for (const fixture of fixtures) { + const prefix = `${fixtureDir}/${fixture}`; + defineTest(__dirname, fixtureDir, null, prefix) +} diff --git a/packages/next-codemod/transforms/__tests__/next-image-to-legacy-image.test.js b/packages/next-codemod/transforms/__tests__/next-image-to-legacy-image.test.js new file mode 100644 index 0000000000000..35b9828665300 --- /dev/null +++ b/packages/next-codemod/transforms/__tests__/next-image-to-legacy-image.test.js @@ -0,0 +1,16 @@ +/* global jest */ +jest.autoMockOff() +const defineTest = require('jscodeshift/dist/testUtils').defineTest +const { readdirSync } = require('fs') +const { join } = require('path') + +const fixtureDir = 'next-image-to-legacy-image' +const fixtureDirPath = join(__dirname, '..', '__testfixtures__', fixtureDir) +const fixtures = readdirSync(fixtureDirPath) + .filter(file => file.endsWith('.input.js')) + .map(file => file.replace('.input.js', '')) + +for (const fixture of fixtures) { + const prefix = `${fixtureDir}/${fixture}`; + defineTest(__dirname, fixtureDir, null, prefix) +} diff --git a/packages/next-codemod/transforms/next-image-experimental.ts b/packages/next-codemod/transforms/next-image-experimental.ts new file mode 100644 index 0000000000000..2d3d2e54b1c22 --- /dev/null +++ b/packages/next-codemod/transforms/next-image-experimental.ts @@ -0,0 +1,180 @@ +import type { + API, + FileInfo, + ImportDefaultSpecifier, + JSXAttribute, + Options, +} from 'jscodeshift' + +export default function transformer( + file: FileInfo, + api: API, + options: Options +) { + const j = api.jscodeshift + const root = j(file.source) + + // Before: import Image from "next/legacy/image" + // After: import Image from "next/image" + root + .find(j.ImportDeclaration, { + source: { value: 'next/legacy/image' }, + }) + .forEach((imageImport) => { + const defaultSpecifier = imageImport.node.specifiers?.find( + (node) => node.type === 'ImportDefaultSpecifier' + ) as ImportDefaultSpecifier | undefined + const defaultSpecifierName = defaultSpecifier?.local?.name + + j(imageImport).replaceWith( + j.importDeclaration( + imageImport.node.specifiers, + j.stringLiteral('next/image') + ) + ) + + const layoutToStyle: Record | null> = { + intrinsic: { maxWidth: '100%', height: 'auto' }, + responsive: { width: '100%', height: 'auto' }, + fill: null, + fixed: null, + } + const layoutToSizes: Record = { + intrinsic: null, + responsive: '100vw', + fill: '100vw', + fixed: null, + } + root + .find(j.JSXElement) + .filter( + (el) => + el.value.openingElement.name && + el.value.openingElement.name.type === 'JSXIdentifier' && + el.value.openingElement.name.name === defaultSpecifierName + ) + .forEach((el) => { + let layout = 'intrisic' + let objectFit = null + let objectPosition = null + let styleExpProps = [] + let sizesAttr: JSXAttribute | null = null + const attributes = el.node.openingElement.attributes?.filter((a) => { + if (a.type !== 'JSXAttribute') { + return true + } + // TODO: hanlde case when not Literal + if (a.value?.type === 'Literal') { + if (a.name.name === 'layout') { + layout = String(a.value.value) + return false + } + if (a.name.name === 'objectFit') { + objectFit = String(a.value.value) + return false + } + if (a.name.name === 'objectPosition') { + objectPosition = String(a.value.value) + return false + } + } + if (a.name.name === 'style') { + if ( + a.value?.type === 'JSXExpressionContainer' && + a.value.expression.type === 'ObjectExpression' + ) { + styleExpProps = a.value.expression.properties + } else if ( + a.value?.type === 'JSXExpressionContainer' && + a.value.expression.type === 'Identifier' + ) { + styleExpProps = [ + j.spreadElement(j.identifier(a.value.expression.name)), + ] + } else { + console.warn('Unknown style attribute value detected', a.value) + } + return false + } + if (a.name.name === 'sizes') { + sizesAttr = a + return false + } + if (a.name.name === 'lazyBoundary') { + return false + } + if (a.name.name === 'lazyRoot') { + return false + } + return true + }) + + if (layout === 'fill') { + attributes.push(j.jsxAttribute(j.jsxIdentifier('fill'))) + } + + const sizes = layoutToSizes[layout] + if (sizes && !sizesAttr) { + sizesAttr = j.jsxAttribute( + j.jsxIdentifier('sizes'), + j.literal(sizes) + ) + } + + if (sizesAttr) { + attributes.push(sizesAttr) + } + + let style = layoutToStyle[layout] + if (style || objectFit || objectPosition) { + if (!style) { + style = {} + } + if (objectFit) { + style.objectFit = objectFit + } + if (objectPosition) { + style.objectPosition = objectPosition + } + Object.entries(style).forEach(([key, value]) => { + styleExpProps.push( + j.objectProperty(j.identifier(key), j.stringLiteral(value)) + ) + }) + const styleAttribute = j.jsxAttribute( + j.jsxIdentifier('style'), + j.jsxExpressionContainer(j.objectExpression(styleExpProps)) + ) + attributes.push(styleAttribute) + } + + // TODO: should we add `alt=""` attribute? + // We should probably let the use it manually. + + j(el).replaceWith( + j.jsxElement( + j.jsxOpeningElement( + el.node.openingElement.name, + attributes, + el.node.openingElement.selfClosing + ), + el.node.closingElement, + el.node.children + ) + ) + }) + }) + // Before: const Image = await import("next/legacy/image") + // After: const Image = await import("next/image") + root + .find(j.ImportExpression, { + source: { value: 'next/legacy/image' }, + }) + .forEach((imageImport) => { + j(imageImport).replaceWith( + j.importExpression(j.stringLiteral('next/image')) + ) + }) + // TODO: do the same transforms for dynamic imports + return root.toSource(options) +} diff --git a/packages/next-codemod/transforms/next-image-to-legacy-image.ts b/packages/next-codemod/transforms/next-image-to-legacy-image.ts new file mode 100644 index 0000000000000..4fdd7ef7b4dac --- /dev/null +++ b/packages/next-codemod/transforms/next-image-to-legacy-image.ts @@ -0,0 +1,68 @@ +import type { API, FileInfo, Options } from 'jscodeshift' + +export default function transformer( + file: FileInfo, + api: API, + options: Options +) { + const j = api.jscodeshift + const root = j(file.source) + + // Before: import Image from "next/image" + // After: import Image from "next/legacy/image" + root + .find(j.ImportDeclaration, { + source: { value: 'next/image' }, + }) + .forEach((imageImport) => { + j(imageImport).replaceWith( + j.importDeclaration( + imageImport.node.specifiers, + j.stringLiteral('next/legacy/image') + ) + ) + }) + // Before: const Image = await import("next/image") + // After: const Image = await import("next/legacy/image") + root + .find(j.ImportExpression, { + source: { value: 'next/image' }, + }) + .forEach((imageImport) => { + j(imageImport).replaceWith( + j.importExpression(j.stringLiteral('next/legacy/image')) + ) + }) + + // Before: import Image from "next/future/image" + // After: import Image from "next/image" + root + .find(j.ImportDeclaration, { + source: { value: 'next/future/image' }, + }) + .forEach((imageFutureImport) => { + j(imageFutureImport).replaceWith( + j.importDeclaration( + imageFutureImport.node.specifiers, + j.stringLiteral('next/image') + ) + ) + }) + + // Before: const Image = await import("next/future/image") + // After: const Image = await import("next/image") + root + .find(j.ImportExpression, { + source: { value: 'next/future/image' }, + }) + .forEach((imageFutureImport) => { + j(imageFutureImport).replaceWith( + j.importExpression(j.stringLiteral('next/image')) + ) + }) + + // Learn more about renaming an import declaration here: + // https://www.codeshiftcommunity.com/docs/import-manipulation/#replacerename-an-import-declaration + + return root.toSource(options) +}