diff --git a/packages/next-codemod/transforms/__testfixtures__/next-image-experimental-loader/many-keys/input/next.config.js b/packages/next-codemod/transforms/__testfixtures__/next-image-experimental-loader/many-keys/input/next.config.js new file mode 100644 index 0000000000000..06217557737ac --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/next-image-experimental-loader/many-keys/input/next.config.js @@ -0,0 +1,21 @@ +/** + * @type {import('next').NextConfig} + */ +module.exports = { + reactStrictMode: true, + + images: { + loader: "cloudinary", + path: "https://example.com/" + }, + + async redirects() { + return [ + { + source: '/source', + destination: '/dest', + permanent: true, + }, + ]; + }, +} diff --git a/packages/next-codemod/transforms/__testfixtures__/next-image-experimental-loader/many-keys/output/cloudinary-loader.js b/packages/next-codemod/transforms/__testfixtures__/next-image-experimental-loader/many-keys/output/cloudinary-loader.js new file mode 100644 index 0000000000000..f0ac0c3209d6b --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/next-image-experimental-loader/many-keys/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/many-keys/output/next.config.js b/packages/next-codemod/transforms/__testfixtures__/next-image-experimental-loader/many-keys/output/next.config.js new file mode 100644 index 0000000000000..9430e62d4e6e0 --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/next-image-experimental-loader/many-keys/output/next.config.js @@ -0,0 +1,21 @@ +/** + * @type {import('next').NextConfig} + */ +module.exports = { + reactStrictMode: true, + + images: { + loader: "custom", + loaderFile: "./cloudinary-loader.js" + }, + + async redirects() { + return [ + { + source: '/source', + destination: '/dest', + permanent: true, + }, + ]; + }, +} diff --git a/packages/next-codemod/transforms/__testfixtures__/next-image-experimental-loader/monorepo/input/app1/next.config.js b/packages/next-codemod/transforms/__testfixtures__/next-image-experimental-loader/monorepo/input/app1/next.config.js new file mode 100644 index 0000000000000..0a5810c97c6b3 --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/next-image-experimental-loader/monorepo/input/app1/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/monorepo/input/app2/next.config.js b/packages/next-codemod/transforms/__testfixtures__/next-image-experimental-loader/monorepo/input/app2/next.config.js new file mode 100644 index 0000000000000..eba596451330a --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/next-image-experimental-loader/monorepo/input/app2/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/monorepo/output/app1/imgix-loader.js b/packages/next-codemod/transforms/__testfixtures__/next-image-experimental-loader/monorepo/output/app1/imgix-loader.js new file mode 100644 index 0000000000000..22468f5a87f5d --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/next-image-experimental-loader/monorepo/output/app1/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/monorepo/output/app1/next.config.js b/packages/next-codemod/transforms/__testfixtures__/next-image-experimental-loader/monorepo/output/app1/next.config.js new file mode 100644 index 0000000000000..28ec52518cef4 --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/next-image-experimental-loader/monorepo/output/app1/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/__testfixtures__/next-image-experimental-loader/monorepo/output/app2/cloudinary-loader.js b/packages/next-codemod/transforms/__testfixtures__/next-image-experimental-loader/monorepo/output/app2/cloudinary-loader.js new file mode 100644 index 0000000000000..f0ac0c3209d6b --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/next-image-experimental-loader/monorepo/output/app2/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/monorepo/output/app2/next.config.js b/packages/next-codemod/transforms/__testfixtures__/next-image-experimental-loader/monorepo/output/app2/next.config.js new file mode 100644 index 0000000000000..28175db3e45c9 --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/next-image-experimental-loader/monorepo/output/app2/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/__tests__/next-image-experimental-loader.test.js b/packages/next-codemod/transforms/__tests__/next-image-experimental-loader.test.js index a80f8c5bc1b94..cb1bb262b5594 100644 --- a/packages/next-codemod/transforms/__tests__/next-image-experimental-loader.test.js +++ b/packages/next-codemod/transforms/__tests__/next-image-experimental-loader.test.js @@ -1,36 +1,46 @@ /* global jest */ jest.autoMockOff() const Runner = require('jscodeshift/dist/Runner'); -const { cp, mkdir, rm, readdir, readFile } = require('fs/promises') +const { cp, mkdir, mkdtemp, rm, readdir, readFile, stat } = require('fs/promises') const { readdirSync } = require('fs') +const { tmpdir } = require('os') 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 } +const opts = { recursive: true, force: 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') + const filePath = join(dir, file) + const s = await stat(filePath) + if (s.isDirectory()) { + obj[file] = await toObj(filePath) + } else { + obj[file] = await readFile(filePath, 'utf8') + } } return obj } it.each(readdirSync(fixtureDir))('should transform loader %s', async (loader) => { + const tmp = await mkdtemp(join(tmpdir(), `next-image-experimental-${loader}-`)) + const originalCwd = process.cwd() try { - await mkdir(join(fixtureDir, 'tmp'), opts) - await cp(join(fixtureDir, loader, 'input'), join(fixtureDir, 'tmp'), opts) - process.chdir(join(fixtureDir, 'tmp')) + await mkdir(tmp, opts) + await cp(join(fixtureDir, loader, 'input'), tmp, opts) + process.chdir(tmp) const result = await Runner.run(transform, [`.`], {}) expect(result.error).toBe(0) expect( - await toObj(join(fixtureDir, 'tmp')) + await toObj(tmp) ).toStrictEqual( await toObj(join(fixtureDir, loader, 'output')) ) } finally { - await rm(join(fixtureDir, 'tmp'), opts) + await rm(tmp, opts) + process.chdir(originalCwd) } }) \ 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 c26d471001b22..96bdd384f1ca9 100644 --- a/packages/next-codemod/transforms/next-image-experimental.ts +++ b/packages/next-codemod/transforms/next-image-experimental.ts @@ -1,3 +1,4 @@ +import { join, parse } from 'path' import { writeFileSync } from 'fs' import type { API, @@ -148,56 +149,65 @@ function findAndReplaceProps( }) } -function nextConfigTransformer(j: JSCodeshift, root: Collection) { +function nextConfigTransformer( + j: JSCodeshift, + root: Collection, + appDir: string +) { let pathPrefix = '' let loaderType = '' root.find(j.ObjectExpression).forEach((o) => { - const [images] = o.value.properties || [] - if ( - images.type === 'ObjectProperty' && - 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 === 'ObjectProperty' && - p.key.type === 'Identifier' && - p.key.name === 'loader' && - 'value' in p.value - ) { + ;(o.value.properties || []).forEach((images) => { + if ( + images.type === 'ObjectProperty' && + images.key.type === 'Identifier' && + images.key.name === 'images' && + images.value.type === 'ObjectExpression' && + images.value.properties + ) { + const properties = images.value.properties.filter((p) => { if ( - p.value.value === 'imgix' || - p.value.value === 'cloudinary' || - p.value.value === 'akamai' + p.type === 'ObjectProperty' && + p.key.type === 'Identifier' && + p.key.name === 'loader' && + 'value' in p.value ) { - loaderType = p.value.value - p.value.value = 'custom' + if ( + p.value.value === 'imgix' || + p.value.value === 'cloudinary' || + p.value.value === 'akamai' + ) { + loaderType = p.value.value + p.value.value = 'custom' + } } - } - if ( - p.type === 'ObjectProperty' && - 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} + if ( + p.type === 'ObjectProperty' && + p.key.type === 'Identifier' && + p.key.name === 'path' && + 'value' in p.value + ) { + pathPrefix = String(p.value.value) + return false + } + return true + }) + if (loaderType && pathPrefix) { + const importSpecifier = `./${loaderType}-loader.js` + const filePath = join(appDir, importSpecifier) + properties.push( + j.property( + 'init', + j.identifier('loaderFile'), + j.literal(importSpecifier) + ) + ) + images.value.properties = properties + const normalizeSrc = `const normalizeSrc = (src) => src[0] === '/' ? src.slice(1) : src` + if (loaderType === 'imgix') { + writeFileSync( + filePath, + `${normalizeSrc} export default function imgixLoader({ src, width, quality }) { const url = new URL('${pathPrefix}' + normalizeSrc(src)) const params = url.searchParams @@ -207,37 +217,38 @@ function nextConfigTransformer(j: JSCodeshift, root: Collection) { 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} + .split('\n') + .map((l) => l.trim()) + .join('\n') + ) + } else if (loaderType === 'cloudinary') { + writeFileSync( + filePath, + `${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} + .split('\n') + .map((l) => l.trim()) + .join('\n') + ) + } else if (loaderType === 'akamai') { + writeFileSync( + filePath, + `${normalizeSrc} export default function akamaiLoader({ src, width, quality }) { return '${pathPrefix}' + normalizeSrc(src) + '?imwidth=' + width }` - .split('\n') - .map((l) => l.trim()) - .join('\n') - ) + .split('\n') + .map((l) => l.trim()) + .join('\n') + ) + } } } - } + }) }) return root } @@ -250,14 +261,15 @@ export default function transformer( const j = api.jscodeshift.withParser('tsx') const root = j(file.source) + const parsed = parse(file.path || '/') const isConfig = - file.path === 'next.config.js' || - file.path === 'next.config.ts' || - file.path === 'next.config.mjs' || - file.path === 'next.config.cjs' + parsed.base === 'next.config.js' || + parsed.base === 'next.config.ts' || + parsed.base === 'next.config.mjs' || + parsed.base === 'next.config.cjs' if (isConfig) { - const result = nextConfigTransformer(j, root) + const result = nextConfigTransformer(j, root, parsed.dir) return result.toSource() }