diff --git a/docs/advanced-features/codemods.md b/docs/advanced-features/codemods.md index 512872ed3ea77..a19777c251cba 100644 --- a/docs/advanced-features/codemods.md +++ b/docs/advanced-features/codemods.md @@ -88,7 +88,7 @@ Dangerously migrates from `next/legacy/image` to the new `next/image` by adding - Removes `objectPosition` prop and adds `style` - Removes `lazyBoundary` prop - Removes `lazyRoot` prop -- TODO: does not migrate the `loader` config. If you need it, you must manually add a `loader` prop. +- Changes next.config.js `loader` to "custom", removes `path`, and sets `loaderFile` to a new file. #### Before: intrinsic diff --git a/packages/next-codemod/transforms/__testfixtures__/next-image-experimental-loader/akamai/input/next.config.js b/packages/next-codemod/transforms/__testfixtures__/next-image-experimental-loader/akamai/input/next.config.js new file mode 100644 index 0000000000000..3f91075b744be --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/next-image-experimental-loader/akamai/input/next.config.js @@ -0,0 +1,6 @@ +module.exports = { + images: { + loader: "akamai", + path: "https://example.com/", + }, +} \ No newline at end of file diff --git a/packages/next-codemod/transforms/__testfixtures__/next-image-experimental-loader/akamai/output/akamai-loader.js b/packages/next-codemod/transforms/__testfixtures__/next-image-experimental-loader/akamai/output/akamai-loader.js new file mode 100644 index 0000000000000..555068c92b5ee --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/next-image-experimental-loader/akamai/output/akamai-loader.js @@ -0,0 +1,4 @@ +const normalizeSrc = (src) => src[0] === '/' ? src.slice(1) : src +export default function akamaiLoader({ src, width, quality }) { +return 'https://example.com/' + normalizeSrc(src) + '?imwidth=' + width +} \ No newline at end of file diff --git a/packages/next-codemod/transforms/__testfixtures__/next-image-experimental-loader/akamai/output/next.config.js b/packages/next-codemod/transforms/__testfixtures__/next-image-experimental-loader/akamai/output/next.config.js new file mode 100644 index 0000000000000..a6f03e360cfac --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/next-image-experimental-loader/akamai/output/next.config.js @@ -0,0 +1,6 @@ +module.exports = { + images: { + loader: "custom", + loaderFile: "./akamai-loader.js", + }, +} \ No newline at end of file diff --git a/packages/next-codemod/transforms/__testfixtures__/next-image-experimental-loader/cloudinary/input/next.config.js b/packages/next-codemod/transforms/__testfixtures__/next-image-experimental-loader/cloudinary/input/next.config.js new file mode 100644 index 0000000000000..089eb8205f339 --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/next-image-experimental-loader/cloudinary/input/next.config.js @@ -0,0 +1,6 @@ +module.exports = { + images: { + loader: "cloudinary", + path: "https://example.com/", + }, +} \ No newline at end of file diff --git a/packages/next-codemod/transforms/__testfixtures__/next-image-experimental-loader/cloudinary/output/cloudinary-loader.js b/packages/next-codemod/transforms/__testfixtures__/next-image-experimental-loader/cloudinary/output/cloudinary-loader.js new file mode 100644 index 0000000000000..f0ac0c3209d6b --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/next-image-experimental-loader/cloudinary/output/cloudinary-loader.js @@ -0,0 +1,6 @@ +const normalizeSrc = (src) => src[0] === '/' ? src.slice(1) : src +export default function cloudinaryLoader({ src, width, quality }) { +const params = ['f_auto', 'c_limit', 'w_' + width, 'q_' + (quality || 'auto')] +const paramsString = params.join(',') + '/' +return 'https://example.com/' + paramsString + normalizeSrc(src) +} \ No newline at end of file diff --git a/packages/next-codemod/transforms/__testfixtures__/next-image-experimental-loader/cloudinary/output/next.config.js b/packages/next-codemod/transforms/__testfixtures__/next-image-experimental-loader/cloudinary/output/next.config.js new file mode 100644 index 0000000000000..27ea821d47e81 --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/next-image-experimental-loader/cloudinary/output/next.config.js @@ -0,0 +1,6 @@ +module.exports = { + images: { + loader: "custom", + loaderFile: "./cloudinary-loader.js", + }, +} \ No newline at end of file diff --git a/packages/next-codemod/transforms/__testfixtures__/next-image-experimental-loader/imgix/input/next.config.js b/packages/next-codemod/transforms/__testfixtures__/next-image-experimental-loader/imgix/input/next.config.js new file mode 100644 index 0000000000000..8c8e249cb946b --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/next-image-experimental-loader/imgix/input/next.config.js @@ -0,0 +1,6 @@ +module.exports = { + images: { + loader: "imgix", + path: "https://example.com/", + }, +} \ No newline at end of file diff --git a/packages/next-codemod/transforms/__testfixtures__/next-image-experimental-loader/imgix/output/imgix-loader.js b/packages/next-codemod/transforms/__testfixtures__/next-image-experimental-loader/imgix/output/imgix-loader.js new file mode 100644 index 0000000000000..22468f5a87f5d --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/next-image-experimental-loader/imgix/output/imgix-loader.js @@ -0,0 +1,10 @@ +const normalizeSrc = (src) => src[0] === '/' ? src.slice(1) : src +export default function imgixLoader({ src, width, quality }) { +const url = new URL('https://example.com/' + normalizeSrc(src)) +const params = url.searchParams +params.set('auto', params.getAll('auto').join(',') || 'format') +params.set('fit', params.get('fit') || 'max') +params.set('w', params.get('w') || width.toString()) +if (quality) { params.set('q', quality.toString()) } +return url.href +} \ No newline at end of file diff --git a/packages/next-codemod/transforms/__testfixtures__/next-image-experimental-loader/imgix/output/next.config.js b/packages/next-codemod/transforms/__testfixtures__/next-image-experimental-loader/imgix/output/next.config.js new file mode 100644 index 0000000000000..1304e43f493a1 --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/next-image-experimental-loader/imgix/output/next.config.js @@ -0,0 +1,6 @@ +module.exports = { + images: { + loader: "custom", + loaderFile: "./imgix-loader.js", + }, +} \ No newline at end of file diff --git a/packages/next-codemod/transforms/__tests__/next-image-experimental-loader.test.js b/packages/next-codemod/transforms/__tests__/next-image-experimental-loader.test.js new file mode 100644 index 0000000000000..e783c2fc5589a --- /dev/null +++ b/packages/next-codemod/transforms/__tests__/next-image-experimental-loader.test.js @@ -0,0 +1,34 @@ +/* global jest */ +jest.autoMockOff() +const Runner = require('jscodeshift/dist/Runner'); +const { cp, mkdir, rm, readdir, readFile } = require('fs/promises') +const { join } = require('path') + +const fixtureDir = join(__dirname, '..', '__testfixtures__', 'next-image-experimental-loader') +const transform = join(__dirname, '..', 'next-image-experimental.js') +const opts = { recursive: true } + +async function toObj(dir) { + const obj = {} + const files = await readdir(dir) + for (const file of files) { + obj[file] = await readFile(join(dir, file), 'utf8') + } + return obj +} +it.each(['imgix', 'cloudinary', 'akamai'])('should transform loader %s', async (loader) => { + try { + await mkdir(join(fixtureDir, 'tmp'), opts) + await cp(join(fixtureDir, loader, 'input'), join(fixtureDir, 'tmp'), opts) + process.chdir(join(fixtureDir, 'tmp')) + const result = await Runner.run(transform, [`.`], {}) + expect(result.error).toBe(0) + expect( + await toObj(join(fixtureDir, 'tmp')) + ).toStrictEqual( + await toObj(join(fixtureDir, loader, 'output')) + ) + } finally { + await rm(join(fixtureDir, 'tmp'), opts) + } +}) \ No newline at end of file diff --git a/packages/next-codemod/transforms/next-image-experimental.ts b/packages/next-codemod/transforms/next-image-experimental.ts index 1514c8243424a..e787164962614 100644 --- a/packages/next-codemod/transforms/next-image-experimental.ts +++ b/packages/next-codemod/transforms/next-image-experimental.ts @@ -1,3 +1,4 @@ +import { writeFileSync } from 'fs' import type { API, Collection, @@ -141,6 +142,100 @@ function findAndReplaceProps( }) } +function nextConfigTransformer(j: JSCodeshift, root: Collection) { + let pathPrefix = '' + let loaderType = '' + root.find(j.ObjectExpression).forEach((o) => { + const [images] = o.value.properties || [] + if ( + images.type === 'Property' && + images.key.type === 'Identifier' && + images.key.name === 'images' && + images.value.type === 'ObjectExpression' && + images.value.properties + ) { + const properties = images.value.properties.filter((p) => { + if ( + p.type === 'Property' && + p.key.type === 'Identifier' && + p.key.name === 'loader' && + 'value' in p.value + ) { + if ( + p.value.value === 'imgix' || + p.value.value === 'cloudinary' || + p.value.value === 'akamai' + ) { + loaderType = p.value.value + p.value.value = 'custom' + } + } + if ( + p.type === 'Property' && + p.key.type === 'Identifier' && + p.key.name === 'path' && + 'value' in p.value + ) { + pathPrefix = String(p.value.value) + return false + } + return true + }) + if (loaderType && pathPrefix) { + let filename = `./${loaderType}-loader.js` + properties.push( + j.property('init', j.identifier('loaderFile'), j.literal(filename)) + ) + images.value.properties = properties + const normalizeSrc = `const normalizeSrc = (src) => src[0] === '/' ? src.slice(1) : src` + if (loaderType === 'imgix') { + writeFileSync( + filename, + `${normalizeSrc} + export default function imgixLoader({ src, width, quality }) { + const url = new URL('${pathPrefix}' + normalizeSrc(src)) + const params = url.searchParams + params.set('auto', params.getAll('auto').join(',') || 'format') + params.set('fit', params.get('fit') || 'max') + params.set('w', params.get('w') || width.toString()) + if (quality) { params.set('q', quality.toString()) } + return url.href + }` + .split('\n') + .map((l) => l.trim()) + .join('\n') + ) + } else if (loaderType === 'cloudinary') { + writeFileSync( + filename, + `${normalizeSrc} + export default function cloudinaryLoader({ src, width, quality }) { + const params = ['f_auto', 'c_limit', 'w_' + width, 'q_' + (quality || 'auto')] + const paramsString = params.join(',') + '/' + return '${pathPrefix}' + paramsString + normalizeSrc(src) + }` + .split('\n') + .map((l) => l.trim()) + .join('\n') + ) + } else if (loaderType === 'akamai') { + writeFileSync( + filename, + `${normalizeSrc} + export default function akamaiLoader({ src, width, quality }) { + return '${pathPrefix}' + normalizeSrc(src) + '?imwidth=' + width + }` + .split('\n') + .map((l) => l.trim()) + .join('\n') + ) + } + } + } + }) + return root +} + export default function transformer( file: FileInfo, api: API, @@ -149,6 +244,17 @@ export default function transformer( const j = api.jscodeshift const root = j(file.source) + const isConfig = + file.path === 'next.config.js' || + file.path === 'next.config.ts' || + file.path === 'next.config.mjs' || + file.path === 'next.config.cjs' + + if (isConfig) { + const result = nextConfigTransformer(j, root) + return result.toSource() + } + // Before: import Image from "next/legacy/image" // After: import Image from "next/image" root