diff --git a/docs/generated/packages/angular/generators/component.json b/docs/generated/packages/angular/generators/component.json
index 1d786ff1a5a8b..8af01710c8708 100644
--- a/docs/generated/packages/angular/generators/component.json
+++ b/docs/generated/packages/angular/generators/component.json
@@ -1,32 +1,38 @@
{
"name": "component",
- "factory": "./src/generators/component/component",
+ "factory": "./src/generators/component/component#componentGeneratorInternal",
"schema": {
"$schema": "http://json-schema.org/draft-07/schema",
"$id": "SchematicsAngularComponent",
"title": "Angular Component Schema",
"cli": "nx",
"type": "object",
- "description": "Creates a new, generic Angular component definition in the given or default project.",
+ "description": "Creates a new Angular component.",
"additionalProperties": false,
"properties": {
- "path": {
+ "name": {
+ "type": "string",
+ "description": "The name of the component.",
+ "$default": { "$source": "argv", "index": 0 },
+ "x-prompt": "What name would you like to use for the component?"
+ },
+ "directory": {
+ "type": "string",
+ "description": "The directory at which to create the component file. When `--nameAndDirectoryFormat=as-provided`, it will be relative to the current working directory. Otherwise, it will be relative to the workspace root.",
+ "aliases": ["dir", "path"],
+ "x-priority": "important"
+ },
+ "nameAndDirectoryFormat": {
+ "description": "Whether to generate the component in the directory as provided, relative to the current working directory and ignoring the project (`as-provided`) or generate it using the project and directory relative to the workspace root (`derived`).",
"type": "string",
- "format": "path",
- "description": "The path at which to create the component file, relative to the current workspace. Default is a folder with the same name as the component in the project root.",
- "visible": false
+ "enum": ["as-provided", "derived"]
},
"project": {
"type": "string",
"description": "The name of the project.",
"$default": { "$source": "projectName" },
- "x-dropdown": "projects"
- },
- "name": {
- "type": "string",
- "description": "The name of the component.",
- "$default": { "$source": "argv", "index": 0 },
- "x-prompt": "What name would you like to use for the component?"
+ "x-dropdown": "projects",
+ "x-deprecated": "Provide the `directory` option instead and use the `as-provided` format. The project will be determined from the directory provided. It will be removed in Nx v18."
},
"prefix": {
"type": "string",
@@ -89,7 +95,8 @@
"flat": {
"type": "boolean",
"description": "Create the new files at the top level of the current project.",
- "default": false
+ "default": false,
+ "x-deprecated": "Provide the `directory` option instead and use the `as-provided` format. It will be removed in Nx v18."
},
"skipImport": {
"type": "boolean",
@@ -124,13 +131,13 @@
"x-priority": "internal"
}
},
- "required": ["name", "project"],
+ "required": ["name"],
"examplesFile": "## Examples\n\n{% tabs %}\n{% tab label=\"Simple Component\" %}\n\nCreate a component named `my-component`:\n\n```bash\nnx g @nx/angular:component my-component\n```\n\n{% /tab %}\n\n{% tab label=\"Standalone Component\" %}\n\nCreate a standalone component named `my-component`:\n\n```bash\nnx g @nx/angular:component my-component --standalone\n```\n\n{% /tab %}\n\n{% tab label=\"Single File Component\" %}\n\nCreate a component named `my-component` with inline styles and inline template:\n\n```bash\nnx g @nx/angular:component my-component --inlineStyle --inlineTemplate\n```\n\n{% /tab %}\n\n{% tab label=\"Component with OnPush Change Detection Strategy\" %}\n\nCreate a component named `my-component` with OnPush Change Detection Strategy:\n\n```bash\nnx g @nx/angular:component my-component --changeDetection=OnPush\n```\n\n{% /tab %}\n",
"presets": []
},
"aliases": ["c"],
"description": "Generate an Angular Component.",
- "implementation": "/packages/angular/src/generators/component/component.ts",
+ "implementation": "/packages/angular/src/generators/component/component#componentGeneratorInternal.ts",
"hidden": false,
"path": "/packages/angular/src/generators/component/schema.json",
"type": "generator"
diff --git a/packages/angular/generators.json b/packages/angular/generators.json
index 1f4ffbe4ea583..5d632cdaec398 100644
--- a/packages/angular/generators.json
+++ b/packages/angular/generators.json
@@ -165,7 +165,7 @@
"description": "Creates an Angular application."
},
"component": {
- "factory": "./src/generators/component/component",
+ "factory": "./src/generators/component/component#componentGeneratorInternal",
"schema": "./src/generators/component/schema.json",
"aliases": ["c"],
"description": "Generate an Angular Component."
diff --git a/packages/angular/src/generators/component/component.ts b/packages/angular/src/generators/component/component.ts
index 3b76b3cc0c5a4..5f9c3a383561c 100644
--- a/packages/angular/src/generators/component/component.ts
+++ b/packages/angular/src/generators/component/component.ts
@@ -15,21 +15,27 @@ import {
import type { Schema } from './schema';
export async function componentGenerator(tree: Tree, rawOptions: Schema) {
- validateOptions(tree, rawOptions);
- const options = normalizeOptions(tree, rawOptions);
+ await componentGeneratorInternal(tree, {
+ nameAndDirectoryFormat: 'derived',
+ ...rawOptions,
+ });
+}
- const componentNames = names(options.name);
- const typeNames = names(options.type);
+export async function componentGeneratorInternal(
+ tree: Tree,
+ rawOptions: Schema
+) {
+ validateOptions(tree, rawOptions);
+ const options = await normalizeOptions(tree, rawOptions);
generateFiles(
tree,
joinPathFragments(__dirname, 'files'),
options.directory,
{
- fileName: componentNames.fileName,
- className: componentNames.className,
- type: typeNames.fileName,
- typeClassName: typeNames.className,
+ name: options.name,
+ fileName: options.fileName,
+ symbolName: options.symbolName,
style: options.style,
inlineStyle: options.inlineStyle,
inlineTemplate: options.inlineTemplate,
@@ -46,7 +52,7 @@ export async function componentGenerator(tree: Tree, rawOptions: Schema) {
if (options.skipTests) {
const pathToSpecFile = joinPathFragments(
options.directory,
- `${componentNames.fileName}.${typeNames.fileName}.spec.ts`
+ `${options.fileName}.spec.ts`
);
tree.delete(pathToSpecFile);
@@ -55,7 +61,7 @@ export async function componentGenerator(tree: Tree, rawOptions: Schema) {
if (options.inlineTemplate) {
const pathToTemplateFile = joinPathFragments(
options.directory,
- `${componentNames.fileName}.${typeNames.fileName}.html`
+ `${options.fileName}.html`
);
tree.delete(pathToTemplateFile);
@@ -64,7 +70,7 @@ export async function componentGenerator(tree: Tree, rawOptions: Schema) {
if (options.style === 'none' || options.inlineStyle) {
const pathToStyleFile = joinPathFragments(
options.directory,
- `${componentNames.fileName}.${typeNames.fileName}.${options.style}`
+ `${options.fileName}.${options.style}`
);
tree.delete(pathToStyleFile);
@@ -78,13 +84,13 @@ export async function componentGenerator(tree: Tree, rawOptions: Schema) {
);
addToNgModule(
tree,
- options.path,
+ options.directory,
modulePath,
- componentNames.fileName,
- `${componentNames.className}${typeNames.className}`,
- `${componentNames.fileName}.${typeNames.fileName}`,
+ options.name,
+ options.symbolName,
+ options.fileName,
'declarations',
- options.flat,
+ true,
options.export
);
}
diff --git a/packages/angular/src/generators/component/files/__fileName__.__type__.__style__ b/packages/angular/src/generators/component/files/__fileName__.__style__
similarity index 100%
rename from packages/angular/src/generators/component/files/__fileName__.__type__.__style__
rename to packages/angular/src/generators/component/files/__fileName__.__style__
diff --git a/packages/angular/src/generators/component/files/__fileName__.__type__.html__tpl__ b/packages/angular/src/generators/component/files/__fileName__.__type__.html__tpl__
deleted file mode 100644
index 2fd10df414b4e..0000000000000
--- a/packages/angular/src/generators/component/files/__fileName__.__type__.html__tpl__
+++ /dev/null
@@ -1 +0,0 @@
-
<%= fileName %> works!
diff --git a/packages/angular/src/generators/component/files/__fileName__.__type__.spec.ts__tpl__ b/packages/angular/src/generators/component/files/__fileName__.__type__.spec.ts__tpl__
deleted file mode 100644
index 3a6f444f216e9..0000000000000
--- a/packages/angular/src/generators/component/files/__fileName__.__type__.spec.ts__tpl__
+++ /dev/null
@@ -1,22 +0,0 @@
-import { ComponentFixture, TestBed } from '@angular/core/testing';
-import { <%= className %><%= typeClassName %> } from './<%= fileName %><%= type ? '.' + type: '' %>';
-
-describe('<%= className %><%= typeClassName %>', () => {
- let component: <%= className %><%= typeClassName %>;
- let fixture: ComponentFixture<<%= className %><%= typeClassName %>>;
-
- beforeEach(async () => {
- await TestBed.configureTestingModule({
- <%= standalone ? 'imports' : 'declarations' %>: [ <%= className %><%= typeClassName %> ]
- })
- .compileComponents();
-
- fixture = TestBed.createComponent(<%= className %><%= typeClassName %>);
- component = fixture.componentInstance;
- fixture.detectChanges();
- });
-
- it('should create', () => {
- expect(component).toBeTruthy();
- });
-});
diff --git a/packages/angular/src/generators/component/files/__fileName__.html__tpl__ b/packages/angular/src/generators/component/files/__fileName__.html__tpl__
new file mode 100644
index 0000000000000..0b2a8c4bdcfac
--- /dev/null
+++ b/packages/angular/src/generators/component/files/__fileName__.html__tpl__
@@ -0,0 +1 @@
+<%= name %> works!
diff --git a/packages/angular/src/generators/component/files/__fileName__.spec.ts__tpl__ b/packages/angular/src/generators/component/files/__fileName__.spec.ts__tpl__
new file mode 100644
index 0000000000000..b95c25c90e598
--- /dev/null
+++ b/packages/angular/src/generators/component/files/__fileName__.spec.ts__tpl__
@@ -0,0 +1,22 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { <%= symbolName %> } from './<%= fileName %>';
+
+describe('<%= symbolName %>', () => {
+ let component: <%= symbolName %>;
+ let fixture: ComponentFixture<<%= symbolName %>>;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ <%= standalone ? 'imports' : 'declarations' %>: [ <%= symbolName %> ]
+ })
+ .compileComponents();
+
+ fixture = TestBed.createComponent(<%= symbolName %>);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/packages/angular/src/generators/component/files/__fileName__.__type__.ts__tpl__ b/packages/angular/src/generators/component/files/__fileName__.ts__tpl__
similarity index 69%
rename from packages/angular/src/generators/component/files/__fileName__.__type__.ts__tpl__
rename to packages/angular/src/generators/component/files/__fileName__.ts__tpl__
index 6cb79a450b3d3..a5e19026fff95 100644
--- a/packages/angular/src/generators/component/files/__fileName__.__type__.ts__tpl__
+++ b/packages/angular/src/generators/component/files/__fileName__.ts__tpl__
@@ -5,8 +5,8 @@ import { CommonModule } from '@angular/common';<% } %>
selector: '<%= selector %>',<%}%><% if(standalone) {%>
standalone: true,
imports: [CommonModule],<%}%><% if(inlineTemplate) { %>
- template: `<%= fileName %> works!
`<% } else { %>
- templateUrl: './<%= fileName %><%= type ? '.' + type : '' %>.html'<% } if(inlineStyle) { %>,
+ template: `<%= name %> works!
`<% } else { %>
+ templateUrl: './<%= fileName %>.html'<% } if(inlineStyle) { %>,
styles: [<% if(displayBlock){ %>
`
:host {
@@ -14,8 +14,8 @@ import { CommonModule } from '@angular/common';<% } %>
}
`<% } %>
]<% } else if (style !== 'none') { %>,
- styleUrls: ['./<%= fileName %><%= type ? '.' + type : '' %>.<%= style %>']<% } %><% if(!!viewEncapsulation) { %>,
+ styleUrls: ['./<%= fileName %>.<%= style %>']<% } %><% if(!!viewEncapsulation) { %>,
encapsulation: ViewEncapsulation.<%= viewEncapsulation %><% } if (changeDetection !== 'Default') { %>,
changeDetection: ChangeDetectionStrategy.<%= changeDetection %><% } %>
})
-export class <%= className %><%= typeClassName %> {}
+export class <%= symbolName %> {}
diff --git a/packages/angular/src/generators/component/lib/module.ts b/packages/angular/src/generators/component/lib/module.ts
index e6648666f531b..57120371f9166 100644
--- a/packages/angular/src/generators/component/lib/module.ts
+++ b/packages/angular/src/generators/component/lib/module.ts
@@ -16,11 +16,11 @@ export function findModuleFromOptions(
if (!options.module) {
return normalizePath(findModule(tree, options.directory, projectRoot));
} else {
- const modulePath = joinPathFragments(options.path, options.module);
+ const modulePath = joinPathFragments(options.directory, options.module);
const componentPath = options.directory;
const moduleBaseName = basename(modulePath);
- const candidateSet = new Set([options.path]);
+ const candidateSet = new Set([options.directory]);
const projectRootParent = dirname(projectRoot);
for (let dir = modulePath; dir !== projectRootParent; dir = dirname(dir)) {
diff --git a/packages/angular/src/generators/component/lib/normalize-options.ts b/packages/angular/src/generators/component/lib/normalize-options.ts
index 5ca348798e115..10581ff11bf18 100644
--- a/packages/angular/src/generators/component/lib/normalize-options.ts
+++ b/packages/angular/src/generators/component/lib/normalize-options.ts
@@ -1,21 +1,39 @@
import type { Tree } from '@nx/devkit';
-import { readProjectConfiguration } from '@nx/devkit';
+import { names, readProjectConfiguration } from '@nx/devkit';
+import { determineArtifactNameAndDirectoryOptions } from '@nx/devkit/src/generators/artifact-name-and-directory-utils';
import type { AngularProjectConfiguration } from '../../../utils/types';
-import { normalizeNameAndPaths } from '../../utils/path';
import { buildSelector } from '../../utils/selector';
import type { NormalizedSchema, Schema } from '../schema';
-export function normalizeOptions(
+export async function normalizeOptions(
tree: Tree,
options: Schema
-): NormalizedSchema {
+): Promise {
options.type ??= 'component';
- const { directory, filePath, name, path, root, sourceRoot } =
- normalizeNameAndPaths(tree, options);
+ const {
+ artifactName: name,
+ directory,
+ fileName,
+ filePath,
+ project,
+ } = await determineArtifactNameAndDirectoryOptions(tree, {
+ artifactType: 'component',
+ callingGenerator: '@nx/angular:component',
+ name: options.name,
+ directory: options.directory ?? options.path,
+ flat: options.flat,
+ nameAndDirectoryFormat: options.nameAndDirectoryFormat,
+ project: options.project,
+ suffix: options.type ?? 'component',
+ });
+
+ const { className } = names(name);
+ const { className: suffixClassName } = names(options.type);
+ const symbolName = `${className}${suffixClassName}`;
- const { prefix } = readProjectConfiguration(
+ const { prefix, root, sourceRoot } = readProjectConfiguration(
tree,
- options.project
+ project
) as AngularProjectConfiguration;
const selector =
@@ -25,12 +43,13 @@ export function normalizeOptions(
return {
...options,
name,
+ project,
changeDetection: options.changeDetection ?? 'Default',
style: options.style ?? 'css',
- flat: options.flat ?? false,
directory,
+ fileName,
filePath,
- path,
+ symbolName,
projectSourceRoot: sourceRoot,
projectRoot: root,
selector,
diff --git a/packages/angular/src/generators/component/lib/validate-options.ts b/packages/angular/src/generators/component/lib/validate-options.ts
index 01ce0032013ff..d51a443cd84fb 100644
--- a/packages/angular/src/generators/component/lib/validate-options.ts
+++ b/packages/angular/src/generators/component/lib/validate-options.ts
@@ -1,13 +1,7 @@
import type { Tree } from '@nx/devkit';
-import {
- validatePathIsUnderProjectRoot,
- validateProject,
- validateStandaloneOption,
-} from '../../utils/validations';
+import { validateStandaloneOption } from '../../utils/validations';
import type { Schema } from '../schema';
export function validateOptions(tree: Tree, options: Schema): void {
- validateProject(tree, options.project);
- validatePathIsUnderProjectRoot(tree, options.project, options.path);
validateStandaloneOption(tree, options.standalone);
}
diff --git a/packages/angular/src/generators/component/schema.d.ts b/packages/angular/src/generators/component/schema.d.ts
index 510d1c099bf70..8564b49f16e76 100644
--- a/packages/angular/src/generators/component/schema.d.ts
+++ b/packages/angular/src/generators/component/schema.d.ts
@@ -1,7 +1,9 @@
+import type { NameAndDirectoryFormat } from '@nx/devkit/src/generators/artifact-name-and-directory-utils';
+
export interface Schema {
name: string;
- project: string;
- path?: string;
+ directory?: string;
+ nameAndDirectoryFormat?: NameAndDirectoryFormat;
displayBlock?: boolean;
inlineStyle?: boolean;
inlineTemplate?: boolean;
@@ -11,7 +13,6 @@ export interface Schema {
style?: 'css' | 'scss' | 'sass' | 'less' | 'none';
skipTests?: boolean;
type?: string;
- flat?: boolean;
skipImport?: boolean;
selector?: string;
module?: string;
@@ -19,12 +20,29 @@ export interface Schema {
export?: boolean;
prefix?: string;
skipFormat?: boolean;
+
+ /**
+ * @deprecated Provide the `directory` option instead and use the `as-provided` format. It will be removed in Nx v18.
+ */
+ flat?: boolean;
+ /**
+ * @deprecated Provide the `directory` option instead. It will be removed in Nx v18.
+ */
+ path?: string;
+ /**
+ * @deprecated Provide the `directory` option instead. The project will be determined from the directory provided. It will be removed in Nx v18.
+ */
+ project?: string;
}
export interface NormalizedSchema extends Schema {
directory: string;
filePath: string;
+ project: string;
projectSourceRoot: string;
projectRoot: string;
selector: string;
+
+ fileName: string;
+ symbolName: string;
}
diff --git a/packages/angular/src/generators/component/schema.json b/packages/angular/src/generators/component/schema.json
index 03823ac4d84e9..d66db2fcdc5d4 100644
--- a/packages/angular/src/generators/component/schema.json
+++ b/packages/angular/src/generators/component/schema.json
@@ -4,23 +4,9 @@
"title": "Angular Component Schema",
"cli": "nx",
"type": "object",
- "description": "Creates a new, generic Angular component definition in the given or default project.",
+ "description": "Creates a new Angular component.",
"additionalProperties": false,
"properties": {
- "path": {
- "type": "string",
- "format": "path",
- "description": "The path at which to create the component file, relative to the current workspace. Default is a folder with the same name as the component in the project root.",
- "visible": false
- },
- "project": {
- "type": "string",
- "description": "The name of the project.",
- "$default": {
- "$source": "projectName"
- },
- "x-dropdown": "projects"
- },
"name": {
"type": "string",
"description": "The name of the component.",
@@ -30,6 +16,26 @@
},
"x-prompt": "What name would you like to use for the component?"
},
+ "directory": {
+ "type": "string",
+ "description": "The directory at which to create the component file. When `--nameAndDirectoryFormat=as-provided`, it will be relative to the current working directory. Otherwise, it will be relative to the workspace root.",
+ "aliases": ["dir", "path"],
+ "x-priority": "important"
+ },
+ "nameAndDirectoryFormat": {
+ "description": "Whether to generate the component in the directory as provided, relative to the current working directory and ignoring the project (`as-provided`) or generate it using the project and directory relative to the workspace root (`derived`).",
+ "type": "string",
+ "enum": ["as-provided", "derived"]
+ },
+ "project": {
+ "type": "string",
+ "description": "The name of the project.",
+ "$default": {
+ "$source": "projectName"
+ },
+ "x-dropdown": "projects",
+ "x-deprecated": "Provide the `directory` option instead and use the `as-provided` format. The project will be determined from the directory provided. It will be removed in Nx v18."
+ },
"prefix": {
"type": "string",
"description": "The prefix to apply to the generated component selector.",
@@ -91,7 +97,8 @@
"flat": {
"type": "boolean",
"description": "Create the new files at the top level of the current project.",
- "default": false
+ "default": false,
+ "x-deprecated": "Provide the `directory` option instead and use the `as-provided` format. It will be removed in Nx v18."
},
"skipImport": {
"type": "boolean",
@@ -126,6 +133,6 @@
"x-priority": "internal"
}
},
- "required": ["name", "project"],
+ "required": ["name"],
"examplesFile": "../../../docs/component-examples.md"
}
diff --git a/packages/angular/src/generators/utils/find-module.ts b/packages/angular/src/generators/utils/find-module.ts
index 0d7af2b46b3f2..aae9d9c69a933 100644
--- a/packages/angular/src/generators/utils/find-module.ts
+++ b/packages/angular/src/generators/utils/find-module.ts
@@ -58,6 +58,8 @@ export function addToNgModule(
className: string,
fileName: string,
ngModuleProperty: ngModuleDecoratorProperty,
+ // TODO(leo): remove once all consumers are updated
+ // // check if exported in the public api
isFlat = true,
isExported = false
) {
diff --git a/packages/devkit/src/generators/artifact-name-and-directory-utils.spec.ts b/packages/devkit/src/generators/artifact-name-and-directory-utils.spec.ts
new file mode 100644
index 0000000000000..2b4656c63a769
--- /dev/null
+++ b/packages/devkit/src/generators/artifact-name-and-directory-utils.spec.ts
@@ -0,0 +1,797 @@
+import * as enquirer from 'enquirer';
+import { addProjectConfiguration } from 'nx/src/devkit-exports';
+import { createTreeWithEmptyWorkspace } from 'nx/src/generators/testing-utils/create-tree-with-empty-workspace';
+import type { Tree } from 'nx/src/generators/tree';
+import { workspaceRoot } from 'nx/src/utils/workspace-root';
+import { join } from 'path';
+import { determineArtifactNameAndDirectoryOptions } from './artifact-name-and-directory-utils';
+
+describe('determineArtifactNameAndDirectoryOptions', () => {
+ let tree: Tree;
+ let originalInteractiveValue;
+ let originalCIValue;
+ let originalIsTTYValue;
+ let originalInitCwd;
+
+ function ensureInteractiveMode() {
+ process.env.NX_INTERACTIVE = 'true';
+ process.env.CI = 'false';
+ process.stdout.isTTY = true;
+ }
+
+ function restoreOriginalInteractiveMode() {
+ process.env.NX_INTERACTIVE = originalInteractiveValue;
+ process.env.CI = originalCIValue;
+ process.stdout.isTTY = originalIsTTYValue;
+ }
+
+ function setCwd(path: string) {
+ process.env.INIT_CWD = join(workspaceRoot, path);
+ }
+
+ function restoreCwd() {
+ if (originalInitCwd === undefined) {
+ delete process.env.INIT_CWD;
+ } else {
+ process.env.INIT_CWD = originalInitCwd;
+ }
+ }
+
+ beforeEach(() => {
+ tree = createTreeWithEmptyWorkspace();
+ jest.clearAllMocks();
+
+ originalInteractiveValue = process.env.NX_INTERACTIVE;
+ originalCIValue = process.env.CI;
+ originalIsTTYValue = process.stdout.isTTY;
+ originalInitCwd = process.env.INIT_CWD;
+ });
+
+ it('should throw an error when the resolver directory is not under any project root', async () => {
+ addProjectConfiguration(tree, 'app1', {
+ root: 'apps/app1',
+ projectType: 'application',
+ });
+ setCwd('some/path');
+
+ await expect(
+ determineArtifactNameAndDirectoryOptions(tree, {
+ name: 'myComponent',
+ artifactType: 'component',
+ callingGenerator: '@my-org/my-plugin:component',
+ })
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
+ `"The current working directory "some/path" does not exist under any project root. Please make sure to navigate to a location or provide a directory that exists under a project root."`
+ );
+
+ restoreCwd();
+ });
+
+ it('should throw an error when the provided project does not exist', async () => {
+ await expect(
+ determineArtifactNameAndDirectoryOptions(tree, {
+ name: 'myComponent',
+ project: 'app1',
+ artifactType: 'component',
+ callingGenerator: '@my-org/my-plugin:component',
+ })
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
+ `"The provided project "app1" does not exist! Please provide an existing project name."`
+ );
+ });
+
+ it('should throw when receiving a path as the name and a directory', async () => {
+ addProjectConfiguration(tree, 'app1', {
+ root: 'apps/app1',
+ projectType: 'application',
+ });
+
+ await expect(
+ determineArtifactNameAndDirectoryOptions(tree, {
+ name: 'apps/app1/foo/bar/myComponent',
+ directory: 'foo/bar',
+ artifactType: 'component',
+ callingGenerator: '@my-org/my-plugin:component',
+ })
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
+ `"You can't specify both a directory (foo/bar) and a name with a directory path (apps/app1/foo/bar/myComponent). Please specify either a directory or a name with a directory path."`
+ );
+ });
+
+ describe('as-provided', () => {
+ it('should return options as provided when there is a project at the cwd', async () => {
+ addProjectConfiguration(tree, 'app1', {
+ root: 'apps/app1',
+ projectType: 'application',
+ });
+ setCwd('apps/app1');
+
+ const result = await determineArtifactNameAndDirectoryOptions(tree, {
+ name: 'myComponent',
+ nameAndDirectoryFormat: 'as-provided',
+ artifactType: 'component',
+ callingGenerator: '@my-org/my-plugin:component',
+ });
+
+ expect(result).toStrictEqual({
+ artifactName: 'myComponent',
+ directory: 'apps/app1',
+ fileName: 'myComponent',
+ filePath: 'apps/app1/myComponent.ts',
+ project: 'app1',
+ nameAndDirectoryFormat: 'as-provided',
+ });
+
+ restoreCwd();
+ });
+
+ it('should return the options as provided when directory is provided', async () => {
+ addProjectConfiguration(tree, 'app1', {
+ root: 'apps/app1',
+ projectType: 'application',
+ });
+
+ const result = await determineArtifactNameAndDirectoryOptions(tree, {
+ name: 'myComponent',
+ directory: 'apps/app1',
+ nameAndDirectoryFormat: 'as-provided',
+ artifactType: 'component',
+ callingGenerator: '@my-org/my-plugin:component',
+ });
+
+ expect(result).toStrictEqual({
+ artifactName: 'myComponent',
+ directory: 'apps/app1',
+ fileName: 'myComponent',
+ filePath: 'apps/app1/myComponent.ts',
+ project: 'app1',
+ nameAndDirectoryFormat: 'as-provided',
+ });
+ });
+
+ it(`should handle window's style paths correctly`, async () => {
+ addProjectConfiguration(tree, 'app1', {
+ root: 'apps/app1',
+ projectType: 'application',
+ });
+
+ const result = await determineArtifactNameAndDirectoryOptions(tree, {
+ name: 'myComponent',
+ directory: 'apps\\app1',
+ nameAndDirectoryFormat: 'as-provided',
+ artifactType: 'component',
+ callingGenerator: '@my-org/my-plugin:component',
+ });
+
+ expect(result).toStrictEqual({
+ artifactName: 'myComponent',
+ directory: 'apps/app1',
+ fileName: 'myComponent',
+ filePath: 'apps/app1/myComponent.ts',
+ project: 'app1',
+ nameAndDirectoryFormat: 'as-provided',
+ });
+ });
+
+ it('should ignore the project and use the provided directory', async () => {
+ addProjectConfiguration(tree, 'app1', {
+ root: 'apps/app1',
+ projectType: 'application',
+ });
+ addProjectConfiguration(tree, 'app2', {
+ root: 'apps/app2',
+ projectType: 'application',
+ });
+
+ const result = await determineArtifactNameAndDirectoryOptions(tree, {
+ name: 'myComponent',
+ project: 'app2',
+ directory: 'apps/app1/foo/bar',
+ nameAndDirectoryFormat: 'as-provided',
+ artifactType: 'component',
+ callingGenerator: '@my-org/my-plugin:component',
+ });
+
+ expect(result).toStrictEqual({
+ artifactName: 'myComponent',
+ directory: 'apps/app1/foo/bar',
+ fileName: 'myComponent',
+ filePath: 'apps/app1/foo/bar/myComponent.ts',
+ project: 'app1',
+ nameAndDirectoryFormat: 'as-provided',
+ });
+ });
+
+ it('should support receiving a path as the name', async () => {
+ addProjectConfiguration(tree, 'app1', {
+ root: 'apps/app1',
+ projectType: 'application',
+ });
+
+ const result = await determineArtifactNameAndDirectoryOptions(tree, {
+ name: 'apps/app1/foo/bar/myComponent',
+ nameAndDirectoryFormat: 'as-provided',
+ artifactType: 'component',
+ callingGenerator: '@my-org/my-plugin:component',
+ });
+
+ expect(result).toStrictEqual({
+ artifactName: 'myComponent',
+ directory: 'apps/app1/foo/bar',
+ fileName: 'myComponent',
+ filePath: 'apps/app1/foo/bar/myComponent.ts',
+ project: 'app1',
+ nameAndDirectoryFormat: 'as-provided',
+ });
+ });
+
+ it('should support receiving a suffix', async () => {
+ addProjectConfiguration(tree, 'app1', {
+ root: 'apps/app1',
+ projectType: 'application',
+ });
+
+ const result = await determineArtifactNameAndDirectoryOptions(tree, {
+ name: 'myComponent',
+ suffix: 'component',
+ directory: 'apps/app1',
+ nameAndDirectoryFormat: 'as-provided',
+ artifactType: 'component',
+ callingGenerator: '@my-org/my-plugin:component',
+ });
+
+ expect(result).toStrictEqual({
+ artifactName: 'myComponent',
+ directory: 'apps/app1',
+ fileName: 'myComponent.component',
+ filePath: 'apps/app1/myComponent.component.ts',
+ project: 'app1',
+ nameAndDirectoryFormat: 'as-provided',
+ });
+ });
+
+ it('should support receiving a fileName', async () => {
+ addProjectConfiguration(tree, 'app1', {
+ root: 'apps/app1',
+ projectType: 'application',
+ });
+
+ const result = await determineArtifactNameAndDirectoryOptions(tree, {
+ name: 'myComponent',
+ fileName: 'myComponent.component',
+ directory: 'apps/app1',
+ nameAndDirectoryFormat: 'as-provided',
+ artifactType: 'component',
+ callingGenerator: '@my-org/my-plugin:component',
+ });
+
+ expect(result).toStrictEqual({
+ artifactName: 'myComponent',
+ directory: 'apps/app1',
+ fileName: 'myComponent.component',
+ filePath: 'apps/app1/myComponent.component.ts',
+ project: 'app1',
+ nameAndDirectoryFormat: 'as-provided',
+ });
+ });
+
+ it('should ignore "--pascalCaseFile"', async () => {
+ addProjectConfiguration(tree, 'app1', {
+ root: 'apps/app1',
+ projectType: 'application',
+ });
+
+ const result = await determineArtifactNameAndDirectoryOptions(tree, {
+ name: 'myComponent',
+ pascalCaseFile: true,
+ directory: 'apps/app1',
+ nameAndDirectoryFormat: 'as-provided',
+ artifactType: 'component',
+ callingGenerator: '@my-org/my-plugin:component',
+ });
+
+ expect(result).toStrictEqual({
+ artifactName: 'myComponent',
+ directory: 'apps/app1',
+ fileName: 'myComponent',
+ filePath: 'apps/app1/myComponent.ts',
+ project: 'app1',
+ nameAndDirectoryFormat: 'as-provided',
+ });
+ });
+
+ it('should ignore "--pascalCaseDirectory"', async () => {
+ addProjectConfiguration(tree, 'app1', {
+ root: 'apps/app1',
+ projectType: 'application',
+ });
+
+ const result = await determineArtifactNameAndDirectoryOptions(tree, {
+ name: 'myComponent',
+ pascalCaseDirectory: true,
+ directory: 'apps/app1',
+ nameAndDirectoryFormat: 'as-provided',
+ artifactType: 'component',
+ callingGenerator: '@my-org/my-plugin:component',
+ });
+
+ expect(result).toStrictEqual({
+ artifactName: 'myComponent',
+ directory: 'apps/app1',
+ fileName: 'myComponent',
+ filePath: 'apps/app1/myComponent.ts',
+ project: 'app1',
+ nameAndDirectoryFormat: 'as-provided',
+ });
+ });
+
+ it('should support receiving a different file extension', async () => {
+ addProjectConfiguration(tree, 'app1', {
+ root: 'apps/app1',
+ projectType: 'application',
+ });
+
+ const result = await determineArtifactNameAndDirectoryOptions(tree, {
+ name: 'myComponent',
+ fileExtension: 'tsx',
+ directory: 'apps/app1',
+ nameAndDirectoryFormat: 'as-provided',
+ artifactType: 'component',
+ callingGenerator: '@my-org/my-plugin:component',
+ });
+
+ expect(result).toStrictEqual({
+ artifactName: 'myComponent',
+ directory: 'apps/app1',
+ fileName: 'myComponent',
+ filePath: 'apps/app1/myComponent.tsx',
+ project: 'app1',
+ nameAndDirectoryFormat: 'as-provided',
+ });
+ });
+ });
+
+ describe('derived', () => {
+ it('should infer project and return options when project is not provided and there is a project at the cwd', async () => {
+ addProjectConfiguration(tree, 'app1', {
+ root: 'apps/app1',
+ projectType: 'application',
+ });
+ setCwd('apps/app1');
+
+ const result = await determineArtifactNameAndDirectoryOptions(tree, {
+ name: 'myComponent',
+ nameAndDirectoryFormat: 'derived',
+ artifactType: 'component',
+ callingGenerator: '@my-org/my-plugin:component',
+ });
+
+ expect(result).toStrictEqual({
+ artifactName: 'myComponent',
+ directory: 'apps/app1/src/app/my-component',
+ fileName: 'my-component',
+ filePath: 'apps/app1/src/app/my-component/my-component.ts',
+ project: 'app1',
+ nameAndDirectoryFormat: 'derived',
+ });
+
+ restoreCwd();
+ });
+
+ it('should support receiving a directory correctly under the inferred project root', async () => {
+ addProjectConfiguration(tree, 'app1', {
+ root: 'apps/app1',
+ projectType: 'application',
+ });
+
+ const result = await determineArtifactNameAndDirectoryOptions(tree, {
+ name: 'myComponent',
+ directory: 'apps/app1',
+ nameAndDirectoryFormat: 'derived',
+ artifactType: 'component',
+ callingGenerator: '@my-org/my-plugin:component',
+ });
+
+ expect(result).toStrictEqual({
+ artifactName: 'myComponent',
+ directory: 'apps/app1/my-component',
+ fileName: 'my-component',
+ filePath: 'apps/app1/my-component/my-component.ts',
+ project: 'app1',
+ nameAndDirectoryFormat: 'derived',
+ });
+ });
+
+ it(`should handle window's style paths correctly`, async () => {
+ addProjectConfiguration(tree, 'app1', {
+ root: 'apps/app1',
+ projectType: 'application',
+ });
+
+ const result = await determineArtifactNameAndDirectoryOptions(tree, {
+ name: 'myComponent',
+ directory: 'apps\\app1',
+ nameAndDirectoryFormat: 'derived',
+ artifactType: 'component',
+ callingGenerator: '@my-org/my-plugin:component',
+ });
+
+ expect(result).toStrictEqual({
+ artifactName: 'myComponent',
+ directory: 'apps/app1/my-component',
+ fileName: 'my-component',
+ filePath: 'apps/app1/my-component/my-component.ts',
+ project: 'app1',
+ nameAndDirectoryFormat: 'derived',
+ });
+ });
+
+ it('should support receiving a project', async () => {
+ addProjectConfiguration(tree, 'app1', {
+ root: 'apps/app1',
+ projectType: 'application',
+ });
+
+ const result = await determineArtifactNameAndDirectoryOptions(tree, {
+ name: 'myComponent',
+ project: 'app1',
+ nameAndDirectoryFormat: 'derived',
+ artifactType: 'component',
+ callingGenerator: '@my-org/my-plugin:component',
+ });
+
+ expect(result).toStrictEqual({
+ artifactName: 'myComponent',
+ directory: 'apps/app1/src/app/my-component',
+ fileName: 'my-component',
+ filePath: 'apps/app1/src/app/my-component/my-component.ts',
+ project: 'app1',
+ nameAndDirectoryFormat: 'derived',
+ });
+ });
+
+ it('should throw when the provided directory is not under the provided project root', async () => {
+ addProjectConfiguration(tree, 'app1', {
+ root: 'apps/app1',
+ projectType: 'application',
+ });
+
+ await expect(
+ determineArtifactNameAndDirectoryOptions(tree, {
+ name: 'myComponent',
+ project: 'app1',
+ directory: 'foo/bar',
+ nameAndDirectoryFormat: 'derived',
+ artifactType: 'component',
+ callingGenerator: '@my-org/my-plugin:component',
+ })
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
+ `"The provided directory "foo/bar" is not under the provided project root "apps/app1". Please provide a directory that is under the provided project root or use the "as-provided" format and only provide the directory."`
+ );
+ });
+
+ it('should support receiving a path as the name', async () => {
+ addProjectConfiguration(tree, 'app1', {
+ root: 'apps/app1',
+ projectType: 'application',
+ });
+
+ const result = await determineArtifactNameAndDirectoryOptions(tree, {
+ name: 'foo/bar/myComponent',
+ project: 'app1',
+ nameAndDirectoryFormat: 'derived',
+ artifactType: 'component',
+ callingGenerator: '@my-org/my-plugin:component',
+ });
+
+ expect(result).toStrictEqual({
+ artifactName: 'myComponent',
+ directory: 'apps/app1/src/app/foo/bar/my-component',
+ fileName: 'my-component',
+ filePath: 'apps/app1/src/app/foo/bar/my-component/my-component.ts',
+ project: 'app1',
+ nameAndDirectoryFormat: 'derived',
+ });
+ });
+
+ it('should throw when `--disallowPathInNameForDerived` and receiving a path as the name', async () => {
+ addProjectConfiguration(tree, 'app1', {
+ root: 'apps/app1',
+ projectType: 'application',
+ });
+
+ await expect(
+ determineArtifactNameAndDirectoryOptions(tree, {
+ name: 'apps/app1/foo/bar/myComponent',
+ disallowPathInNameForDerived: true,
+ nameAndDirectoryFormat: 'derived',
+ artifactType: 'component',
+ callingGenerator: '@my-org/my-plugin:component',
+ })
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
+ `"The provided name "apps/app1/foo/bar/myComponent" contains a path and this is not supported by the "@my-org/my-plugin:component" when using the "derived" format. Please provide a name without a path or use the "as-provided" format."`
+ );
+ });
+
+ it('should support "--flat"', async () => {
+ addProjectConfiguration(tree, 'app1', {
+ root: 'apps/app1',
+ projectType: 'application',
+ });
+
+ const result = await determineArtifactNameAndDirectoryOptions(tree, {
+ name: 'myComponent',
+ flat: true,
+ project: 'app1',
+ nameAndDirectoryFormat: 'derived',
+ artifactType: 'component',
+ callingGenerator: '@my-org/my-plugin:component',
+ });
+
+ expect(result).toStrictEqual({
+ artifactName: 'myComponent',
+ directory: 'apps/app1/src/app',
+ fileName: 'my-component',
+ filePath: 'apps/app1/src/app/my-component.ts',
+ project: 'app1',
+ nameAndDirectoryFormat: 'derived',
+ });
+ });
+
+ it('should support receiving a suffix', async () => {
+ addProjectConfiguration(tree, 'app1', {
+ root: 'apps/app1',
+ projectType: 'application',
+ });
+
+ const result = await determineArtifactNameAndDirectoryOptions(tree, {
+ name: 'myComponent',
+ suffix: 'component',
+ project: 'app1',
+ nameAndDirectoryFormat: 'derived',
+ artifactType: 'component',
+ callingGenerator: '@my-org/my-plugin:component',
+ });
+
+ expect(result).toStrictEqual({
+ artifactName: 'myComponent',
+ directory: 'apps/app1/src/app/my-component',
+ fileName: 'my-component.component',
+ filePath: 'apps/app1/src/app/my-component/my-component.component.ts',
+ project: 'app1',
+ nameAndDirectoryFormat: 'derived',
+ });
+ });
+
+ it('should support receiving a fileName', async () => {
+ addProjectConfiguration(tree, 'app1', {
+ root: 'apps/app1',
+ projectType: 'application',
+ });
+
+ const result = await determineArtifactNameAndDirectoryOptions(tree, {
+ name: 'myComponent',
+ fileName: 'myComponent.component',
+ project: 'app1',
+ nameAndDirectoryFormat: 'derived',
+ artifactType: 'component',
+ callingGenerator: '@my-org/my-plugin:component',
+ });
+
+ expect(result).toStrictEqual({
+ artifactName: 'myComponent',
+ directory: 'apps/app1/src/app/my-component',
+ fileName: 'myComponent.component',
+ filePath: 'apps/app1/src/app/my-component/myComponent.component.ts',
+ project: 'app1',
+ nameAndDirectoryFormat: 'derived',
+ });
+ });
+
+ it('should support "--pascalCaseFile"', async () => {
+ addProjectConfiguration(tree, 'app1', {
+ root: 'apps/app1',
+ projectType: 'application',
+ });
+
+ const result = await determineArtifactNameAndDirectoryOptions(tree, {
+ name: 'myComponent',
+ pascalCaseFile: true,
+ project: 'app1',
+ nameAndDirectoryFormat: 'derived',
+ artifactType: 'component',
+ callingGenerator: '@my-org/my-plugin:component',
+ });
+
+ expect(result).toStrictEqual({
+ artifactName: 'myComponent',
+ directory: 'apps/app1/src/app/my-component',
+ fileName: 'MyComponent',
+ filePath: 'apps/app1/src/app/my-component/MyComponent.ts',
+ project: 'app1',
+ nameAndDirectoryFormat: 'derived',
+ });
+ });
+
+ it('should support "--pascalCaseDirectory"', async () => {
+ addProjectConfiguration(tree, 'app1', {
+ root: 'apps/app1',
+ projectType: 'application',
+ });
+
+ const result = await determineArtifactNameAndDirectoryOptions(tree, {
+ name: 'myComponent',
+ pascalCaseDirectory: true,
+ project: 'app1',
+ nameAndDirectoryFormat: 'derived',
+ artifactType: 'component',
+ callingGenerator: '@my-org/my-plugin:component',
+ });
+
+ expect(result).toStrictEqual({
+ artifactName: 'myComponent',
+ directory: 'apps/app1/src/app/MyComponent',
+ fileName: 'my-component',
+ filePath: 'apps/app1/src/app/MyComponent/my-component.ts',
+ project: 'app1',
+ nameAndDirectoryFormat: 'derived',
+ });
+ });
+
+ it('should support receiving a different file extension', async () => {
+ addProjectConfiguration(tree, 'app1', {
+ root: 'apps/app1',
+ projectType: 'application',
+ });
+
+ const result = await determineArtifactNameAndDirectoryOptions(tree, {
+ name: 'myComponent',
+ fileExtension: 'tsx',
+ project: 'app1',
+ nameAndDirectoryFormat: 'derived',
+ artifactType: 'component',
+ callingGenerator: '@my-org/my-plugin:component',
+ });
+
+ expect(result).toStrictEqual({
+ artifactName: 'myComponent',
+ directory: 'apps/app1/src/app/my-component',
+ fileName: 'my-component',
+ filePath: 'apps/app1/src/app/my-component/my-component.tsx',
+ project: 'app1',
+ nameAndDirectoryFormat: 'derived',
+ });
+ });
+ });
+
+ describe('no format', () => {
+ it('should prompt for the format to use', async () => {
+ // simulate interactive mode
+ ensureInteractiveMode();
+ addProjectConfiguration(tree, 'app1', {
+ root: 'apps/app1',
+ projectType: 'application',
+ });
+ const promptSpy = jest
+ .spyOn(enquirer, 'prompt')
+ .mockImplementation(() => Promise.resolve({ format: 'as-provided' }));
+
+ await determineArtifactNameAndDirectoryOptions(tree, {
+ name: 'myComponent',
+ project: 'app1',
+ artifactType: 'component',
+ callingGenerator: '@my-org/my-plugin:component',
+ });
+
+ expect(promptSpy).toHaveBeenCalled();
+ const promptCallOptions = promptSpy.mock.calls[0][0] as any;
+ expect(promptCallOptions.choices).toStrictEqual([
+ {
+ message: 'As provided: myComponent.ts',
+ // as-provided ignores the project and uses cwd + directory
+ // in this case, both are empty
+ name: 'myComponent.ts',
+ },
+ {
+ message:
+ 'Derived: apps/app1/src/app/my-component/my-component.ts',
+ name: 'apps/app1/src/app/my-component/my-component.ts',
+ },
+ ]);
+
+ // restore original interactive mode
+ restoreOriginalInteractiveMode();
+ });
+
+ it('should not prompt and default to "derived" format when running in a non-interactive env', async () => {
+ addProjectConfiguration(tree, 'app1', {
+ root: 'apps/app1',
+ projectType: 'application',
+ });
+ const promptSpy = jest.spyOn(enquirer, 'prompt');
+
+ const result = await determineArtifactNameAndDirectoryOptions(tree, {
+ name: 'myComponent',
+ project: 'app1',
+ artifactType: 'component',
+ callingGenerator: '@my-org/my-plugin:component',
+ });
+
+ expect(promptSpy).not.toHaveBeenCalled();
+ expect(result.nameAndDirectoryFormat).toBe('derived');
+ });
+
+ it('should not prompt and default to "as-provided" format when providing a directory in the name is disallowed', async () => {
+ addProjectConfiguration(tree, 'app1', {
+ root: 'apps/app1',
+ projectType: 'application',
+ });
+ const promptSpy = jest.spyOn(enquirer, 'prompt');
+
+ const result = await determineArtifactNameAndDirectoryOptions(tree, {
+ name: 'apps/app1/myComponent',
+ project: 'app1',
+ disallowPathInNameForDerived: true,
+ artifactType: 'component',
+ callingGenerator: '@my-org/my-plugin:component',
+ });
+
+ expect(promptSpy).not.toHaveBeenCalled();
+ expect(result.nameAndDirectoryFormat).toBe('as-provided');
+ });
+
+ it('should not prompt and default to "as-provided" format when the directory is not under the provided project root', async () => {
+ addProjectConfiguration(tree, 'app1', {
+ root: 'apps/app1',
+ projectType: 'application',
+ });
+ addProjectConfiguration(tree, 'app2', {
+ root: 'apps/app2',
+ projectType: 'application',
+ });
+ const promptSpy = jest.spyOn(enquirer, 'prompt');
+
+ const result = await determineArtifactNameAndDirectoryOptions(tree, {
+ name: 'myComponent',
+ project: 'app1',
+ directory: 'apps/app2',
+ artifactType: 'component',
+ callingGenerator: '@my-org/my-plugin:component',
+ });
+
+ expect(promptSpy).not.toHaveBeenCalled();
+ expect(result.nameAndDirectoryFormat).toBe('as-provided');
+ });
+
+ it('should not prompt when the resulting name and directory are the same for both formats', async () => {
+ // simulate interactive mode
+ ensureInteractiveMode();
+ addProjectConfiguration(tree, 'app1', {
+ root: '.',
+ projectType: 'application',
+ });
+ const promptSpy = jest.spyOn(enquirer, 'prompt');
+
+ const result = await determineArtifactNameAndDirectoryOptions(tree, {
+ name: 'my-component',
+ directory: 'src/app',
+ flat: true,
+ artifactType: 'component',
+ callingGenerator: '@my-org/my-plugin:component',
+ });
+
+ expect(promptSpy).not.toHaveBeenCalled();
+ expect(result).toStrictEqual({
+ artifactName: 'my-component',
+ directory: 'src/app',
+ fileName: 'my-component',
+ filePath: 'src/app/my-component.ts',
+ project: 'app1',
+ nameAndDirectoryFormat: 'as-provided',
+ });
+
+ // restore original interactive mode
+ restoreOriginalInteractiveMode();
+ });
+ });
+});
diff --git a/packages/devkit/src/generators/artifact-name-and-directory-utils.ts b/packages/devkit/src/generators/artifact-name-and-directory-utils.ts
new file mode 100644
index 0000000000000..faa34888e5692
--- /dev/null
+++ b/packages/devkit/src/generators/artifact-name-and-directory-utils.ts
@@ -0,0 +1,410 @@
+import { prompt } from 'enquirer';
+import type { ProjectConfiguration } from 'nx/src/config/workspace-json-project-json';
+import type { Tree } from 'nx/src/generators/tree';
+import { relative } from 'path';
+import { requireNx } from '../../nx';
+import { names } from '../utils/names';
+
+const {
+ createProjectRootMappingsFromProjectConfigurations,
+ findProjectForPath,
+ getProjects,
+ joinPathFragments,
+ logger,
+ normalizePath,
+ output,
+ workspaceRoot,
+} = requireNx();
+
+export type NameAndDirectoryFormat = 'as-provided' | 'derived';
+export type ArtifactGenerationOptions = {
+ artifactType: string;
+ callingGenerator: string | null;
+ name: string;
+ directory?: string;
+ disallowPathInNameForDerived?: boolean;
+ fileExtension?: 'js' | 'jsx' | 'ts' | 'tsx';
+ fileName?: string;
+ flat?: boolean;
+ nameAndDirectoryFormat?: NameAndDirectoryFormat;
+ pascalCaseDirectory?: boolean;
+ pascalCaseFile?: boolean;
+ project?: string;
+ suffix?: string;
+};
+
+export type NameAndDirectoryOptions = {
+ /**
+ * Normalized artifact name.
+ */
+ artifactName: string;
+ /**
+ * Normalized directory path where the artifact will be generated.
+ */
+ directory: string;
+ /**
+ * Normalized file name of the artifact without the extension.
+ */
+ fileName: string;
+ /**
+ * Normalized full file path of the artifact.
+ */
+ filePath: string;
+ /**
+ * Project name where the artifact will be generated.
+ */
+ project: string;
+};
+
+type NameAndDirectoryFormats = {
+ 'as-provided': NameAndDirectoryOptions;
+ derived: NameAndDirectoryOptions | undefined;
+};
+
+export async function determineArtifactNameAndDirectoryOptions(
+ tree: Tree,
+ options: ArtifactGenerationOptions
+): Promise<
+ NameAndDirectoryOptions & {
+ nameAndDirectoryFormat: NameAndDirectoryFormat;
+ }
+> {
+ const formats = getNameAndDirectoryOptionFormats(tree, options);
+ const format =
+ options.nameAndDirectoryFormat ?? (await determineFormat(formats, options));
+
+ validateResolvedProject(
+ formats[format]?.project,
+ options,
+ formats[format]?.directory
+ );
+
+ return {
+ ...formats[format],
+ nameAndDirectoryFormat: format,
+ };
+}
+
+async function determineFormat(
+ formats: NameAndDirectoryFormats,
+ options: ArtifactGenerationOptions
+): Promise {
+ if (!formats.derived) {
+ return 'as-provided';
+ }
+
+ if (process.env.NX_INTERACTIVE !== 'true' || !isTTY()) {
+ logDeprecationMessage(options, formats);
+
+ return 'derived';
+ }
+
+ const asProvidedDescription = `As provided: ${formats['as-provided'].filePath}`;
+ const asProvidedSelectedValue = formats['as-provided'].filePath;
+ const derivedDescription = `Derived: ${formats['derived'].filePath}`;
+ const derivedSelectedValue = formats['derived'].filePath;
+
+ if (asProvidedSelectedValue === derivedSelectedValue) {
+ return 'as-provided';
+ }
+
+ const result = await prompt<{ format: NameAndDirectoryFormat }>({
+ type: 'select',
+ name: 'format',
+ message: `Where should the ${options.artifactType} be generated?`,
+ choices: [
+ {
+ message: asProvidedDescription,
+ name: asProvidedSelectedValue,
+ },
+ {
+ message: derivedDescription,
+ name: derivedSelectedValue,
+ },
+ ],
+ initial: 'as-provided' as any,
+ }).then(({ format }) =>
+ format === asProvidedSelectedValue ? 'as-provided' : 'derived'
+ );
+
+ if (result === 'derived' && options.callingGenerator) {
+ logDeprecationMessage(options, formats);
+ }
+
+ return result;
+}
+
+function logDeprecationMessage(
+ options: ArtifactGenerationOptions,
+ formats: NameAndDirectoryFormats
+) {
+ logger.warn(`
+In Nx 18, generating a ${options.artifactType} will no longer support providing a project and deriving the directory.
+Please provide the exact directory in the future.
+Example: nx g ${options.callingGenerator} ${formats['derived'].artifactName} --directory ${formats['derived'].directory}
+NOTE: The example above assumes the command is being run from the workspace root. If the command is being run from a subdirectory, the directory option should be adjusted accordingly.
+`);
+}
+
+function getNameAndDirectoryOptionFormats(
+ tree: Tree,
+ options: ArtifactGenerationOptions
+): NameAndDirectoryFormats {
+ const directory = options.directory
+ ? normalizePath(options.directory.replace(/^\.?\//, ''))
+ : undefined;
+ const fileExtension = options.fileExtension ?? 'ts';
+ const { name: extractedName, directory: extractedDirectory } =
+ extractNameAndDirectoryFromName(options.name);
+
+ if (extractedDirectory && directory) {
+ throw new Error(
+ `You can't specify both a directory (${options.directory}) and a name with a directory path (${options.name}). ` +
+ `Please specify either a directory or a name with a directory path.`
+ );
+ }
+
+ const asProvidedOptions = getAsProvidedOptions(tree, {
+ ...options,
+ directory: directory ?? extractedDirectory,
+ fileExtension,
+ name: extractedName,
+ });
+
+ if (!options.project) {
+ validateResolvedProject(
+ asProvidedOptions.project,
+ options,
+ asProvidedOptions.directory
+ );
+ }
+
+ if (options.nameAndDirectoryFormat === 'as-provided') {
+ return {
+ 'as-provided': asProvidedOptions,
+ derived: undefined,
+ };
+ }
+
+ if (options.disallowPathInNameForDerived && options.name.includes('/')) {
+ if (!options.nameAndDirectoryFormat) {
+ output.warn({
+ title: `The provided name "${options.name}" contains a path and this is not supported by the "${options.callingGenerator}" when using the "derived" format.`,
+ bodyLines: [
+ `The generator will try to generate the ${options.artifactType} using the "as-provided" format at "${asProvidedOptions.filePath}".`,
+ ],
+ });
+
+ return {
+ 'as-provided': asProvidedOptions,
+ derived: undefined,
+ };
+ }
+
+ throw new Error(
+ `The provided name "${options.name}" contains a path and this is not supported by the "${options.callingGenerator}" when using the "derived" format. ` +
+ `Please provide a name without a path or use the "as-provided" format.`
+ );
+ }
+
+ const derivedOptions = getDerivedOptions(
+ tree,
+ {
+ ...options,
+ directory,
+ fileExtension,
+ name: extractedName,
+ },
+ asProvidedOptions,
+ !options.disallowPathInNameForDerived && extractedDirectory
+ ? extractedDirectory
+ : undefined
+ );
+
+ return {
+ 'as-provided': asProvidedOptions,
+ derived: derivedOptions,
+ };
+}
+
+function getAsProvidedOptions(
+ tree: Tree,
+ options: ArtifactGenerationOptions
+): NameAndDirectoryOptions {
+ const relativeCwd = getRelativeCwd();
+
+ const asProvidedDirectory = options.directory
+ ? joinPathFragments(relativeCwd, options.directory)
+ : relativeCwd;
+ const asProvidedProject = findProjectFromPath(tree, asProvidedDirectory);
+
+ const asProvidedFileName =
+ options.fileName ??
+ (options.suffix ? `${options.name}.${options.suffix}` : options.name);
+ const asProvidedFilePath = joinPathFragments(
+ asProvidedDirectory,
+ `${asProvidedFileName}.${options.fileExtension}`
+ );
+
+ return {
+ artifactName: options.name,
+ directory: asProvidedDirectory,
+ fileName: asProvidedFileName,
+ filePath: asProvidedFilePath,
+ project: asProvidedProject,
+ };
+}
+
+function getDerivedOptions(
+ tree: Tree,
+ options: ArtifactGenerationOptions,
+ asProvidedOptions: NameAndDirectoryOptions,
+ extractedDirectory: string | undefined
+): NameAndDirectoryOptions | undefined {
+ const projects = getProjects(tree);
+ if (options.project && !projects.has(options.project)) {
+ throw new Error(
+ `The provided project "${options.project}" does not exist! Please provide an existing project name.`
+ );
+ }
+
+ const projectName = options.project ?? asProvidedOptions.project;
+ const project = projects.get(projectName);
+ const derivedName = options.name;
+ const baseDirectory = options.directory
+ ? names(options.directory).fileName
+ : joinPathFragments(
+ project.sourceRoot ?? joinPathFragments(project.root, 'src'),
+ project.projectType === 'application' ? 'app' : 'lib',
+ extractedDirectory ?? ''
+ );
+ const derivedDirectory = options.flat
+ ? normalizePath(baseDirectory)
+ : joinPathFragments(
+ baseDirectory,
+ options.pascalCaseDirectory
+ ? names(derivedName).className
+ : names(derivedName).fileName
+ );
+
+ if (
+ options.directory &&
+ !isDirectoryUnderProjectRoot(derivedDirectory, project.root)
+ ) {
+ if (!options.nameAndDirectoryFormat) {
+ output.warn({
+ title: `The provided directory "${options.directory}" is not under the provided project root "${project.root}".`,
+ bodyLines: [
+ `The generator will try to generate the ${options.artifactType} using the "as-provided" format.`,
+ `With the "as-provided" format, the "project" option is ignored and the ${options.artifactType} will be generated at "${asProvidedOptions.filePath}" (/).`,
+ ],
+ });
+
+ return undefined;
+ }
+
+ throw new Error(
+ `The provided directory "${options.directory}" is not under the provided project root "${project.root}". ` +
+ `Please provide a directory that is under the provided project root or use the "as-provided" format and only provide the directory.`
+ );
+ }
+
+ let derivedFileName = options.fileName;
+ if (!derivedFileName) {
+ derivedFileName = options.suffix
+ ? `${derivedName}.${options.suffix}`
+ : derivedName;
+ derivedFileName = options.pascalCaseFile
+ ? names(derivedFileName).className
+ : names(derivedFileName).fileName;
+ }
+ const derivedFilePath = joinPathFragments(
+ derivedDirectory,
+ `${derivedFileName}.${options.fileExtension}`
+ );
+
+ return {
+ artifactName: derivedName,
+ directory: derivedDirectory,
+ fileName: derivedFileName,
+ filePath: derivedFilePath,
+ project: projectName,
+ };
+}
+
+function validateResolvedProject(
+ project: string | undefined,
+ options: ArtifactGenerationOptions,
+ normalizedDirectory: string
+): void {
+ if (project) {
+ return;
+ }
+
+ if (options.directory) {
+ throw new Error(
+ `The provided directory resolved relative to the current working directory "${normalizedDirectory}" does not exist under any project root. ` +
+ `Please make sure to navigate to a location or provide a directory that exists under a project root.`
+ );
+ }
+
+ throw new Error(
+ `The current working directory "${
+ getRelativeCwd() || '.'
+ }" does not exist under any project root. ` +
+ `Please make sure to navigate to a location or provide a directory that exists under a project root.`
+ );
+}
+
+function findProjectFromPath(tree: Tree, path: string): string | null {
+ const projectConfigurations: Record = {};
+ const projects = getProjects(tree);
+ for (const [projectName, project] of projects) {
+ projectConfigurations[projectName] = project;
+ }
+ const projectRootMappings =
+ createProjectRootMappingsFromProjectConfigurations(projectConfigurations);
+
+ return findProjectForPath(path, projectRootMappings);
+}
+
+function isDirectoryUnderProjectRoot(
+ directory: string,
+ projectRoot: string
+): boolean {
+ const normalizedDirectory = joinPathFragments(workspaceRoot, directory);
+ const normalizedProjectRoot = joinPathFragments(
+ workspaceRoot,
+ projectRoot
+ ).replace(/\/$/, '');
+
+ return (
+ normalizedDirectory === normalizedProjectRoot ||
+ normalizedDirectory.startsWith(`${normalizedProjectRoot}/`)
+ );
+}
+
+function isTTY(): boolean {
+ return !!process.stdout.isTTY && process.env['CI'] !== 'true';
+}
+
+function getRelativeCwd(): string {
+ return normalizePath(relative(workspaceRoot, getCwd()));
+}
+
+function getCwd(): string {
+ return process.env.INIT_CWD?.startsWith(workspaceRoot)
+ ? process.env.INIT_CWD
+ : process.cwd();
+}
+
+function extractNameAndDirectoryFromName(rawName: string): {
+ name: string;
+ directory: string | undefined;
+} {
+ const parsedName = normalizePath(rawName).split('/');
+ const name = parsedName.pop();
+ const directory = parsedName.length ? parsedName.join('/') : undefined;
+
+ return { name, directory };
+}
diff --git a/packages/nx/src/devkit-internals.ts b/packages/nx/src/devkit-internals.ts
index cd12d71e36086..ed7d2be4e3af5 100644
--- a/packages/nx/src/devkit-internals.ts
+++ b/packages/nx/src/devkit-internals.ts
@@ -14,3 +14,7 @@ export { sortObjectByKeys } from './utils/object-sort';
export { stripIndent } from './utils/logger';
export { readModulePackageJson } from './utils/package-json';
export { splitByColons } from './utils/split-target';
+export {
+ createProjectRootMappingsFromProjectConfigurations,
+ findProjectForPath,
+} from './project-graph/utils/find-project-for-path';
diff --git a/packages/nx/src/utils/params.ts b/packages/nx/src/utils/params.ts
index 1488ff3e7d2e5..7fb629e79b852 100644
--- a/packages/nx/src/utils/params.ts
+++ b/packages/nx/src/utils/params.ts
@@ -633,6 +633,8 @@ export async function combineOptionsForGenerator(
schema,
false
);
+
+ warnDeprecations(combined, schema);
convertSmartDefaultsIntoNamedParams(
combined,
schema,
@@ -644,9 +646,7 @@ export async function combineOptionsForGenerator(
combined = await promptForValues(combined, schema, projectsConfigurations);
}
- warnDeprecations(combined, schema);
setDefaults(combined, schema);
-
validateOptsAgainstSchema(combined, schema);
applyVerbosity(combined, schema, isVerbose);
return combined;