326 lines
9.7 KiB
TypeScript
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';
|
|
}
|