nx/packages/next/src/executors/build/lib/create-next-config-file.ts
Nicholas Cunningham 51bed0e456
feat(nextjs): add support for typescript Next.js config file (#28709)
<!-- 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
2024-10-30 16:15:48 -04:00

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(
', '
)}`
);
}
});
}