diff --git a/docs/generated/packages/js/generators/library.json b/docs/generated/packages/js/generators/library.json index cb359ac83c..0673c658b5 100644 --- a/docs/generated/packages/js/generators/library.json +++ b/docs/generated/packages/js/generators/library.json @@ -1,6 +1,6 @@ { "name": "library", - "factory": "./src/generators/library/library#libraryGenerator", + "factory": "./src/generators/library/library#libraryGeneratorInternal", "schema": { "$schema": "http://json-schema.org/schema", "$id": "NxTypescriptLibrary", @@ -14,13 +14,18 @@ "description": "Library name.", "$default": { "$source": "argv", "index": 0 }, "x-prompt": "What name would you like to use for the library?", - "pattern": "^[a-zA-Z].*$" + "pattern": "(?:^@[a-zA-Z0-9-*~][a-zA-Z0-9-*._~]*\\/[a-zA-Z0-9-~][a-zA-Z0-9-._~]*|^[a-zA-Z][^:]*)$" }, "directory": { "type": "string", "description": "A directory where the lib is placed.", "x-priority": "important" }, + "projectNameAndRootFormat": { + "description": "Whether to generate the project name and root directory as provided (`as-provided`) or generate them composing their values and taking the configured layout into account (`derived`).", + "type": "string", + "enum": ["as-provided", "derived"] + }, "linter": { "description": "The tool to use for running lint checks.", "type": "string", @@ -142,7 +147,7 @@ "aliases": ["lib"], "x-type": "library", "description": "Create a library", - "implementation": "/packages/js/src/generators/library/library#libraryGenerator.ts", + "implementation": "/packages/js/src/generators/library/library#libraryGeneratorInternal.ts", "hidden": false, "path": "/packages/js/src/generators/library/schema.json", "type": "generator" diff --git a/e2e/js/src/js.test.ts b/e2e/js/src/js.test.ts index b022723754..8ab8ad3872 100644 --- a/e2e/js/src/js.test.ts +++ b/e2e/js/src/js.test.ts @@ -230,4 +230,55 @@ export function ${lib}Wildcard() { // restore nx.json updateFile('nx.json', () => originalNxJson); }); + + it('should generate project with name and directory as provided when --project-name-and-root-format=as-provided', async () => { + const lib1 = uniq('lib1'); + runCLI( + `generate @nx/js:lib ${lib1} --directory=shared --bundler=tsc --project-name-and-root-format=as-provided` + ); + + // check files are generated without the layout directory ("libs/") and + // in the directory provided (no project name appended) + checkFilesExist('shared/src/index.ts'); + // check project name is as provided (no prefixed directory name) + expect(runCLI(`build ${lib1}`)).toContain( + 'Done compiling TypeScript files' + ); + // check tests pass + const testResult = await runCLIAsync(`test ${lib1}`); + expect(testResult.combinedOutput).toContain( + 'Test Suites: 1 passed, 1 total' + ); + }, 500_000); + + it('should support generating with a scoped project name when --project-name-and-root-format=as-provided', async () => { + const scopedLib = uniq('@my-org/lib1'); + + // assert scoped project names are not supported when --project-name-and-root-format=derived + expect(() => + runCLI( + `generate @nx/js:lib ${scopedLib} --bundler=tsc --project-name-and-root-format=derived` + ) + ).toThrow(); + + runCLI( + `generate @nx/js:lib ${scopedLib} --bundler=tsc --project-name-and-root-format=as-provided` + ); + + // check files are generated without the layout directory ("libs/") and + // using the project name as the directory when no directory is provided + checkFilesExist( + `${scopedLib}/src/index.ts`, + `${scopedLib}/src/lib/${scopedLib.split('/')[1]}.ts` + ); + // check build works + expect(runCLI(`build ${scopedLib}`)).toContain( + 'Done compiling TypeScript files' + ); + // check tests pass + const testResult = await runCLIAsync(`test ${scopedLib}`); + expect(testResult.combinedOutput).toContain( + 'Test Suites: 1 passed, 1 total' + ); + }, 500_000); }); diff --git a/packages/devkit/package.json b/packages/devkit/package.json index c7043eed97..9e33c35b60 100644 --- a/packages/devkit/package.json +++ b/packages/devkit/package.json @@ -29,6 +29,7 @@ "homepage": "https://nx.dev", "dependencies": { "ejs": "^3.1.7", + "enquirer": "~2.3.6", "ignore": "^5.0.4", "tmp": "~0.2.1", "tslib": "^2.3.0", diff --git a/packages/devkit/src/generators/project-name-and-root-utils.spec.ts b/packages/devkit/src/generators/project-name-and-root-utils.spec.ts new file mode 100644 index 0000000000..897a2178ef --- /dev/null +++ b/packages/devkit/src/generators/project-name-and-root-utils.spec.ts @@ -0,0 +1,580 @@ +import * as enquirer from 'enquirer'; +import { createTreeWithEmptyWorkspace } from 'nx/src/generators/testing-utils/create-tree-with-empty-workspace'; +import type { Tree } from 'nx/src/generators/tree'; +import { updateJson } from 'nx/src/generators/utils/json'; +import { determineProjectNameAndRootOptions } from './project-name-and-root-utils'; + +describe('determineProjectNameAndRootOptions', () => { + let tree: Tree; + let originalInteractiveValue; + let originalCIValue; + let originalIsTTYValue; + + 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; + } + + beforeEach(() => { + originalInteractiveValue = process.env.NX_INTERACTIVE; + originalCIValue = process.env.CI; + originalIsTTYValue = process.stdout.isTTY; + }); + + describe('no layout', () => { + beforeEach(() => { + tree = createTreeWithEmptyWorkspace(); + jest.clearAllMocks(); + }); + + it('should return the project name and directory as provided', async () => { + const result = await determineProjectNameAndRootOptions(tree, { + name: 'libName', + directory: 'shared', + projectType: 'library', + projectNameAndRootFormat: 'as-provided', + }); + + expect(result).toStrictEqual({ + projectName: 'lib-name', + names: { + projectSimpleName: 'lib-name', + projectFileName: 'lib-name', + }, + importPath: '@proj/lib-name', + projectDirectory: 'shared', + }); + }); + + it('should use a scoped package name as the project name and import path when format is "as-provided"', async () => { + const result = await determineProjectNameAndRootOptions(tree, { + name: '@scope/libName', + directory: 'shared', + projectType: 'library', + projectNameAndRootFormat: 'as-provided', + }); + + expect(result).toEqual({ + projectName: '@scope/lib-name', + names: { + projectSimpleName: 'lib-name', + projectFileName: 'lib-name', + }, + importPath: '@scope/lib-name', + projectDirectory: 'shared', + }); + }); + + it('should use provided import path over scoped name when format is "as-provided"', async () => { + const result = await determineProjectNameAndRootOptions(tree, { + name: '@scope/libName', + directory: 'shared', + projectType: 'library', + projectNameAndRootFormat: 'as-provided', + importPath: '@custom-scope/lib-name', + }); + + expect(result).toEqual({ + projectName: '@scope/lib-name', + names: { + projectSimpleName: 'lib-name', + projectFileName: 'lib-name', + }, + importPath: '@custom-scope/lib-name', + projectDirectory: 'shared', + }); + }); + + it('should return the directory as the project name when directory is not provided and format is "as-provided"', async () => { + updateJson(tree, 'package.json', (json) => { + json.name = 'lib-name'; + return json; + }); + const result = await determineProjectNameAndRootOptions(tree, { + name: '@scope/libName', + projectType: 'library', + projectNameAndRootFormat: 'as-provided', + }); + + expect(result).toEqual({ + projectName: '@scope/lib-name', + names: { + projectSimpleName: 'lib-name', + projectFileName: 'lib-name', + }, + importPath: '@scope/lib-name', + projectDirectory: '@scope/lib-name', + }); + }); + + it('should return the project name and directory as provided for root projects', async () => { + updateJson(tree, 'package.json', (json) => { + json.name = 'lib-name'; + return json; + }); + + const result = await determineProjectNameAndRootOptions(tree, { + name: 'libName', + projectType: 'library', + projectNameAndRootFormat: 'as-provided', + rootProject: true, + }); + + expect(result).toEqual({ + projectName: 'lib-name', + names: { + projectSimpleName: 'lib-name', + projectFileName: 'lib-name', + }, + importPath: 'lib-name', + projectDirectory: '.', + }); + }); + + it('should derive import path for root projects when package.json does not have a name and format is as-provided', async () => { + updateJson(tree, 'package.json', (json) => { + json.name = undefined; + return json; + }); + + const result = await determineProjectNameAndRootOptions(tree, { + name: 'libName', + projectType: 'library', + projectNameAndRootFormat: 'as-provided', + rootProject: true, + }); + + expect(result.importPath).toBe('@proj/lib-name'); + }); + + it('should throw when an invalid name is provided', async () => { + await expect( + determineProjectNameAndRootOptions(tree, { + name: '!scope/libName', + directory: 'shared', + projectType: 'library', + projectNameAndRootFormat: 'as-provided', + }) + ).rejects.toThrowError(); + }); + + it('should return the derived project name and directory', async () => { + const result = await determineProjectNameAndRootOptions(tree, { + name: 'libName', + directory: 'shared', + projectType: 'library', + projectNameAndRootFormat: 'derived', + }); + + expect(result).toEqual({ + projectName: 'shared-lib-name', + names: { + projectSimpleName: 'lib-name', + projectFileName: 'shared-lib-name', + }, + importPath: '@proj/shared/lib-name', + projectDirectory: 'shared/lib-name', + }); + }); + + it('should throw when using a scoped package name as the project name and format is "derived"', async () => { + await expect( + determineProjectNameAndRootOptions(tree, { + name: '@scope/libName', + directory: 'shared', + projectType: 'library', + projectNameAndRootFormat: 'derived', + }) + ).rejects.toThrowError(); + }); + + it('should return the derived project name and directory for root projects', async () => { + updateJson(tree, 'package.json', (json) => { + json.name = 'lib-name'; + return json; + }); + const result = await determineProjectNameAndRootOptions(tree, { + name: 'libName', + projectType: 'library', + projectNameAndRootFormat: 'derived', + rootProject: true, + }); + + expect(result).toEqual({ + projectName: 'lib-name', + names: { + projectSimpleName: 'lib-name', + projectFileName: 'lib-name', + }, + importPath: 'lib-name', + projectDirectory: '.', + }); + }); + + it('should derive import path for root projects when package.json does not have a name and format is "derived"', async () => { + updateJson(tree, 'package.json', (json) => { + json.name = undefined; + return json; + }); + + const result = await determineProjectNameAndRootOptions(tree, { + name: 'libName', + projectType: 'library', + projectNameAndRootFormat: 'as-provided', + rootProject: true, + }); + + expect(result.importPath).toBe('@proj/lib-name'); + }); + + it('should prompt for the project name and root format', async () => { + // simulate interactive mode + ensureInteractiveMode(); + const promptSpy = jest + .spyOn(enquirer, 'prompt') + .mockImplementation(() => Promise.resolve({ format: 'as-provided' })); + + await determineProjectNameAndRootOptions(tree, { + name: 'libName', + projectType: 'library', + directory: 'shared', + }); + + expect(promptSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'select', + name: 'format', + message: + 'What should be the project name and where should it be generated?', + choices: [ + { + message: `Recommended: + Name: lib-name + Root: shared`, + name: 'as-provided', + }, + { + message: `Legacy: + Name: shared-lib-name + Root: shared/lib-name`, + name: 'derived', + }, + ], + initial: 'as-provided', + }) + ); + + // restore original interactive mode + restoreOriginalInteractiveMode(); + }); + + it('should directly use format as-provided and not prompt when the name is a scoped package name', async () => { + // simulate interactive mode + ensureInteractiveMode(); + const promptSpy = jest.spyOn(enquirer, 'prompt'); + + const result = await determineProjectNameAndRootOptions(tree, { + name: '@scope/libName', + projectType: 'library', + directory: 'shared', + }); + + expect(promptSpy).not.toHaveBeenCalled(); + expect(result).toEqual({ + projectName: '@scope/lib-name', + names: { + projectSimpleName: 'lib-name', + projectFileName: 'lib-name', + }, + importPath: '@scope/lib-name', + projectDirectory: 'shared', + }); + + // restore original interactive mode + restoreOriginalInteractiveMode(); + }); + }); + + describe('with layout', () => { + beforeEach(() => { + tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' }); + jest.clearAllMocks(); + }); + + it('should return the project name and directory as provided', async () => { + const result = await determineProjectNameAndRootOptions(tree, { + name: 'libName', + directory: 'shared', + projectType: 'library', + projectNameAndRootFormat: 'as-provided', + }); + + expect(result).toEqual({ + projectName: 'lib-name', + names: { + projectSimpleName: 'lib-name', + projectFileName: 'lib-name', + }, + importPath: '@proj/lib-name', + projectDirectory: 'shared', + }); + }); + + it('should use a scoped package name as the project name and import path when format is "as-provided"', async () => { + const result = await determineProjectNameAndRootOptions(tree, { + name: '@scope/libName', + directory: 'shared', + projectType: 'library', + projectNameAndRootFormat: 'as-provided', + }); + + expect(result).toEqual({ + projectName: '@scope/lib-name', + names: { + projectSimpleName: 'lib-name', + projectFileName: 'lib-name', + }, + importPath: '@scope/lib-name', + projectDirectory: 'shared', + }); + }); + + it('should use provided import path over scoped name when format is "as-provided"', async () => { + const result = await determineProjectNameAndRootOptions(tree, { + name: '@scope/libName', + directory: 'shared', + projectType: 'library', + projectNameAndRootFormat: 'as-provided', + importPath: '@custom-scope/lib-name', + }); + + expect(result).toEqual({ + projectName: '@scope/lib-name', + names: { + projectSimpleName: 'lib-name', + projectFileName: 'lib-name', + }, + importPath: '@custom-scope/lib-name', + projectDirectory: 'shared', + }); + }); + + it('should return the directory as the project name when directory is not provided and format is "as-provided"', async () => { + updateJson(tree, 'package.json', (json) => { + json.name = 'lib-name'; + return json; + }); + + const result = await determineProjectNameAndRootOptions(tree, { + name: '@scope/libName', + projectType: 'library', + projectNameAndRootFormat: 'as-provided', + }); + + expect(result).toEqual({ + projectName: '@scope/lib-name', + names: { + projectSimpleName: 'lib-name', + projectFileName: 'lib-name', + }, + importPath: '@scope/lib-name', + projectDirectory: '@scope/lib-name', + }); + }); + + it('should return the project name and directory as provided for root projects', async () => { + updateJson(tree, 'package.json', (json) => { + json.name = 'lib-name'; + return json; + }); + + const result = await determineProjectNameAndRootOptions(tree, { + name: 'libName', + projectType: 'library', + projectNameAndRootFormat: 'as-provided', + rootProject: true, + }); + + expect(result).toEqual({ + projectName: 'lib-name', + names: { + projectSimpleName: 'lib-name', + projectFileName: 'lib-name', + }, + importPath: 'lib-name', + projectDirectory: '.', + }); + }); + + it('should derive import path for root projects when package.json does not have a name and format is "as-provided"', async () => { + updateJson(tree, 'package.json', (json) => { + json.name = undefined; + return json; + }); + + const result = await determineProjectNameAndRootOptions(tree, { + name: 'libName', + projectType: 'library', + projectNameAndRootFormat: 'as-provided', + rootProject: true, + }); + + expect(result.importPath).toBe('@proj/lib-name'); + }); + + it('should throw when an invalid name is provided', async () => { + await expect( + determineProjectNameAndRootOptions(tree, { + name: '!scope/libName', + directory: 'shared', + projectType: 'library', + projectNameAndRootFormat: 'as-provided', + }) + ).rejects.toThrowError(); + }); + + it('should return the derived project name and directory', async () => { + const result = await determineProjectNameAndRootOptions(tree, { + name: 'libName', + directory: 'shared', + projectType: 'library', + projectNameAndRootFormat: 'derived', + }); + + expect(result).toEqual({ + projectName: 'shared-lib-name', + names: { + projectSimpleName: 'lib-name', + projectFileName: 'shared-lib-name', + }, + importPath: '@proj/shared/lib-name', + projectDirectory: 'libs/shared/lib-name', + }); + }); + + it('should throw when using a scoped package name as the project name and format is derived', async () => { + await expect( + determineProjectNameAndRootOptions(tree, { + name: '@scope/libName', + directory: 'shared', + projectType: 'library', + projectNameAndRootFormat: 'derived', + }) + ).rejects.toThrowError(); + }); + + it('should return the derived project name and directory for root projects', async () => { + updateJson(tree, 'package.json', (json) => { + json.name = 'lib-name'; + return json; + }); + + const result = await determineProjectNameAndRootOptions(tree, { + name: 'libName', + projectType: 'library', + projectNameAndRootFormat: 'derived', + rootProject: true, + }); + + expect(result).toEqual({ + projectName: 'lib-name', + names: { + projectSimpleName: 'lib-name', + projectFileName: 'lib-name', + }, + importPath: 'lib-name', + projectDirectory: '.', + }); + }); + + it('should derive import path for root projects when package.json does not have a name and format is "derived"', async () => { + updateJson(tree, 'package.json', (json) => { + json.name = undefined; + return json; + }); + + const result = await determineProjectNameAndRootOptions(tree, { + name: 'libName', + projectType: 'library', + projectNameAndRootFormat: 'as-provided', + rootProject: true, + }); + + expect(result.importPath).toBe('@proj/lib-name'); + }); + + it('should prompt for the project name and root format', async () => { + // simulate interactive mode + ensureInteractiveMode(); + const promptSpy = jest + .spyOn(enquirer, 'prompt') + .mockImplementation(() => Promise.resolve({ format: 'as-provided' })); + + await determineProjectNameAndRootOptions(tree, { + name: 'libName', + projectType: 'library', + directory: 'shared', + }); + + expect(promptSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'select', + name: 'format', + message: + 'What should be the project name and where should it be generated?', + choices: [ + { + message: `Recommended: + Name: lib-name + Root: shared`, + name: 'as-provided', + }, + { + message: `Legacy: + Name: shared-lib-name + Root: libs/shared/lib-name`, + name: 'derived', + }, + ], + initial: 'as-provided', + }) + ); + + // restore original interactive mode + restoreOriginalInteractiveMode(); + }); + + it('should directly use format as-provided and not prompt when the name is a scoped package name', async () => { + // simulate interactive mode + ensureInteractiveMode(); + const promptSpy = jest.spyOn(enquirer, 'prompt'); + + const result = await determineProjectNameAndRootOptions(tree, { + name: '@scope/libName', + projectType: 'library', + directory: 'shared', + }); + + expect(promptSpy).not.toHaveBeenCalled(); + expect(result).toEqual({ + projectName: '@scope/lib-name', + names: { + projectSimpleName: 'lib-name', + projectFileName: 'lib-name', + }, + importPath: '@scope/lib-name', + projectDirectory: 'shared', + }); + + // restore original interactive mode + restoreOriginalInteractiveMode(); + }); + }); +}); diff --git a/packages/devkit/src/generators/project-name-and-root-utils.ts b/packages/devkit/src/generators/project-name-and-root-utils.ts new file mode 100644 index 0000000000..df855f7f83 --- /dev/null +++ b/packages/devkit/src/generators/project-name-and-root-utils.ts @@ -0,0 +1,290 @@ +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, readJson, readNxJson } = requireNx(); + +export type ProjectNameAndRootFormat = 'as-provided' | 'derived'; +export type ProjectGenerationOptions = { + name: string; + projectType: ProjectType; + 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 directory, including the layout directory if configured. + */ + projectDirectory: 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': { + description: string; + options: ProjectNameAndRootOptions; + }; + derived?: { + description: string; + options: ProjectNameAndRootOptions; + }; +}; + +export async function determineProjectNameAndRootOptions( + tree: Tree, + options: ProjectGenerationOptions +): Promise { + validateName(options.name, options.projectNameAndRootFormat); + const formats = getProjectNameAndRootFormats(tree, options); + const format = + options.projectNameAndRootFormat ?? (await determineFormat(formats)); + + return formats[format].options; +} + +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( + formats: ProjectNameAndRootFormats +): Promise { + if (!formats.derived) { + return 'as-provided'; + } + + if (process.env.NX_INTERACTIVE !== 'true' || !isTTY()) { + return 'derived'; + } + + return await prompt<{ format: ProjectNameAndRootFormat }>({ + type: 'select', + name: 'format', + message: + 'What should be the project name and where should it be generated?', + choices: [ + { + message: formats['as-provided'].description, + name: 'as-provided', + }, + { + message: formats['derived'].description, + name: 'derived', + }, + ], + initial: 'as-provided' as any, + }).then(({ format }) => format); +} + +function getProjectNameAndRootFormats( + tree: Tree, + options: ProjectGenerationOptions +): ProjectNameAndRootFormats { + const name = names(options.name).fileName; + const directory = options.directory?.replace(/^\.?\//, ''); + + const asProvidedProjectName = name; + const asProvidedProjectDirectory = directory + ? names(directory).fileName + : options.rootProject + ? '.' + : asProvidedProjectName; + + if (name.startsWith('@')) { + const nameWithoutScope = asProvidedProjectName.split('/')[1]; + return { + 'as-provided': { + description: `Recommended: + Name: ${asProvidedProjectName} + Root: ${asProvidedProjectDirectory}`, + options: { + projectName: asProvidedProjectName, + names: { + projectSimpleName: nameWithoutScope, + projectFileName: nameWithoutScope, + }, + importPath: options.importPath ?? asProvidedProjectName, + projectDirectory: 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': { + description: `Recommended: + Name: ${asProvidedProjectName} + Root: ${asProvidedProjectDirectory}`, + options: { + projectName: asProvidedProjectName, + names: { + projectSimpleName: asProvidedProjectName, + projectFileName: asProvidedProjectName, + }, + importPath: asProvidedImportPath, + projectDirectory: asProvidedProjectDirectory, + }, + }, + derived: { + description: `Legacy: + Name: ${derivedProjectName} + Root: ${derivedProjectDirectory}`, + options: { + projectName: derivedProjectName, + names: { + projectSimpleName: derivedSimpleProjectName, + projectFileName: derivedProjectName, + }, + importPath: derivedImportPath, + projectDirectory: 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'; +} diff --git a/packages/js/generators.json b/packages/js/generators.json index 6b71476703..b5a3791c0f 100644 --- a/packages/js/generators.json +++ b/packages/js/generators.json @@ -38,7 +38,7 @@ }, "generators": { "library": { - "factory": "./src/generators/library/library#libraryGenerator", + "factory": "./src/generators/library/library#libraryGeneratorInternal", "schema": "./src/generators/library/schema.json", "aliases": ["lib"], "x-type": "library", diff --git a/packages/js/src/generators/library/library.ts b/packages/js/src/generators/library/library.ts index 74f93f7f56..59b1140e34 100644 --- a/packages/js/src/generators/library/library.ts +++ b/packages/js/src/generators/library/library.ts @@ -3,22 +3,23 @@ import { addProjectConfiguration, convertNxGenerator, ensurePackage, - extractLayoutDirectory, formatFiles, generateFiles, GeneratorCallback, - getWorkspaceLayout, joinPathFragments, names, offsetFromRoot, ProjectConfiguration, - readJson, runTasksInSerial, toJS, Tree, updateJson, writeJson, } from '@nx/devkit'; +import { + determineProjectNameAndRootOptions, + type ProjectNameAndRootOptions, +} from '@nx/devkit/src/generators/project-name-and-root-utils'; import { addTsConfigPath, @@ -29,7 +30,6 @@ import { addMinimalPublishScript } from '../../utils/minimal-publish-script'; import { Bundler, LibraryGeneratorSchema } from '../../utils/schema'; import { addSwcConfig } from '../../utils/swc/add-swc-config'; import { addSwcDependencies } from '../../utils/swc/add-swc-dependencies'; -import { getImportPath } from '../../utils/get-import-path'; import { esbuildVersion, nxVersion, @@ -46,22 +46,20 @@ export async function libraryGenerator( tree: Tree, schema: LibraryGeneratorSchema ) { - const { layoutDirectory, projectDirectory } = extractLayoutDirectory( - schema.directory - ); - schema.directory = projectDirectory; - const libsDir = schema.rootProject - ? '.' - : layoutDirectory ?? getWorkspaceLayout(tree).libsDir; - return projectGenerator(tree, schema, libsDir, join(__dirname, './files')); + return await libraryGeneratorInternal(tree, { + // provide a default projectNameAndRootFormat to avoid breaking changes + // to external generators invoking this one + projectNameAndRootFormat: 'derived', + ...schema, + }); } -export async function projectGenerator( +export async function libraryGeneratorInternal( tree: Tree, - schema: LibraryGeneratorSchema, - destinationDir: string, - filesDir: string + schema: LibraryGeneratorSchema ) { + const filesDir = join(__dirname, './files'); + const tasks: GeneratorCallback[] = []; tasks.push( await jsInitGenerator(tree, { @@ -70,11 +68,11 @@ export async function projectGenerator( tsConfigName: schema.rootProject ? 'tsconfig.json' : 'tsconfig.base.json', }) ); - const options = normalizeOptions(tree, schema, destinationDir); + const options = await normalizeOptions(tree, schema); createFiles(tree, options, `${filesDir}/lib`); - addProject(tree, options, destinationDir); + addProject(tree, options); tasks.push(addProjectDependencies(tree, options)); @@ -144,18 +142,14 @@ export async function projectGenerator( export interface NormalizedSchema extends LibraryGeneratorSchema { name: string; + projectNames: ProjectNameAndRootOptions['names']; fileName: string; projectRoot: string; - projectDirectory: string; parsedTags: string[]; importPath?: string; } -function addProject( - tree: Tree, - options: NormalizedSchema, - destinationDir: string -) { +function addProject(tree: Tree, options: NormalizedSchema) { const projectConfiguration: ProjectConfiguration = { root: options.projectRoot, sourceRoot: joinPathFragments(options.projectRoot, 'src'), @@ -169,7 +163,7 @@ function addProject( options.bundler !== 'none' && options.config !== 'npm-scripts' ) { - const outputPath = getOutputPath(options, destinationDir); + const outputPath = getOutputPath(options); projectConfiguration.targets.build = { executor: getBuildExecutor(options.bundler), outputs: ['{options.outputPath}'], @@ -233,9 +227,19 @@ function addProject( } } +export type AddLintOptions = Pick< + NormalizedSchema, + | 'name' + | 'linter' + | 'projectRoot' + | 'unitTestRunner' + | 'js' + | 'setParserOptionsProject' + | 'rootProject' +>; export async function addLint( tree: Tree, - options: NormalizedSchema + options: AddLintOptions ): Promise { const { lintProjectGenerator } = ensurePackage('@nx/linter', nxVersion); const task = lintProjectGenerator(tree, { @@ -316,7 +320,9 @@ function addBabelRc(tree: Tree, options: NormalizedSchema) { } function createFiles(tree: Tree, options: NormalizedSchema, filesDir: string) { - const { className, name, propertyName } = names(options.name); + const { className, name, propertyName } = names( + options.projectNames.projectFileName + ); createProjectTsConfigJson(tree, options); @@ -458,11 +464,10 @@ function replaceJestConfig( }); } -function normalizeOptions( +async function normalizeOptions( tree: Tree, - options: LibraryGeneratorSchema, - destinationDir: string -): NormalizedSchema { + options: LibraryGeneratorSchema +): Promise { /** * We are deprecating the compiler and the buildable options. * However, we want to keep the existing behavior for now. @@ -523,13 +528,6 @@ function normalizeOptions( options.skipTypeCheck = false; } - const name = names(options.name).fileName; - const projectDirectory = options.directory - ? `${names(options.directory).fileName}/${name}` - : options.rootProject - ? '.' - : name; - if (!options.unitTestRunner && options.bundler === 'vite') { options.unitTestRunner = 'vitest'; } else if (!options.unitTestRunner && options.config !== 'npm-scripts') { @@ -540,32 +538,39 @@ function normalizeOptions( options.linter = Linter.EsLint; } - const projectName = options.rootProject - ? name - : projectDirectory.replace(new RegExp('/', 'g'), '-'); + const { + projectName, + names: projectNames, + projectDirectory, + importPath, + } = await determineProjectNameAndRootOptions(tree, { + name: options.name, + projectType: 'library', + directory: options.directory, + importPath: options.importPath, + projectNameAndRootFormat: options.projectNameAndRootFormat, + rootProject: options.rootProject, + }); + options.rootProject = projectDirectory === '.'; const fileName = getCaseAwareFileName({ - fileName: options.simpleName ? name : projectName, + fileName: options.simpleName + ? projectNames.projectSimpleName + : projectNames.projectFileName, pascalCaseFiles: options.pascalCaseFiles, }); - const projectRoot = joinPathFragments(destinationDir, projectDirectory); - const parsedTags = options.tags ? options.tags.split(',').map((s) => s.trim()) : []; - const importPath = options.rootProject - ? readJson(tree, 'package.json').name ?? getImportPath(tree, 'core') - : options.importPath || getImportPath(tree, projectDirectory); - options.minimal ??= false; return { ...options, fileName, name: projectName, - projectRoot, - projectDirectory, + projectNames, + projectRoot: projectDirectory, parsedTags, importPath, }; @@ -631,15 +636,12 @@ function getBuildExecutor(bundler: Bundler) { } } -function getOutputPath(options: NormalizedSchema, destinationDir?: string) { +function getOutputPath(options: NormalizedSchema) { const parts = ['dist']; - if (destinationDir) { - parts.push(destinationDir); - } - if (options.projectDirectory === '.') { + if (options.projectRoot === '.') { parts.push(options.name); } else { - parts.push(options.projectDirectory); + parts.push(options.projectRoot); } return joinPathFragments(...parts); } @@ -741,4 +743,4 @@ function determineEntryFields( } export default libraryGenerator; -export const librarySchematic = convertNxGenerator(libraryGenerator); +export const librarySchematic = convertNxGenerator(libraryGeneratorInternal); diff --git a/packages/js/src/generators/library/schema.json b/packages/js/src/generators/library/schema.json index 758627502d..3122e75d68 100644 --- a/packages/js/src/generators/library/schema.json +++ b/packages/js/src/generators/library/schema.json @@ -14,13 +14,18 @@ "index": 0 }, "x-prompt": "What name would you like to use for the library?", - "pattern": "^[a-zA-Z].*$" + "pattern": "(?:^@[a-zA-Z0-9-*~][a-zA-Z0-9-*._~]*\\/[a-zA-Z0-9-~][a-zA-Z0-9-._~]*|^[a-zA-Z][^:]*)$" }, "directory": { "type": "string", "description": "A directory where the lib is placed.", "x-priority": "important" }, + "projectNameAndRootFormat": { + "description": "Whether to generate the project name and root directory as provided (`as-provided`) or generate them composing their values and taking the configured layout into account (`derived`).", + "type": "string", + "enum": ["as-provided", "derived"] + }, "linter": { "description": "The tool to use for running lint checks.", "type": "string", diff --git a/packages/js/src/utils/schema.d.ts b/packages/js/src/utils/schema.d.ts index 9d13f86976..1079d3feec 100644 --- a/packages/js/src/utils/schema.d.ts +++ b/packages/js/src/utils/schema.d.ts @@ -1,3 +1,4 @@ +import type { ProjectNameAndRootFormat } from '@nx/devkit/src/generators/project-name-directory-utils'; // nx-ignore-next-line const { Linter } = require('@nx/linter'); // use require to import to avoid circular dependency import type { AssetGlob, FileInputOutput } from './assets/assets'; @@ -9,6 +10,7 @@ export type Bundler = 'swc' | 'tsc' | 'rollup' | 'vite' | 'esbuild' | 'none'; export interface LibraryGeneratorSchema { name: string; directory?: string; + projectNameAndRootFormat?: ProjectNameAndRootFormat; skipFormat?: boolean; tags?: string; skipTsConfig?: boolean; diff --git a/packages/nest/src/generators/convert-tslint-to-eslint/convert-tslint-to-eslint.ts b/packages/nest/src/generators/convert-tslint-to-eslint/convert-tslint-to-eslint.ts index 5bd8c3e9e6..b2c3e498da 100755 --- a/packages/nest/src/generators/convert-tslint-to-eslint/convert-tslint-to-eslint.ts +++ b/packages/nest/src/generators/convert-tslint-to-eslint/convert-tslint-to-eslint.ts @@ -9,10 +9,7 @@ import { addLintingToApplication, NormalizedSchema as AddLintForApplicationSchema, } from '@nx/node/src/generators/application/application'; -import { - addLint as addLintingToLibraryGenerator, - NormalizedSchema as AddLintForLibrarySchema, -} from '@nx/js/src/generators/library/library'; +import { addLint as addLintingToLibraryGenerator } from '@nx/js/src/generators/library/library'; import type { Linter } from 'eslint'; /** @@ -68,11 +65,7 @@ export async function conversionGenerator( projectRoot: projectConfig.root, js, setParserOptionsProject, - projectDirectory: '', - fileName: '', - parsedTags: [], - skipFormat: true, - } as AddLintForLibrarySchema); + }); } }, });