feat(cpa): support next.config.ts (#7367)

Support new `next.config.ts` config file.

Had to do some weird gymnastics around `swc` in order to use it within
unit tests. Had to pass through the `parsed.span.end` value of any
previous iteration and account for it.

Looks to be an open issue here:
https://github.com/swc-project/swc/issues/1366

Fixes #7318
This commit is contained in:
Elliot DeNolf
2024-07-26 10:33:46 -04:00
committed by GitHub
parent 55c6ce92b0
commit a64f37e014
7 changed files with 289 additions and 120 deletions

View File

@@ -50,6 +50,7 @@
"dependencies": {
"@clack/prompts": "^0.7.0",
"@sindresorhus/slugify": "^1.1.0",
"@swc/core": "^1.6.13",
"arg": "^5.0.0",
"chalk": "^4.1.0",
"comment-json": "^4.2.3",

View File

@@ -79,7 +79,7 @@ export async function initNext(args: InitNextArgs): Promise<InitNextResult> {
const installSpinner = p.spinner()
installSpinner.start('Installing Payload and dependencies...')
const configurationResult = installAndConfigurePayload({
const configurationResult = await installAndConfigurePayload({
...args,
nextAppDetails,
nextConfigType,
@@ -143,15 +143,16 @@ async function addPayloadConfigToTsConfig(projectDir: string, isSrcDir: boolean)
}
}
function installAndConfigurePayload(
async function installAndConfigurePayload(
args: {
nextAppDetails: NextAppDetails
nextConfigType: NextConfigType
useDistFiles?: boolean
} & InitNextArgs,
):
): Promise<
| { payloadConfigPath: string; success: true }
| { payloadConfigPath?: string; reason: string; success: false } {
| { payloadConfigPath?: string; reason: string; success: false }
> {
const {
'--debug': debug,
nextAppDetails: { isSrcDir, nextAppDir, nextConfigPath } = {},
@@ -212,7 +213,7 @@ function installAndConfigurePayload(
copyRecursiveSync(templateSrcDir, path.dirname(nextConfigPath), debug)
// Wrap next.config.js with withPayload
wrapNextConfig({ nextConfigPath, nextConfigType })
await wrapNextConfig({ nextConfigPath, nextConfigType })
return {
payloadConfigPath: path.resolve(nextAppDir, '../payload.config.ts'),
@@ -240,7 +241,7 @@ export async function getNextAppDetails(projectDir: string): Promise<NextAppDeta
const isSrcDir = fs.existsSync(path.resolve(projectDir, 'src'))
const nextConfigPath: string | undefined = (
await globby('next.config.*js', { absolute: true, cwd: projectDir })
await globby('next.config.*(t|j)s', { absolute: true, cwd: projectDir })
)?.[0]
if (!nextConfigPath || nextConfigPath.length === 0) {
@@ -286,8 +287,13 @@ export async function getNextAppDetails(projectDir: string): Promise<NextAppDeta
function getProjectType(args: {
nextConfigPath: string
packageObj: Record<string, unknown>
}): 'cjs' | 'esm' {
}): NextConfigType {
const { nextConfigPath, packageObj } = args
if (nextConfigPath.endsWith('.ts')) {
return 'ts'
}
if (nextConfigPath.endsWith('.mjs')) {
return 'esm'
}

View File

@@ -3,6 +3,35 @@ import { jest } from '@jest/globals'
import { parseAndModifyConfigContent, withPayloadStatement } from './wrap-next-config.js'
const tsConfigs = {
defaultNextConfig: `import type { NextConfig } from "next";
const nextConfig: NextConfig = {};
export default nextConfig;`,
nextConfigExportNamedDefault: `import type { NextConfig } from "next";
const nextConfig: NextConfig = {};
const wrapped = someFunc(asdf);
export { wrapped as default };
`,
nextConfigWithFunc: `import type { NextConfig } from "next";
const nextConfig: NextConfig = {};
export default someFunc(nextConfig);
`,
nextConfigWithFuncMultiline: `import type { NextConfig } from "next";
const nextConfig: NextConfig = {};
export default someFunc(
nextConfig
);
`,
nextConfigWithSpread: `import type { NextConfig } from "next";
const nextConfig: NextConfig = {
...someConfig,
};
export default nextConfig;
`,
}
const esmConfigs = {
defaultNextConfig: `/** @type {import('next').NextConfig} */
const nextConfig = {};
@@ -52,27 +81,66 @@ module.exports = nextConfig;
}
describe('parseAndInsertWithPayload', () => {
describe('ts', () => {
const configType = 'ts'
const importStatement = withPayloadStatement[configType]
it('should parse the default next config', async () => {
const { modifiedConfigContent } = await parseAndModifyConfigContent(
tsConfigs.defaultNextConfig,
configType,
)
expect(modifiedConfigContent).toContain(importStatement)
expect(modifiedConfigContent).toContain('withPayload(nextConfig)')
})
it('should parse the config with a function', async () => {
const { modifiedConfigContent: modifiedConfigContent2 } = await parseAndModifyConfigContent(
tsConfigs.nextConfigWithFunc,
configType,
)
expect(modifiedConfigContent2).toContain('withPayload(someFunc(nextConfig))')
})
it('should parse the config with a multi-lined function', async () => {
const { modifiedConfigContent } = await parseAndModifyConfigContent(
tsConfigs.nextConfigWithFuncMultiline,
configType,
)
expect(modifiedConfigContent).toContain(importStatement)
expect(modifiedConfigContent).toMatch(/withPayload\(someFunc\(\n {2}nextConfig\n\)\)/)
})
it('should parse the config with a spread', async () => {
const { modifiedConfigContent } = await parseAndModifyConfigContent(
tsConfigs.nextConfigWithSpread,
configType,
)
expect(modifiedConfigContent).toContain(importStatement)
expect(modifiedConfigContent).toContain('withPayload(nextConfig)')
})
})
describe('esm', () => {
const configType = 'esm'
const importStatement = withPayloadStatement[configType]
it('should parse the default next config', () => {
const { modifiedConfigContent } = parseAndModifyConfigContent(
it('should parse the default next config', async () => {
const { modifiedConfigContent } = await parseAndModifyConfigContent(
esmConfigs.defaultNextConfig,
configType,
)
expect(modifiedConfigContent).toContain(importStatement)
expect(modifiedConfigContent).toContain('withPayload(nextConfig)')
})
it('should parse the config with a function', () => {
const { modifiedConfigContent } = parseAndModifyConfigContent(
it('should parse the config with a function', async () => {
const { modifiedConfigContent } = await parseAndModifyConfigContent(
esmConfigs.nextConfigWithFunc,
configType,
)
expect(modifiedConfigContent).toContain('withPayload(someFunc(nextConfig))')
})
it('should parse the config with a function on a new line', () => {
const { modifiedConfigContent } = parseAndModifyConfigContent(
it('should parse the config with a multi-lined function', async () => {
const { modifiedConfigContent } = await parseAndModifyConfigContent(
esmConfigs.nextConfigWithFuncMultiline,
configType,
)
@@ -80,8 +148,8 @@ describe('parseAndInsertWithPayload', () => {
expect(modifiedConfigContent).toMatch(/withPayload\(someFunc\(\n {2}nextConfig\n\)\)/)
})
it('should parse the config with a spread', () => {
const { modifiedConfigContent } = parseAndModifyConfigContent(
it('should parse the config with a spread', async () => {
const { modifiedConfigContent } = await parseAndModifyConfigContent(
esmConfigs.nextConfigWithSpread,
configType,
)
@@ -90,10 +158,10 @@ describe('parseAndInsertWithPayload', () => {
})
// Unsupported: export { wrapped as default }
it('should give warning with a named export as default', () => {
it('should give warning with a named export as default', async () => {
const warnLogSpy = jest.spyOn(p.log, 'warn').mockImplementation(() => {})
const { modifiedConfigContent, success } = parseAndModifyConfigContent(
const { modifiedConfigContent, success } = await parseAndModifyConfigContent(
esmConfigs.nextConfigExportNamedDefault,
configType,
)
@@ -109,39 +177,39 @@ describe('parseAndInsertWithPayload', () => {
describe('cjs', () => {
const configType = 'cjs'
const requireStatement = withPayloadStatement[configType]
it('should parse the default next config', () => {
const { modifiedConfigContent } = parseAndModifyConfigContent(
it('should parse the default next config', async () => {
const { modifiedConfigContent } = await parseAndModifyConfigContent(
cjsConfigs.defaultNextConfig,
configType,
)
expect(modifiedConfigContent).toContain(requireStatement)
expect(modifiedConfigContent).toContain('withPayload(nextConfig)')
})
it('should parse anonymous default config', () => {
const { modifiedConfigContent } = parseAndModifyConfigContent(
it('should parse anonymous default config', async () => {
const { modifiedConfigContent } = await parseAndModifyConfigContent(
cjsConfigs.anonConfig,
configType,
)
expect(modifiedConfigContent).toContain(requireStatement)
expect(modifiedConfigContent).toContain('withPayload({})')
})
it('should parse the config with a function', () => {
const { modifiedConfigContent } = parseAndModifyConfigContent(
it('should parse the config with a function', async () => {
const { modifiedConfigContent } = await parseAndModifyConfigContent(
cjsConfigs.nextConfigWithFunc,
configType,
)
expect(modifiedConfigContent).toContain('withPayload(someFunc(nextConfig))')
})
it('should parse the config with a function on a new line', () => {
const { modifiedConfigContent } = parseAndModifyConfigContent(
it('should parse the config with a multi-lined function', async () => {
const { modifiedConfigContent } = await parseAndModifyConfigContent(
cjsConfigs.nextConfigWithFuncMultiline,
configType,
)
expect(modifiedConfigContent).toContain(requireStatement)
expect(modifiedConfigContent).toMatch(/withPayload\(someFunc\(\n {2}nextConfig\n\)\)/)
})
it('should parse the config with a named export as default', () => {
const { modifiedConfigContent } = parseAndModifyConfigContent(
it('should parse the config with a named export as default', async () => {
const { modifiedConfigContent } = await parseAndModifyConfigContent(
cjsConfigs.nextConfigExportNamedDefault,
configType,
)
@@ -149,8 +217,8 @@ describe('parseAndInsertWithPayload', () => {
expect(modifiedConfigContent).toContain('withPayload(wrapped)')
})
it('should parse the config with a spread', () => {
const { modifiedConfigContent } = parseAndModifyConfigContent(
it('should parse the config with a spread', async () => {
const { modifiedConfigContent } = await parseAndModifyConfigContent(
cjsConfigs.nextConfigWithSpread,
configType,
)

View File

@@ -1,25 +1,27 @@
import type { Program } from 'esprima-next'
import type { ExportDefaultExpression, ModuleItem } from '@swc/core'
import swc from '@swc/core'
import chalk from 'chalk'
import { Syntax, parseModule } from 'esprima-next'
import fs from 'fs'
import type { NextConfigType } from '../types.js'
import { log, warning } from '../utils/log.js'
export const withPayloadStatement = {
cjs: `const { withPayload } = require('@payloadcms/next/withPayload')\n`,
esm: `import { withPayload } from '@payloadcms/next/withPayload'\n`,
cjs: `const { withPayload } = require("@payloadcms/next/withPayload");`,
esm: `import { withPayload } from "@payloadcms/next/withPayload";`,
ts: `import { withPayload } from "@payloadcms/next/withPayload";`,
}
type NextConfigType = 'cjs' | 'esm'
export const wrapNextConfig = (args: {
export const wrapNextConfig = async (args: {
nextConfigPath: string
nextConfigType: NextConfigType
}) => {
const { nextConfigPath, nextConfigType: configType } = args
const configContent = fs.readFileSync(nextConfigPath, 'utf8')
const { modifiedConfigContent: newConfig, success } = parseAndModifyConfigContent(
const { modifiedConfigContent: newConfig, success } = await parseAndModifyConfigContent(
configContent,
configType,
)
@@ -34,113 +36,142 @@ export const wrapNextConfig = (args: {
/**
* Parses config content with AST and wraps it with withPayload function
*/
export function parseAndModifyConfigContent(
export async function parseAndModifyConfigContent(
content: string,
configType: NextConfigType,
): { modifiedConfigContent: string; success: boolean } {
content = withPayloadStatement[configType] + content
): Promise<{ modifiedConfigContent: string; success: boolean }> {
content = withPayloadStatement[configType] + '\n' + content
let ast: Program | undefined
try {
ast = parseModule(content, { loc: true })
} catch (error: unknown) {
if (error instanceof Error) {
warning(`Unable to parse Next config. Error: ${error.message} `)
warnUserWrapNotSuccessful(configType)
}
return {
modifiedConfigContent: content,
success: false,
}
}
console.log({ configType, content })
if (configType === 'esm') {
const exportDefaultDeclaration = ast.body.find(
(p) => p.type === Syntax.ExportDefaultDeclaration,
) as Directive | undefined
if (configType === 'cjs' || configType === 'esm') {
try {
const ast = parseModule(content, { loc: true })
const exportNamedDeclaration = ast.body.find(
(p) => p.type === Syntax.ExportNamedDeclaration,
) as ExportNamedDeclaration | undefined
if (configType === 'cjs') {
// Find `module.exports = X`
const moduleExports = ast.body.find(
(p) =>
p.type === Syntax.ExpressionStatement &&
p.expression?.type === Syntax.AssignmentExpression &&
p.expression.left?.type === Syntax.MemberExpression &&
p.expression.left.object?.type === Syntax.Identifier &&
p.expression.left.object.name === 'module' &&
p.expression.left.property?.type === Syntax.Identifier &&
p.expression.left.property.name === 'exports',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) as any
if (!exportDefaultDeclaration && !exportNamedDeclaration) {
throw new Error('Could not find ExportDefaultDeclaration in next.config.js')
}
if (moduleExports && moduleExports.expression.right?.loc) {
const modifiedConfigContent = insertBeforeAndAfter(
content,
moduleExports.expression.right.loc,
)
return { modifiedConfigContent, success: true }
}
if (exportDefaultDeclaration && exportDefaultDeclaration.declaration?.loc) {
const modifiedConfigContent = insertBeforeAndAfter(
content,
exportDefaultDeclaration.declaration.loc,
)
return { modifiedConfigContent, success: true }
} else if (exportNamedDeclaration) {
const exportSpecifier = exportNamedDeclaration.specifiers.find(
(s) =>
s.type === 'ExportSpecifier' &&
s.exported?.name === 'default' &&
s.local?.type === 'Identifier' &&
s.local?.name,
)
if (exportSpecifier) {
warning('Could not automatically wrap next.config.js with withPayload.')
warning('Automatic wrapping of named exports as default not supported yet.')
warnUserWrapNotSuccessful(configType)
return {
return Promise.resolve({
modifiedConfigContent: content,
success: false,
})
} else if (configType === 'esm') {
const exportDefaultDeclaration = ast.body.find(
(p) => p.type === Syntax.ExportDefaultDeclaration,
) as Directive | undefined
const exportNamedDeclaration = ast.body.find(
(p) => p.type === Syntax.ExportNamedDeclaration,
) as ExportNamedDeclaration | undefined
if (!exportDefaultDeclaration && !exportNamedDeclaration) {
throw new Error('Could not find ExportDefaultDeclaration in next.config.js')
}
if (exportDefaultDeclaration && exportDefaultDeclaration.declaration?.loc) {
const modifiedConfigContent = insertBeforeAndAfter(
content,
exportDefaultDeclaration.declaration.loc,
)
return { modifiedConfigContent, success: true }
} else if (exportNamedDeclaration) {
const exportSpecifier = exportNamedDeclaration.specifiers.find(
(s) =>
s.type === 'ExportSpecifier' &&
s.exported?.name === 'default' &&
s.local?.type === 'Identifier' &&
s.local?.name,
)
if (exportSpecifier) {
warning('Could not automatically wrap next.config.js with withPayload.')
warning('Automatic wrapping of named exports as default not supported yet.')
warnUserWrapNotSuccessful(configType)
return {
modifiedConfigContent: content,
success: false,
}
}
}
warning('Could not automatically wrap Next config with withPayload.')
warnUserWrapNotSuccessful(configType)
return Promise.resolve({
modifiedConfigContent: content,
success: false,
})
}
} catch (error: unknown) {
if (error instanceof Error) {
warning(`Unable to parse Next config. Error: ${error.message} `)
warnUserWrapNotSuccessful(configType)
}
return {
modifiedConfigContent: content,
success: false,
}
}
} else if (configType === 'ts') {
const { moduleItems, parseOffset } = await compileTypeScriptFileToAST(content)
warning('Could not automatically wrap Next config with withPayload.')
warnUserWrapNotSuccessful(configType)
return {
modifiedConfigContent: content,
success: false,
}
} else if (configType === 'cjs') {
// Find `module.exports = X`
const moduleExports = ast.body.find(
(p) =>
p.type === Syntax.ExpressionStatement &&
p.expression?.type === Syntax.AssignmentExpression &&
p.expression.left?.type === Syntax.MemberExpression &&
p.expression.left.object?.type === Syntax.Identifier &&
p.expression.left.object.name === 'module' &&
p.expression.left.property?.type === Syntax.Identifier &&
p.expression.left.property.name === 'exports',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) as any
const exportDefaultDeclaration = moduleItems.find(
(m) =>
m.type === 'ExportDefaultExpression' &&
(m.expression.type === 'Identifier' || m.expression.type === 'CallExpression'),
) as ExportDefaultExpression | undefined
if (moduleExports && moduleExports.expression.right?.loc) {
const modifiedConfigContent = insertBeforeAndAfter(
if (exportDefaultDeclaration) {
if (!('span' in exportDefaultDeclaration.expression)) {
warning('Could not automatically wrap Next config with withPayload.')
warnUserWrapNotSuccessful(configType)
return Promise.resolve({
modifiedConfigContent: content,
success: false,
})
}
const modifiedConfigContent = insertBeforeAndAfterSWC(
content,
moduleExports.expression.right.loc,
exportDefaultDeclaration.expression.span,
parseOffset,
)
return { modifiedConfigContent, success: true }
}
return {
modifiedConfigContent: content,
success: false,
}
}
warning('Could not automatically wrap Next config with withPayload.')
warnUserWrapNotSuccessful(configType)
return {
return Promise.resolve({
modifiedConfigContent: content,
success: false,
}
})
}
function warnUserWrapNotSuccessful(configType: NextConfigType) {
// Output directions for user to update next.config.js
const withPayloadMessage = `
${chalk.bold(`Please manually wrap your existing next.config.js with the withPayload function. Here is an example:`)}
${chalk.bold(`Please manually wrap your existing Next config with the withPayload function. Here is an example:`)}
${withPayloadStatement[configType]}
@@ -148,7 +179,7 @@ function warnUserWrapNotSuccessful(configType: NextConfigType) {
// Your Next.js config here
}
${configType === 'esm' ? 'export default withPayload(nextConfig)' : 'module.exports = withPayload(nextConfig)'}
${configType === 'cjs' ? 'module.exports = withPayload(nextConfig)' : 'export default withPayload(nextConfig)'}
`
@@ -186,7 +217,7 @@ type Loc = {
start: { column: number; line: number }
}
function insertBeforeAndAfter(content: string, loc: Loc) {
function insertBeforeAndAfter(content: string, loc: Loc): string {
const { end, start } = loc
const lines = content.split('\n')
@@ -205,3 +236,57 @@ function insertBeforeAndAfter(content: string, loc: Loc) {
return lines.join('\n')
}
function insertBeforeAndAfterSWC(
content: string,
span: ModuleItem['span'],
/**
* WARNING: This is ONLY for unit tests. Defaults to 0 otherwise.
*
* @see compileTypeScriptFileToAST
*/
parseOffset: number,
): string {
const { end: preOffsetEnd, start: preOffsetStart } = span
const start = preOffsetStart - parseOffset
const end = preOffsetEnd - parseOffset
const insert = (pos: number, text: string): string => {
return content.slice(0, pos) + text + content.slice(pos)
}
// insert ) after end
content = insert(end - 1, ')')
// insert withPayload before start
content = insert(start - 1, 'withPayload(')
return content
}
/**
* Compile typescript to AST using the swc compiler
*/
async function compileTypeScriptFileToAST(
fileContent: string,
): Promise<{ moduleItems: ModuleItem[]; parseOffset: number }> {
let parseOffset = 0
/**
* WARNING: This is ONLY for unit tests.
*
* Multiple instances of swc DO NOT reset the .span.end value.
* During unit tests, the .spawn.end value is read and accounted for.
*
* https://github.com/swc-project/swc/issues/1366
*/
if (process.env.NODE_ENV === 'test') {
parseOffset = (await swc.parse('')).span.end
}
const module = await swc.parse(fileContent, {
syntax: 'typescript',
})
return { moduleItems: module.body, parseOffset }
}

View File

@@ -75,6 +75,6 @@ export type NextAppDetails = {
nextConfigType?: NextConfigType
}
export type NextConfigType = 'cjs' | 'esm'
export type NextConfigType = 'cjs' | 'esm' | 'ts'
export type StorageAdapterType = 'localDisk' | 'payloadCloud' | 'vercelBlobStorage'

3
pnpm-lock.yaml generated
View File

@@ -206,6 +206,9 @@ importers:
'@sindresorhus/slugify':
specifier: ^1.1.0
version: 1.1.2
'@swc/core':
specifier: ^1.6.13
version: 1.6.13
arg:
specifier: ^5.0.0
version: 5.0.2

View File

@@ -1,3 +1,4 @@
/* eslint-disable jest/no-conditional-in-test */
import type { CompilerOptions } from 'typescript'
import * as CommentJson from 'comment-json'
@@ -13,11 +14,12 @@ import { promisify } from 'util'
const readFile = promisify(fs.readFile)
const writeFile = promisify(fs.writeFile)
const commonNextCreateParams = '--typescript --eslint --no-tailwind --app --import-alias="@/*"'
const commonNextCreateParams =
'--typescript --eslint --no-tailwind --app --import-alias="@/*" --turbo --yes'
const nextCreateCommands: Partial<Record<'noSrcDir' | 'srcDir', string>> = {
noSrcDir: `pnpm create next-app@latest . ${commonNextCreateParams} --no-src-dir`,
srcDir: `pnpm create next-app@latest . ${commonNextCreateParams} --src-dir`,
noSrcDir: `pnpm create next-app@canary . ${commonNextCreateParams} --no-src-dir`,
srcDir: `pnpm create next-app@canary . ${commonNextCreateParams} --src-dir`,
}
describe('create-payload-app', () => {
@@ -41,7 +43,11 @@ describe('create-payload-app', () => {
// Create a new Next.js project with default options
console.log(`Running: ${nextCreateCommands[nextCmdKey]} in ${projectDir}`)
const [cmd, ...args] = nextCreateCommands[nextCmdKey].split(' ')
const { exitCode, stderr } = await execa(cmd, [...args], { cwd: projectDir })
console.log(`Running: ${cmd} ${args.join(' ')}`)
const { exitCode, stderr } = await execa(cmd, [...args], {
cwd: projectDir,
stdio: 'inherit',
})
if (exitCode !== 0) {
console.error({ exitCode, stderr })
}