<!-- Please make sure you have read the submission guidelines before posting an PR --> <!-- https://github.com/nrwl/nx/blob/master/CONTRIBUTING.md#-submitting-a-pr --> <!-- Please make sure that your commit message follows our format --> <!-- Example: `fix(nx): must begin with lowercase` --> <!-- If this is a particularly complex change or feature addition, you can request a dedicated Nx release for this pull request branch. Mention someone from the Nx team or the `@nrwl/nx-pipelines-reviewers` and they will confirm if the PR warrants its own release for testing purposes, and generate it for you if appropriate. --> ## Current Behavior <!-- This is the behavior we have today --> ## Expected Behavior <!-- This is the behavior we should expect with the changes in this PR --> ## Related Issue(s) <!-- Please link the issue being fixed so it gets closed when this is merged. --> Fixes #28572
265 lines
7.8 KiB
TypeScript
265 lines
7.8 KiB
TypeScript
import type { ExecutorContext } from '@nx/devkit';
|
|
import {
|
|
applyChangesToString,
|
|
ChangeType,
|
|
stripIndents,
|
|
workspaceLayout,
|
|
workspaceRoot,
|
|
} from '@nx/devkit';
|
|
import * as ts from 'typescript';
|
|
import {
|
|
copyFileSync,
|
|
existsSync,
|
|
mkdirSync,
|
|
readFileSync,
|
|
writeFileSync,
|
|
} from 'node:fs';
|
|
import { dirname, extname, join, relative } from 'path';
|
|
import { findNodes } from '@nx/js';
|
|
|
|
import type { NextBuildBuilderOptions } from '../../../utils/types';
|
|
|
|
export function createNextConfigFile(
|
|
options: NextBuildBuilderOptions,
|
|
context: ExecutorContext
|
|
) {
|
|
// Don't overwrite the next.config.js file if output path is the same as the source path.
|
|
if (
|
|
options.outputPath.replace(/\/$/, '') ===
|
|
context.projectGraph.nodes[context.projectName].data.root
|
|
) {
|
|
return;
|
|
}
|
|
const projectRoot = context.projectGraph.nodes[context.projectName].data.root;
|
|
const configRelativeToProjectRoot = findNextConfigPath(
|
|
projectRoot,
|
|
// 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(projectRoot, options.nextConfig) : undefined
|
|
);
|
|
const configAbsolutePath = join(projectRoot, 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.
|
|
const helpersPath = join(options.outputPath, '.nx-helpers');
|
|
mkdirSync(helpersPath, { recursive: true });
|
|
copyFileSync(
|
|
join(__dirname, '../../../utils/compose-plugins.js'),
|
|
join(helpersPath, 'compose-plugins.js')
|
|
);
|
|
writeFileSync(join(helpersPath, 'with-nx.js'), getWithNxContent());
|
|
writeFileSync(
|
|
join(helpersPath, 'compiled.js'),
|
|
`
|
|
const withNx = require('./with-nx');
|
|
module.exports = withNx;
|
|
module.exports.withNx = withNx;
|
|
module.exports.composePlugins = require('./compose-plugins').composePlugins;
|
|
`
|
|
);
|
|
writeFileSync(
|
|
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,
|
|
projectRoot
|
|
);
|
|
for (const moduleFile of moduleFilesToCopy) {
|
|
const moduleFileDir = dirname(
|
|
join(context.root, options.outputPath, moduleFile)
|
|
);
|
|
|
|
mkdirSync(moduleFileDir, { recursive: true });
|
|
|
|
// We already generate a build version of package.json in the dist folder.
|
|
if (moduleFile !== 'package.json') {
|
|
copyFileSync(
|
|
join(context.root, projectRoot, moduleFile),
|
|
join(context.root, options.outputPath, moduleFile)
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
function readSource(getFile: () => string): { file: string; content: string } {
|
|
return {
|
|
file: getFile(),
|
|
content: readFileSync(getFile()).toString(),
|
|
};
|
|
}
|
|
|
|
// Exported for testing
|
|
export function getWithNxContent(
|
|
{ file, content } = readSource(() =>
|
|
join(__dirname, '../../../../plugins/with-nx.js')
|
|
)
|
|
) {
|
|
const withNxSource = ts.createSourceFile(
|
|
file,
|
|
content,
|
|
ts.ScriptTarget.Latest,
|
|
true
|
|
);
|
|
const getWithNxContextDeclaration = findNodes(
|
|
withNxSource,
|
|
ts.SyntaxKind.FunctionDeclaration
|
|
)?.find(
|
|
(node: ts.FunctionDeclaration) => node.name?.text === 'getWithNxContext'
|
|
);
|
|
if (getWithNxContextDeclaration) {
|
|
content = applyChangesToString(content, [
|
|
{
|
|
type: ChangeType.Delete,
|
|
start: getWithNxContextDeclaration.getStart(withNxSource),
|
|
length: getWithNxContextDeclaration.getWidth(withNxSource),
|
|
},
|
|
{
|
|
type: ChangeType.Insert,
|
|
index: getWithNxContextDeclaration.getStart(withNxSource),
|
|
text: stripIndents`function getWithNxContext() {
|
|
return {
|
|
workspaceRoot: '${
|
|
// For Windows, paths like C:\Users\foo\bar need to be written as C:\\Users\\foo\\bar,
|
|
// or else when the file is read back, the single "\" will be treated as an escape character.
|
|
workspaceRoot.replaceAll('\\', '\\\\')
|
|
}',
|
|
libsDir: '${workspaceLayout().libsDir}'
|
|
}
|
|
}`,
|
|
},
|
|
]);
|
|
}
|
|
|
|
return content;
|
|
}
|
|
|
|
export function findNextConfigPath(
|
|
dirname: string,
|
|
userDefinedConfigPath?: string
|
|
): string {
|
|
if (userDefinedConfigPath) {
|
|
const file = userDefinedConfigPath;
|
|
if (existsSync(join(dirname, 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',
|
|
'next.config.ts',
|
|
];
|
|
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', '.json'];
|
|
return files.map((file) => {
|
|
const providedExt = extname(file);
|
|
if (providedExt && extensions.includes(providedExt)) 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(
|
|
', '
|
|
)}`
|
|
);
|
|
}
|
|
});
|
|
}
|