nx/packages/devkit/src/generators/project-name-and-root-utils.ts

326 lines
9.7 KiB
TypeScript

import { prompt } from 'enquirer';
import type { ProjectType } from 'nx/src/config/workspace-json-project-json';
import type { Tree } from 'nx/src/generators/tree';
import { requireNx } from '../../nx';
import {
extractLayoutDirectory,
getWorkspaceLayout,
} from '../utils/get-workspace-layout';
import { names } from '../utils/names';
const {
joinPathFragments,
normalizePath,
logger,
readJson,
readNxJson,
updateNxJson,
} = requireNx();
export type ProjectNameAndRootFormat = 'as-provided' | 'derived';
export type ProjectGenerationOptions = {
name: string;
projectType: ProjectType;
callingGenerator: string | null;
directory?: string;
importPath?: string;
projectNameAndRootFormat?: ProjectNameAndRootFormat;
rootProject?: boolean;
};
export type ProjectNameAndRootOptions = {
/**
* Normalized full project name, including scope if name was provided with
* scope (e.g., `@scope/name`, only available when `projectNameAndRootFormat`
* is `as-provided`).
*/
projectName: string;
/**
* Normalized project root, including the layout directory if configured.
*/
projectRoot: string;
names: {
/**
* Normalized project name without scope. It's meant to be used when
* generating file names that contain the project name.
*/
projectFileName: string;
/**
* Normalized project name without scope or directory. It's meant to be used
* when generating shorter file names that contain the project name.
*/
projectSimpleName: string;
};
/**
* Normalized import path for the project.
*/
importPath?: string;
};
type ProjectNameAndRootFormats = {
'as-provided': ProjectNameAndRootOptions;
derived?: ProjectNameAndRootOptions;
};
export async function determineProjectNameAndRootOptions(
tree: Tree,
options: ProjectGenerationOptions
): Promise<
ProjectNameAndRootOptions & {
projectNameAndRootFormat: ProjectNameAndRootFormat;
}
> {
validateName(options.name, options.projectNameAndRootFormat);
const formats = getProjectNameAndRootFormats(tree, options);
const format =
options.projectNameAndRootFormat ??
(await determineFormat(tree, formats, options.callingGenerator));
return {
...formats[format],
projectNameAndRootFormat: format,
};
}
function validateName(
name: string,
projectNameAndRootFormat?: ProjectNameAndRootFormat
): void {
if (projectNameAndRootFormat === 'derived' && name.startsWith('@')) {
throw new Error(
`The project name "${name}" cannot start with "@" when the "projectNameAndRootFormat" is "derived".`
);
}
/**
* Matches two types of project names:
*
* 1. Valid npm package names (e.g., '@scope/name' or 'name').
* 2. Names starting with a letter and can contain any character except whitespace and ':'.
*
* The second case is to support the legacy behavior (^[a-zA-Z].*$) with the difference
* that it doesn't allow the ":" character. It was wrong to allow it because it would
* conflict with the notation for tasks.
*/
const pattern =
'(?:^@[a-zA-Z0-9-*~][a-zA-Z0-9-*._~]*\\/[a-zA-Z0-9-~][a-zA-Z0-9-._~]*|^[a-zA-Z][^:]*)$';
const validationRegex = new RegExp(pattern);
if (!validationRegex.test(name)) {
throw new Error(
`The project name should match the pattern "${pattern}". The provided value "${name}" does not match.`
);
}
}
async function determineFormat(
tree: Tree,
formats: ProjectNameAndRootFormats,
callingGenerator: string | null
): Promise<ProjectNameAndRootFormat> {
if (!formats.derived) {
return 'as-provided';
}
if (process.env.NX_INTERACTIVE !== 'true' || !isTTY()) {
return 'derived';
}
const asProvidedDescription = `As provided:
Name: ${formats['as-provided'].projectName}
Root: ${formats['as-provided'].projectRoot}`;
const asProvidedSelectedValue = `${formats['as-provided'].projectName} @ ${formats['as-provided'].projectRoot}`;
const derivedDescription = `Derived:
Name: ${formats['derived'].projectName}
Root: ${formats['derived'].projectRoot}`;
const derivedSelectedValue = `${formats['derived'].projectName} @ ${formats['derived'].projectRoot}`;
const result = await prompt<{ format: ProjectNameAndRootFormat }>({
type: 'select',
name: 'format',
message:
'What should be the project name and where should it be generated?',
choices: [
{
message: asProvidedDescription,
name: asProvidedSelectedValue,
},
{
message: derivedDescription,
name: derivedSelectedValue,
},
],
initial: 'as-provided' as any,
}).then(({ format }) =>
format === asProvidedSelectedValue ? 'as-provided' : 'derived'
);
const deprecationWarning =
'In Nx 18, generating projects will no longer derive the name and root. Please provide the exact project name and root in the future.';
if (result === 'as-provided' && callingGenerator) {
const { saveDefault } = await prompt<{ saveDefault: boolean }>({
type: 'confirm',
message: `Would you like to configure Nx to always take project name and root as provided for ${callingGenerator}?`,
name: 'saveDefault',
initial: true,
});
if (saveDefault) {
const nxJson = readNxJson(tree);
nxJson.generators ??= {};
nxJson.generators[callingGenerator] ??= {};
nxJson.generators[callingGenerator].projectNameAndRootFormat = result;
updateNxJson(tree, nxJson);
} else {
logger.warn(deprecationWarning);
}
} else {
logger.warn(deprecationWarning);
}
return result;
}
function getProjectNameAndRootFormats(
tree: Tree,
options: ProjectGenerationOptions
): ProjectNameAndRootFormats {
const name = names(options.name).fileName;
const directory = options.directory
? normalizePath(options.directory.replace(/^\.?\//, ''))
: undefined;
const asProvidedProjectName = name;
const asProvidedProjectDirectory = directory
? names(directory).fileName
: options.rootProject
? '.'
: asProvidedProjectName;
if (name.startsWith('@')) {
const nameWithoutScope = asProvidedProjectName.split('/')[1];
return {
'as-provided': {
projectName: asProvidedProjectName,
names: {
projectSimpleName: nameWithoutScope,
projectFileName: nameWithoutScope,
},
importPath: options.importPath ?? asProvidedProjectName,
projectRoot: asProvidedProjectDirectory,
},
};
}
let asProvidedImportPath: string;
let npmScope: string;
if (options.projectType === 'library') {
asProvidedImportPath = options.importPath;
if (!asProvidedImportPath) {
npmScope = getNpmScope(tree);
asProvidedImportPath =
asProvidedProjectDirectory === '.'
? readJson<{ name?: string }>(tree, 'package.json').name ??
getImportPath(npmScope, asProvidedProjectName)
: getImportPath(npmScope, asProvidedProjectName);
}
}
let { projectDirectory, layoutDirectory } = getDirectories(
tree,
directory,
options.projectType
);
const derivedProjectDirectoryWithoutLayout = projectDirectory
? `${names(projectDirectory).fileName}/${name}`
: options.rootProject
? '.'
: name;
// the project name uses the directory without the layout directory
const derivedProjectName =
derivedProjectDirectoryWithoutLayout === '.'
? name
: derivedProjectDirectoryWithoutLayout.replace(/\//g, '-');
const derivedSimpleProjectName = name;
let derivedProjectDirectory = derivedProjectDirectoryWithoutLayout;
if (derivedProjectDirectoryWithoutLayout !== '.') {
// prepend the layout directory
derivedProjectDirectory = joinPathFragments(
layoutDirectory,
derivedProjectDirectory
);
}
let derivedImportPath: string;
if (options.projectType === 'library') {
derivedImportPath = options.importPath;
if (!derivedImportPath) {
derivedImportPath =
derivedProjectDirectory === '.'
? readJson<{ name?: string }>(tree, 'package.json').name ??
getImportPath(npmScope, derivedProjectName)
: getImportPath(npmScope, derivedProjectDirectoryWithoutLayout);
}
}
return {
'as-provided': {
projectName: asProvidedProjectName,
names: {
projectSimpleName: asProvidedProjectName,
projectFileName: asProvidedProjectName,
},
importPath: asProvidedImportPath,
projectRoot: asProvidedProjectDirectory,
},
derived: {
projectName: derivedProjectName,
names: {
projectSimpleName: derivedSimpleProjectName,
projectFileName: derivedProjectName,
},
importPath: derivedImportPath,
projectRoot: derivedProjectDirectory,
},
};
}
function getDirectories(
tree: Tree,
directory: string | undefined,
projectType: ProjectType
): {
projectDirectory: string;
layoutDirectory: string;
} {
let { projectDirectory, layoutDirectory } = extractLayoutDirectory(directory);
if (!layoutDirectory) {
const { appsDir, libsDir } = getWorkspaceLayout(tree);
layoutDirectory = projectType === 'application' ? appsDir : libsDir;
}
return { projectDirectory, layoutDirectory };
}
function getImportPath(npmScope: string | undefined, name: string) {
return npmScope ? `${npmScope === '@' ? '' : '@'}${npmScope}/${name}` : name;
}
function getNpmScope(tree: Tree): string | undefined {
const nxJson = readNxJson(tree);
// TODO(v17): Remove reading this from nx.json
if (nxJson?.npmScope) {
return nxJson.npmScope;
}
const { name } = tree.exists('package.json')
? readJson<{ name?: string }>(tree, 'package.json')
: { name: null };
return name?.startsWith('@') ? name.split('/')[0].substring(1) : undefined;
}
function isTTY(): boolean {
return !!process.stdout.isTTY && process.env['CI'] !== 'true';
}