diff --git a/packages/create-payload-app/package.json b/packages/create-payload-app/package.json index e6cd8c458..060df5965 100644 --- a/packages/create-payload-app/package.json +++ b/packages/create-payload-app/package.json @@ -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", diff --git a/packages/create-payload-app/src/lib/init-next.ts b/packages/create-payload-app/src/lib/init-next.ts index eeb82615a..d196dd747 100644 --- a/packages/create-payload-app/src/lib/init-next.ts +++ b/packages/create-payload-app/src/lib/init-next.ts @@ -79,7 +79,7 @@ export async function initNext(args: InitNextArgs): Promise { 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 -}): 'cjs' | 'esm' { +}): NextConfigType { const { nextConfigPath, packageObj } = args + + if (nextConfigPath.endsWith('.ts')) { + return 'ts' + } + if (nextConfigPath.endsWith('.mjs')) { return 'esm' } diff --git a/packages/create-payload-app/src/lib/wrap-next-config.spec.ts b/packages/create-payload-app/src/lib/wrap-next-config.spec.ts index 4f67b5e3d..fa9e97892 100644 --- a/packages/create-payload-app/src/lib/wrap-next-config.spec.ts +++ b/packages/create-payload-app/src/lib/wrap-next-config.spec.ts @@ -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, ) diff --git a/packages/create-payload-app/src/lib/wrap-next-config.ts b/packages/create-payload-app/src/lib/wrap-next-config.ts index b06195773..b2e1e208a 100644 --- a/packages/create-payload-app/src/lib/wrap-next-config.ts +++ b/packages/create-payload-app/src/lib/wrap-next-config.ts @@ -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 } +} diff --git a/packages/create-payload-app/src/types.ts b/packages/create-payload-app/src/types.ts index 28308a7fb..8fee9f4b6 100644 --- a/packages/create-payload-app/src/types.ts +++ b/packages/create-payload-app/src/types.ts @@ -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' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7443f39a5..9a2b6db13 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/test/create-payload-app/int.spec.ts b/test/create-payload-app/int.spec.ts index ea2cb372b..a7537ad23 100644 --- a/test/create-payload-app/int.spec.ts +++ b/test/create-payload-app/int.spec.ts @@ -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> = { - 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 }) }