fix(nextjs): support relative imports in next.config.js (#17127)
This commit is contained in:
parent
9e3596abe5
commit
b6361738a4
@ -443,26 +443,26 @@ describe('Next.js Applications', () => {
|
||||
});
|
||||
}, 300_000);
|
||||
|
||||
it('should create a generate a next.js app with app layout enabled', async () => {
|
||||
it('should copy relative modules needed by the next.config.js file', async () => {
|
||||
const appName = uniq('app');
|
||||
|
||||
runCLI(
|
||||
`generate @nx/next:app ${appName} --style=css --appDir --no-interactive`
|
||||
runCLI(`generate @nx/next:app ${appName} --style=css --no-interactive`);
|
||||
|
||||
updateFile(`apps/${appName}/redirects.js`, 'module.exports = [];');
|
||||
updateFile(
|
||||
`apps/${appName}/nested/headers.js`,
|
||||
`module.exports = require('./headers-2');`
|
||||
);
|
||||
|
||||
checkFilesExist(`apps/${appName}/app/api/hello/route.ts`);
|
||||
checkFilesExist(`apps/${appName}/app/page.tsx`);
|
||||
checkFilesExist(`apps/${appName}/app/layout.tsx`);
|
||||
checkFilesExist(`apps/${appName}/app/global.css`);
|
||||
checkFilesExist(`apps/${appName}/app/page.module.css`);
|
||||
|
||||
await checkApp(appName, {
|
||||
checkUnitTest: false,
|
||||
checkLint: false,
|
||||
checkE2E: false,
|
||||
checkExport: false,
|
||||
updateFile(`apps/${appName}/nested/headers-2.js`, 'module.exports = [];');
|
||||
updateFile(`apps/${appName}/next.config.js`, (content) => {
|
||||
return `const redirects = require('./redirects');\nconst headers = require('./nested/headers.js');\n${content}`;
|
||||
});
|
||||
}, 300_000);
|
||||
|
||||
runCLI(`build ${appName}`);
|
||||
checkFilesExist(`dist/apps/${appName}/redirects.js`);
|
||||
checkFilesExist(`dist/apps/${appName}/nested/headers.js`);
|
||||
checkFilesExist(`dist/apps/${appName}/nested/headers-2.js`);
|
||||
}, 120_000);
|
||||
|
||||
it('should support --turbo to enable Turbopack', async () => {
|
||||
const appName = uniq('app');
|
||||
|
||||
@ -1,11 +1,18 @@
|
||||
import { getWithNxContent } from './create-next-config-file';
|
||||
import {
|
||||
ensureFileExtensions,
|
||||
findNextConfigPath,
|
||||
getRelativeFilesToCopy,
|
||||
getRelativeImports,
|
||||
getWithNxContent,
|
||||
} from './create-next-config-file';
|
||||
import { stripIndents } from '@nx/devkit';
|
||||
import { join } from 'path';
|
||||
|
||||
describe('Next.js config: getWithNxContent', () => {
|
||||
it('should swap distDir and getWithNxContext with static values', () => {
|
||||
const result = getWithNxContent({
|
||||
withNxFile: `with-nx.js`,
|
||||
withNxContent: stripIndents`
|
||||
file: `with-nx.js`,
|
||||
content: stripIndents`
|
||||
// SHOULD BE LEFT INTACT
|
||||
const constants = require("next/constants");
|
||||
|
||||
@ -56,4 +63,72 @@ describe('Next.js config: getWithNxContent', () => {
|
||||
expect(result).toContain(`libsDir: ''`);
|
||||
expect(result).not.toContain(`libsDir: workspaceLayout.libsDir()`);
|
||||
});
|
||||
|
||||
it('should return relative module paths used in next.config.js when calling getRelativeFilesToCopy', () => {
|
||||
const modulePaths = getRelativeFilesToCopy(
|
||||
findNextConfigPath(join(__dirname, 'test-fixtures/config-js')),
|
||||
join(__dirname, 'test-fixtures/config-js')
|
||||
);
|
||||
|
||||
expect(modulePaths).toEqual([
|
||||
'nested/a.cjs',
|
||||
'nested/b.cjs',
|
||||
'nested-c.cjs',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return relative module paths used in next.config.mjs when calling getRelativeFilesToCopy', () => {
|
||||
const modulePaths = getRelativeFilesToCopy(
|
||||
findNextConfigPath(join(__dirname, 'test-fixtures/config-mjs')),
|
||||
join(__dirname, 'test-fixtures/config-mjs')
|
||||
);
|
||||
|
||||
expect(modulePaths).toEqual(['a.mjs']);
|
||||
});
|
||||
|
||||
it('should return relative requires when calling getRelativeImports', () => {
|
||||
const result = getRelativeImports({
|
||||
file: 'next.config.js',
|
||||
content: stripIndents`
|
||||
const w = require('@scoped/w');
|
||||
const x = require('x');
|
||||
const y = require("./y");
|
||||
const z = require('./nested/z');
|
||||
`,
|
||||
});
|
||||
|
||||
expect(result).toEqual(['./y', './nested/z']);
|
||||
});
|
||||
|
||||
it('should return relative imports when calling getRelativeImports', () => {
|
||||
const result = getRelativeImports({
|
||||
file: 'next.config.js',
|
||||
content: stripIndents`
|
||||
import { w } from '@scoped/w';
|
||||
import { x } from 'x';
|
||||
import { y } from "./y";
|
||||
import { z } from './nested/z'
|
||||
`,
|
||||
});
|
||||
|
||||
expect(result).toEqual(['./y', './nested/z']);
|
||||
});
|
||||
|
||||
it('should return files with their extensions when calling ensureFileExtensions', () => {
|
||||
const result = ensureFileExtensions(
|
||||
['bar', 'foo', 'faz', 'nested/baz.cjs'],
|
||||
join(__dirname, 'test-fixtures/ensure-exts')
|
||||
);
|
||||
|
||||
expect(result).toEqual(['bar.mjs', 'foo.cjs', 'faz.cjs', 'nested/baz.cjs']);
|
||||
});
|
||||
|
||||
it('should throw an error if a path cannot be found when calling ensureFileExtensions', () => {
|
||||
expect(() =>
|
||||
ensureFileExtensions(
|
||||
['not-found'],
|
||||
join(__dirname, 'test-fixtures/ensure-exts')
|
||||
)
|
||||
).toThrow(/Cannot find file "not-found"/);
|
||||
});
|
||||
});
|
||||
|
||||
@ -9,26 +9,33 @@ import {
|
||||
import * as ts from 'typescript';
|
||||
import {
|
||||
copyFileSync,
|
||||
ensureDirSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
} from 'fs';
|
||||
import { join } from 'path';
|
||||
} from 'fs-extra';
|
||||
import { dirname, extname, join, relative } from 'path';
|
||||
import { findNodes } from 'nx/src/utils/typescript';
|
||||
|
||||
import type { NextBuildBuilderOptions } from '../../../utils/types';
|
||||
import { findNodes } from 'nx/src/utils/typescript';
|
||||
|
||||
export function createNextConfigFile(
|
||||
options: NextBuildBuilderOptions,
|
||||
context: ExecutorContext
|
||||
) {
|
||||
const nextConfigPath = options.nextConfig
|
||||
? join(context.root, options.nextConfig)
|
||||
: join(context.root, options.root, 'next.config.js');
|
||||
const configRelativeToProjectRoot = findNextConfigPath(
|
||||
options.root,
|
||||
// If user passed a config then it is relative to the workspace root, need to normalize it to be relative to the project root.
|
||||
options.nextConfig ? relative(options.root, options.nextConfig) : undefined
|
||||
);
|
||||
const configAbsolutePath = join(options.root, configRelativeToProjectRoot);
|
||||
|
||||
if (!existsSync(configAbsolutePath)) {
|
||||
throw new Error('next.config.js not found');
|
||||
}
|
||||
|
||||
// Copy config file and our `.nx-helpers` folder to remove dependency on @nrwl/next for production build.
|
||||
if (existsSync(nextConfigPath)) {
|
||||
const helpersPath = join(options.outputPath, '.nx-helpers');
|
||||
mkdirSync(helpersPath, { recursive: true });
|
||||
copyFileSync(
|
||||
@ -46,30 +53,45 @@ export function createNextConfigFile(
|
||||
`
|
||||
);
|
||||
writeFileSync(
|
||||
join(options.outputPath, 'next.config.js'),
|
||||
readFileSync(nextConfigPath)
|
||||
join(options.outputPath, configRelativeToProjectRoot),
|
||||
readFileSync(configAbsolutePath)
|
||||
.toString()
|
||||
.replace(/["']@nx\/next["']/, `'./.nx-helpers/compiled.js'`)
|
||||
// TODO(v17): Remove this once users have all migrated to new @nx scope and import from '@nx/next' not the deep import paths.
|
||||
.replace('@nx/next/plugins/with-nx', './.nx-helpers/compiled.js')
|
||||
.replace('@nrwl/next/plugins/with-nx', './.nx-helpers/compiled.js')
|
||||
);
|
||||
|
||||
// Find all relative imports needed by next.config.js and copy them to the dist folder.
|
||||
const moduleFilesToCopy = getRelativeFilesToCopy(
|
||||
configRelativeToProjectRoot,
|
||||
options.root
|
||||
);
|
||||
for (const moduleFile of moduleFilesToCopy) {
|
||||
ensureDirSync(dirname(join(context.root, options.outputPath, moduleFile)));
|
||||
copyFileSync(
|
||||
join(context.root, options.root, moduleFile),
|
||||
join(context.root, options.outputPath, moduleFile)
|
||||
);
|
||||
}
|
||||
}
|
||||
function readSource() {
|
||||
const withNxFile = join(__dirname, '../../../../plugins/with-nx.js');
|
||||
const withNxContent = readFileSync(withNxFile).toString();
|
||||
|
||||
function readSource(getFile: () => string): { file: string; content: string } {
|
||||
return {
|
||||
withNxFile,
|
||||
withNxContent,
|
||||
file: getFile(),
|
||||
content: readFileSync(getFile()).toString(),
|
||||
};
|
||||
}
|
||||
|
||||
// Exported for testing
|
||||
export function getWithNxContent({ withNxFile, withNxContent } = readSource()) {
|
||||
export function getWithNxContent(
|
||||
{ file, content } = readSource(() =>
|
||||
join(__dirname, '../../../../plugins/with-nx.js')
|
||||
)
|
||||
) {
|
||||
const withNxSource = ts.createSourceFile(
|
||||
withNxFile,
|
||||
withNxContent,
|
||||
file,
|
||||
content,
|
||||
ts.ScriptTarget.Latest,
|
||||
true
|
||||
);
|
||||
@ -80,7 +102,7 @@ export function getWithNxContent({ withNxFile, withNxContent } = readSource()) {
|
||||
(node: ts.FunctionDeclaration) => node.name?.text === 'getWithNxContext'
|
||||
);
|
||||
if (getWithNxContextDeclaration) {
|
||||
withNxContent = applyChangesToString(withNxContent, [
|
||||
content = applyChangesToString(content, [
|
||||
{
|
||||
type: ChangeType.Delete,
|
||||
start: getWithNxContextDeclaration.getStart(withNxSource),
|
||||
@ -99,5 +121,119 @@ export function getWithNxContent({ withNxFile, withNxContent } = readSource()) {
|
||||
]);
|
||||
}
|
||||
|
||||
return withNxContent;
|
||||
return content;
|
||||
}
|
||||
|
||||
export function findNextConfigPath(
|
||||
dirname: string,
|
||||
userDefinedConfigPath?: string
|
||||
): string {
|
||||
if (userDefinedConfigPath) {
|
||||
const file = userDefinedConfigPath;
|
||||
if (existsSync(file)) return file;
|
||||
throw new Error(
|
||||
`Cannot find the Next.js config file: ${userDefinedConfigPath}. Is the path correct in project.json?`
|
||||
);
|
||||
}
|
||||
|
||||
const candidates = ['next.config.js', 'next.config.cjs', 'next.config.mjs'];
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(join(dirname, candidate))) return candidate;
|
||||
}
|
||||
throw new Error(
|
||||
`Cannot find any of the following files in your project: ${candidates.join(
|
||||
', '
|
||||
)}. Is this a Next.js project?`
|
||||
);
|
||||
}
|
||||
|
||||
// Exported for testing
|
||||
export function getRelativeFilesToCopy(
|
||||
fileName: string,
|
||||
cwd: string
|
||||
): string[] {
|
||||
const seen = new Set<string>();
|
||||
const collected = new Set<string>();
|
||||
|
||||
function doCollect(currFile: string): void {
|
||||
// Prevent circular dependencies from causing infinite loop
|
||||
if (seen.has(currFile)) return;
|
||||
seen.add(currFile);
|
||||
|
||||
const absoluteFilePath = join(cwd, currFile);
|
||||
const content = readFileSync(absoluteFilePath).toString();
|
||||
const files = getRelativeImports({ file: currFile, content });
|
||||
const modules = ensureFileExtensions(files, dirname(absoluteFilePath));
|
||||
|
||||
const relativeDirPath = dirname(currFile);
|
||||
|
||||
for (const moduleName of modules) {
|
||||
const relativeModulePath = join(relativeDirPath, moduleName);
|
||||
collected.add(relativeModulePath);
|
||||
doCollect(relativeModulePath);
|
||||
}
|
||||
}
|
||||
|
||||
doCollect(fileName);
|
||||
|
||||
return Array.from(collected);
|
||||
}
|
||||
|
||||
// Exported for testing
|
||||
export function getRelativeImports({
|
||||
file,
|
||||
content,
|
||||
}: {
|
||||
file: string;
|
||||
content: string;
|
||||
}): string[] {
|
||||
const source = ts.createSourceFile(
|
||||
file,
|
||||
content,
|
||||
ts.ScriptTarget.Latest,
|
||||
true
|
||||
);
|
||||
const callExpressionsOrImportDeclarations = findNodes(source, [
|
||||
ts.SyntaxKind.CallExpression,
|
||||
ts.SyntaxKind.ImportDeclaration,
|
||||
]) as (ts.CallExpression | ts.ImportDeclaration)[];
|
||||
const modulePaths: string[] = [];
|
||||
for (const node of callExpressionsOrImportDeclarations) {
|
||||
if (node.kind === ts.SyntaxKind.ImportDeclaration) {
|
||||
modulePaths.push(stripOuterQuotes(node.moduleSpecifier.getText(source)));
|
||||
} else {
|
||||
if (node.expression.getText(source) === 'require') {
|
||||
modulePaths.push(stripOuterQuotes(node.arguments[0].getText(source)));
|
||||
}
|
||||
}
|
||||
}
|
||||
return modulePaths.filter((path) => path.startsWith('.'));
|
||||
}
|
||||
|
||||
function stripOuterQuotes(str: string): string {
|
||||
return str.match(/^["'](.*)["']/)?.[1] ?? str;
|
||||
}
|
||||
|
||||
// Exported for testing
|
||||
export function ensureFileExtensions(
|
||||
files: string[],
|
||||
absoluteDir: string
|
||||
): string[] {
|
||||
const extensions = ['.js', '.cjs', '.mjs'];
|
||||
return files.map((file) => {
|
||||
if (extname(file)) return file;
|
||||
|
||||
const ext = extensions.find((ext) =>
|
||||
existsSync(join(absoluteDir, file + ext))
|
||||
);
|
||||
if (ext) {
|
||||
return file + ext;
|
||||
} else {
|
||||
throw new Error(
|
||||
`Cannot find file "${file}" with any of the following extensions: ${extensions.join(
|
||||
', '
|
||||
)}`
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -0,0 +1 @@
|
||||
module.exports = require('./b');
|
||||
@ -0,0 +1,3 @@
|
||||
require('./a'); // Circular dependency should be handled
|
||||
require('../nested-c'); // one level up
|
||||
module.exports = {};
|
||||
@ -0,0 +1 @@
|
||||
require('./nested/a');
|
||||
@ -0,0 +1 @@
|
||||
export const a = [];
|
||||
@ -0,0 +1,2 @@
|
||||
import './a.mjs';
|
||||
export default {};
|
||||
@ -0,0 +1 @@
|
||||
export const bar = 'bar';
|
||||
@ -0,0 +1 @@
|
||||
module.exports = 'faz';
|
||||
@ -0,0 +1 @@
|
||||
module.exports = 'foo';
|
||||
Loading…
x
Reference in New Issue
Block a user