From c033dbcfa2152b6cd907c763b08b385d72ce5272 Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:39:46 +0000 Subject: [PATCH 1/6] feat(@schematics/angular): use library builder and add entry-point schematic Update the library schematic to configure @angular/build:library with a single tsconfig.lib.json instead of @angular/build:ng-packagr, and add an entry-point schematic to generate secondary entry points under src//. --- .../cli/lib/config/workspace-schema.json | 3 + packages/schematics/angular/collection.json | 5 + .../files}/__entryFile__.ts.template | 0 .../schematics/angular/entry-point/index.ts | 171 +++++++ .../angular/entry-point/index_spec.ts | 443 ++++++++++++++++++ .../angular/entry-point/schema.json | 56 +++ .../library/files/ng-package.json.template | 7 - .../library/files/package.json.template | 3 - .../library/files/tsconfig.lib.json.template | 2 - .../files/tsconfig.lib.prod.json.template | 11 - packages/schematics/angular/library/index.ts | 85 +--- .../schematics/angular/library/index_spec.ts | 81 ++-- .../e2e/tests/build/library-with-demo-app.ts | 34 +- tests/e2e/tests/vitest/library.ts | 18 +- 14 files changed, 775 insertions(+), 144 deletions(-) rename packages/schematics/angular/{library/files/src => entry-point/files}/__entryFile__.ts.template (100%) create mode 100644 packages/schematics/angular/entry-point/index.ts create mode 100644 packages/schematics/angular/entry-point/index_spec.ts create mode 100644 packages/schematics/angular/entry-point/schema.json delete mode 100644 packages/schematics/angular/library/files/ng-package.json.template delete mode 100644 packages/schematics/angular/library/files/tsconfig.lib.prod.json.template diff --git a/packages/angular/cli/lib/config/workspace-schema.json b/packages/angular/cli/lib/config/workspace-schema.json index 00bd43311e03..a5da28d651f0 100644 --- a/packages/angular/cli/lib/config/workspace-schema.json +++ b/packages/angular/cli/lib/config/workspace-schema.json @@ -147,6 +147,9 @@ "@schematics/angular:directive": { "$ref": "../../../../schematics/angular/directive/schema.json" }, + "@schematics/angular:entry-point": { + "$ref": "../../../../schematics/angular/entry-point/schema.json" + }, "@schematics/angular:enum": { "$ref": "../../../../schematics/angular/enum/schema.json" }, diff --git a/packages/schematics/angular/collection.json b/packages/schematics/angular/collection.json index 29b361ccafbb..3302c707aa74 100755 --- a/packages/schematics/angular/collection.json +++ b/packages/schematics/angular/collection.json @@ -111,6 +111,11 @@ "schema": "./library/schema.json", "description": "Generate a library project for Angular." }, + "entry-point": { + "factory": "./entry-point", + "schema": "./entry-point/schema.json", + "description": "Generate a secondary entry point for an Angular library." + }, "web-worker": { "factory": "./web-worker", "schema": "./web-worker/schema.json", diff --git a/packages/schematics/angular/library/files/src/__entryFile__.ts.template b/packages/schematics/angular/entry-point/files/__entryFile__.ts.template similarity index 100% rename from packages/schematics/angular/library/files/src/__entryFile__.ts.template rename to packages/schematics/angular/entry-point/files/__entryFile__.ts.template diff --git a/packages/schematics/angular/entry-point/index.ts b/packages/schematics/angular/entry-point/index.ts new file mode 100644 index 000000000000..29726f0bbee5 --- /dev/null +++ b/packages/schematics/angular/entry-point/index.ts @@ -0,0 +1,171 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { type JsonObject, type JsonValue, isJsonObject } from '@angular-devkit/core'; +import { + type Rule, + SchematicsException, + type Tree, + apply, + applyTemplates, + chain, + mergeWith, + move, + noop, + schematic, + strings, + url, +} from '@angular-devkit/schematics'; +import { basename, join, relative } from 'node:path/posix'; +import { JSONFile } from '../utility/json-file'; +import { createProjectSchematic } from '../utility/project'; +import { ProjectType } from '../utility/workspace-models'; +import type { Schema as EntryPointOptions } from './schema'; + +function updateTsConfig(packageName: string, ...paths: string[]): Rule { + return (host: Tree) => { + if (!host.exists('tsconfig.json')) { + return host; + } + + const file = new JSONFile(host, 'tsconfig.json'); + const jsonPath = ['compilerOptions', 'paths', packageName]; + const value = file.get(jsonPath); + file.modify(jsonPath, Array.isArray(value) ? Array.from(new Set([...value, ...paths])) : paths); + }; +} + +export default createProjectSchematic((options, { project, tree }) => { + if (project.extensions['projectType'] !== ProjectType.Library) { + throw new SchematicsException(`Project "${options.project}" is not a library project.`); + } + + if (options.name === 'lib' || options.name.startsWith('lib/')) { + throw new SchematicsException( + `Entry point name "${options.name}" cannot be "lib" or start with "lib/" ` + + 'as it conflicts with the primary entry point source directory.', + ); + } + + const libDir = project.root; + const packageJsonPath = join(libDir, 'package.json'); + if (!tree.exists(packageJsonPath)) { + throw new SchematicsException(`Could not find package.json at "${packageJsonPath}".`); + } + + const packageJson = new JSONFile(tree, packageJsonPath); + const rawPackageName = packageJson.get(['name']); + const packageName = + typeof rawPackageName === 'string' && rawPackageName + ? rawPackageName + : strings.dasherize(options.project); + + const isPrimary = options.name === '.'; + const entryFile = (options.entryFile ?? 'public-api').replace(/\.[cm]?ts$/i, ''); + const sourceRoot = project.sourceRoot ?? join(libDir, 'src'); + const entryPointDir = join(sourceRoot, options.name); + const exportKey = isPrimary ? '.' : `./${options.name}`; + const exportTarget = `./${relative(libDir, join(entryPointDir, `${entryFile}.ts`))}`; + + const existingExports = (packageJson.get(['exports']) as JsonValue | undefined) ?? null; + let exportsObject: JsonObject = {}; + if (typeof existingExports === 'string' || Array.isArray(existingExports)) { + exportsObject = { '.': existingExports }; + } else if (isJsonObject(existingExports)) { + const keys = Object.keys(existingExports); + if (keys.some((k) => k.startsWith('.'))) { + exportsObject = { ...existingExports }; + } else if (keys.length > 0) { + exportsObject = { '.': existingExports }; + } + } + + if (Object.prototype.hasOwnProperty.call(exportsObject, exportKey)) { + throw new SchematicsException( + `Entry point "${exportKey}" already exists in "${packageJsonPath}".`, + ); + } + + if ( + isJsonObject(existingExports) && + Object.keys(existingExports).some((k) => k.startsWith('.')) + ) { + packageJson.modify(['exports', exportKey], exportTarget); + } else { + packageJson.modify(['exports'], { + ...exportsObject, + [exportKey]: exportTarget, + }); + } + + const prefix = options.prefix ?? project.prefix ?? 'lib'; + const sourceDir = join(entryPointDir, 'lib'); + const artifactName = isPrimary + ? /^@.*\/.*/.test(options.project) + ? options.project.split('/')[1] + : options.project + : basename(options.name); + + const buildOptions = project.targets.get('build')?.options; + let distRoot: string; + if (typeof buildOptions?.['outputPath'] === 'string') { + distRoot = buildOptions['outputPath']; + } else { + let folderName = options.project.startsWith('@') ? options.project.slice(1) : options.project; + if (/[A-Z]/.test(folderName)) { + folderName = strings.dasherize(folderName); + } + distRoot = `dist/${folderName}`; + } + + const tsConfigPathKey = isPrimary ? packageName : `${packageName}/${options.name}`; + const tsConfigPathTarget = `./${join(distRoot, options.name)}`; + + const templateSource = apply(url('./files'), [ + applyTemplates({ + ...strings, + ...options, + name: artifactName, + entryFile, + }), + move(entryPointDir), + ]); + + return chain([ + mergeWith(templateSource), + options.skipTsConfig ? noop() : updateTsConfig(tsConfigPathKey, tsConfigPathTarget), + options.standalone + ? noop() + : schematic('module', { + name: artifactName, + commonModule: false, + flat: true, + path: sourceDir, + project: options.project, + // Explicitly set the `typeSeparator` this also ensures that the generated files are valid even if the `module` schematic + // inherits its `typeSeparator` from the workspace. + typeSeparator: '-', + }), + schematic('component', { + name: artifactName, + selector: `${prefix}-${strings.dasherize(artifactName)}`, + inlineStyle: true, + inlineTemplate: true, + flat: true, + path: sourceDir, + export: true, + standalone: options.standalone, + skipTests: options.skipTests, + project: options.project, + // Explicitly set an empty `type` since it doesn't necessarily make sense in a library. + // This also ensures that the generated files are valid even if the `component` schematic + // inherits its `type` from the workspace. + type: '', + }), + ]); +}); diff --git a/packages/schematics/angular/entry-point/index_spec.ts b/packages/schematics/angular/entry-point/index_spec.ts new file mode 100644 index 000000000000..8eb0e875f844 --- /dev/null +++ b/packages/schematics/angular/entry-point/index_spec.ts @@ -0,0 +1,443 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { SchematicTestRunner, type UnitTestTree } from '@angular-devkit/schematics/testing'; +import type { Schema as ApplicationOptions } from '../application/schema'; +import type { Schema as GenerateLibrarySchema } from '../library/schema'; +import type { Schema as WorkspaceOptions } from '../workspace/schema'; +import type { Schema as GenerateEntryPointSchema } from './schema'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function getJsonFileContent(tree: UnitTestTree, path: string): any { + return tree.readJson(path); +} + +describe('EntryPoint Schematic', () => { + const schematicRunner = new SchematicTestRunner( + '@schematics/angular', + require.resolve('../collection.json'), + ); + + const workspaceOptions: WorkspaceOptions = { + name: 'workspace', + newProjectRoot: 'projects', + version: '6.0.0', + }; + + const defaultLibraryOptions: GenerateLibrarySchema = { + name: 'foo', + skipPackageJson: false, + skipTsConfig: false, + skipInstall: false, + }; + + const defaultOptions: GenerateEntryPointSchema = { + name: 'secondary', + project: 'foo', + }; + + let workspaceTree: UnitTestTree; + beforeEach(async () => { + workspaceTree = await schematicRunner.runSchematic('workspace', workspaceOptions); + workspaceTree = await schematicRunner.runSchematic( + 'library', + defaultLibraryOptions, + workspaceTree, + ); + }); + + it('should create secondary entry-point files', async () => { + const tree = await schematicRunner.runSchematic('entry-point', defaultOptions, workspaceTree); + + expect(tree.files).toEqual( + jasmine.arrayContaining([ + '/projects/foo/src/secondary/public-api.ts', + '/projects/foo/src/secondary/lib/secondary.ts', + '/projects/foo/src/secondary/lib/secondary.spec.ts', + ]), + ); + + const publicApiContent = tree.readContent('/projects/foo/src/secondary/public-api.ts'); + expect(publicApiContent).toContain("export * from './lib/secondary';"); + expect(publicApiContent).not.toContain('secondary-module'); + }); + + it('should support nested secondary entry-point names', async () => { + const tree = await schematicRunner.runSchematic( + 'entry-point', + { + ...defaultOptions, + name: 'feature/sub', + }, + workspaceTree, + ); + + expect(tree.files).toEqual( + jasmine.arrayContaining([ + '/projects/foo/src/feature/sub/public-api.ts', + '/projects/foo/src/feature/sub/lib/sub.ts', + '/projects/foo/src/feature/sub/lib/sub.spec.ts', + ]), + ); + + const pkgJson = getJsonFileContent(tree, '/projects/foo/package.json'); + expect(pkgJson.exports['./feature/sub']).toBe('./src/feature/sub/public-api.ts'); + + const tsConfigJson = getJsonFileContent(tree, '/tsconfig.json'); + expect(tsConfigJson.compilerOptions.paths['foo/feature/sub']).toEqual([ + './dist/foo/feature/sub', + ]); + }); + + it('should support custom entryFile option', async () => { + const tree = await schematicRunner.runSchematic( + 'entry-point', + { + ...defaultOptions, + entryFile: 'my-entry', + }, + workspaceTree, + ); + + expect(tree.files).toContain('/projects/foo/src/secondary/my-entry.ts'); + const pkgJson = getJsonFileContent(tree, '/projects/foo/package.json'); + expect(pkgJson.exports['./secondary']).toBe('./src/secondary/my-entry.ts'); + }); + + it('should strip .ts suffix from entryFile option', async () => { + const tree = await schematicRunner.runSchematic( + 'entry-point', + { + ...defaultOptions, + entryFile: 'my-entry.ts', + }, + workspaceTree, + ); + + expect(tree.files).toContain('/projects/foo/src/secondary/my-entry.ts'); + expect(tree.files).not.toContain('/projects/foo/src/secondary/my-entry.ts.ts'); + const pkgJson = getJsonFileContent(tree, '/projects/foo/package.json'); + expect(pkgJson.exports['./secondary']).toBe('./src/secondary/my-entry.ts'); + }); + + it('should update library package.json exports', async () => { + const tree = await schematicRunner.runSchematic('entry-point', defaultOptions, workspaceTree); + + const pkgJson = getJsonFileContent(tree, '/projects/foo/package.json'); + expect(pkgJson.exports).toEqual({ + '.': './src/public-api.ts', + './secondary': './src/secondary/public-api.ts', + }); + }); + + it('should convert string exports in library package.json to an exports object', async () => { + const pkgJson = getJsonFileContent(workspaceTree, '/projects/foo/package.json'); + pkgJson.exports = './src/public-api.ts'; + workspaceTree.overwrite('/projects/foo/package.json', JSON.stringify(pkgJson, null, 2)); + + const tree = await schematicRunner.runSchematic('entry-point', defaultOptions, workspaceTree); + + const updatedPkgJson = getJsonFileContent(tree, '/projects/foo/package.json'); + expect(updatedPkgJson.exports).toEqual({ + '.': './src/public-api.ts', + './secondary': './src/secondary/public-api.ts', + }); + }); + + it('should convert array exports in library package.json to an exports object', async () => { + const pkgJson = getJsonFileContent(workspaceTree, '/projects/foo/package.json'); + pkgJson.exports = ['./src/public-api.ts']; + workspaceTree.overwrite('/projects/foo/package.json', JSON.stringify(pkgJson, null, 2)); + + const tree = await schematicRunner.runSchematic('entry-point', defaultOptions, workspaceTree); + + const updatedPkgJson = getJsonFileContent(tree, '/projects/foo/package.json'); + expect(updatedPkgJson.exports).toEqual({ + '.': ['./src/public-api.ts'], + './secondary': './src/secondary/public-api.ts', + }); + }); + + it('should convert conditional root exports object in library package.json to an exports object', async () => { + const pkgJson = getJsonFileContent(workspaceTree, '/projects/foo/package.json'); + pkgJson.exports = { default: './src/public-api.ts' }; + workspaceTree.overwrite('/projects/foo/package.json', JSON.stringify(pkgJson, null, 2)); + + const tree = await schematicRunner.runSchematic('entry-point', defaultOptions, workspaceTree); + + const updatedPkgJson = getJsonFileContent(tree, '/projects/foo/package.json'); + expect(updatedPkgJson.exports).toEqual({ + '.': { default: './src/public-api.ts' }, + './secondary': './src/secondary/public-api.ts', + }); + }); + + it('should add paths mapping to root tsconfig.json', async () => { + const tree = await schematicRunner.runSchematic('entry-point', defaultOptions, workspaceTree); + + const tsConfigJson = getJsonFileContent(tree, '/tsconfig.json'); + expect(tsConfigJson.compilerOptions.paths['foo']).toEqual(['./dist/foo']); + expect(tsConfigJson.compilerOptions.paths['foo/secondary']).toEqual(['./dist/foo/secondary']); + }); + + it('should not duplicate existing paths mapping in root tsconfig.json', async () => { + const tsConfig = getJsonFileContent(workspaceTree, '/tsconfig.json'); + tsConfig.compilerOptions.paths['foo/secondary'] = ['./dist/foo/secondary']; + workspaceTree.overwrite('/tsconfig.json', JSON.stringify(tsConfig, null, 2)); + + const tree = await schematicRunner.runSchematic('entry-point', defaultOptions, workspaceTree); + + const tsConfigJson = getJsonFileContent(tree, '/tsconfig.json'); + expect(tsConfigJson.compilerOptions.paths['foo/secondary']).toEqual(['./dist/foo/secondary']); + }); + + it('should respect custom outputPath in angular.json for tsconfig paths mapping', async () => { + const angularJson = getJsonFileContent(workspaceTree, '/angular.json'); + angularJson.projects.foo.architect.build.options.outputPath = 'custom-dist/foo'; + workspaceTree.overwrite('/angular.json', JSON.stringify(angularJson, null, 2)); + + const tree = await schematicRunner.runSchematic('entry-point', defaultOptions, workspaceTree); + + const tsConfigJson = getJsonFileContent(tree, '/tsconfig.json'); + expect(tsConfigJson.compilerOptions.paths['foo/secondary']).toEqual([ + './custom-dist/foo/secondary', + ]); + }); + + it('should respect custom sourceRoot in angular.json', async () => { + const angularJson = getJsonFileContent(workspaceTree, '/angular.json'); + angularJson.projects.foo.sourceRoot = 'projects/foo/custom-src'; + workspaceTree.overwrite('/angular.json', JSON.stringify(angularJson, null, 2)); + + const tree = await schematicRunner.runSchematic('entry-point', defaultOptions, workspaceTree); + + expect(tree.files).toEqual( + jasmine.arrayContaining([ + '/projects/foo/custom-src/secondary/public-api.ts', + '/projects/foo/custom-src/secondary/lib/secondary.ts', + '/projects/foo/custom-src/secondary/lib/secondary.spec.ts', + ]), + ); + + const pkgJson = getJsonFileContent(tree, '/projects/foo/package.json'); + expect(pkgJson.exports['./secondary']).toBe('./custom-src/secondary/public-api.ts'); + }); + + it('should not modify root tsconfig.json when skipTsConfig is true', async () => { + const tree = await schematicRunner.runSchematic( + 'entry-point', + { + ...defaultOptions, + skipTsConfig: true, + }, + workspaceTree, + ); + + const tsConfigJson = getJsonFileContent(tree, '/tsconfig.json'); + expect(tsConfigJson.compilerOptions.paths['foo/secondary']).toBeUndefined(); + }); + + it('should inherit prefix from the library project in angular.json', async () => { + let tree = await schematicRunner.runSchematic( + 'library', + { + ...defaultLibraryOptions, + name: 'prefixed-lib', + prefix: 'custom', + }, + workspaceTree, + ); + + tree = await schematicRunner.runSchematic( + 'entry-point', + { + name: 'secondary', + project: 'prefixed-lib', + }, + tree, + ); + + const componentContent = tree.readContent( + '/projects/prefixed-lib/src/secondary/lib/secondary.ts', + ); + expect(componentContent).toContain("selector: 'custom-secondary'"); + }); + + it('should allow overriding prefix via option', async () => { + const tree = await schematicRunner.runSchematic( + 'entry-point', + { + ...defaultOptions, + prefix: 'override', + }, + workspaceTree, + ); + + const componentContent = tree.readContent('/projects/foo/src/secondary/lib/secondary.ts'); + expect(componentContent).toContain("selector: 'override-secondary'"); + }); + + it('should support scoped libraries', async () => { + let tree = await schematicRunner.runSchematic( + 'library', + { + ...defaultLibraryOptions, + name: '@myScope/myLib', + }, + workspaceTree, + ); + + tree = await schematicRunner.runSchematic( + 'entry-point', + { + name: 'secondary', + project: '@myScope/myLib', + }, + tree, + ); + + expect(tree.files).toEqual( + jasmine.arrayContaining([ + '/projects/my-scope/my-lib/src/secondary/public-api.ts', + '/projects/my-scope/my-lib/src/secondary/lib/secondary.ts', + '/projects/my-scope/my-lib/src/secondary/lib/secondary.spec.ts', + ]), + ); + + const pkgJson = getJsonFileContent(tree, '/projects/my-scope/my-lib/package.json'); + expect(pkgJson.exports).toEqual({ + '.': './src/public-api.ts', + './secondary': './src/secondary/public-api.ts', + }); + + const tsConfigJson = getJsonFileContent(tree, '/tsconfig.json'); + expect(tsConfigJson.compilerOptions.paths['@my-scope/my-lib/secondary']).toEqual([ + './dist/my-scope/my-lib/secondary', + ]); + }); + + it('should respect skipTests option', async () => { + const tree = await schematicRunner.runSchematic( + 'entry-point', + { + ...defaultOptions, + skipTests: true, + }, + workspaceTree, + ); + + expect(tree.files).toContain('/projects/foo/src/secondary/lib/secondary.ts'); + expect(tree.files).not.toContain('/projects/foo/src/secondary/lib/secondary.spec.ts'); + }); + + describe('standalone=false', () => { + it('should create an NgModule and export it in the entry file', async () => { + const tree = await schematicRunner.runSchematic( + 'entry-point', + { + ...defaultOptions, + standalone: false, + }, + workspaceTree, + ); + + expect(tree.files).toEqual( + jasmine.arrayContaining([ + '/projects/foo/src/secondary/public-api.ts', + '/projects/foo/src/secondary/lib/secondary-module.ts', + '/projects/foo/src/secondary/lib/secondary.ts', + '/projects/foo/src/secondary/lib/secondary.spec.ts', + ]), + ); + + const publicApiContent = tree.readContent('/projects/foo/src/secondary/public-api.ts'); + expect(publicApiContent).toContain("export * from './lib/secondary';"); + expect(publicApiContent).toContain("export * from './lib/secondary-module';"); + }); + }); + + describe('error handling', () => { + it('should throw if the project does not exist', async () => { + await expectAsync( + schematicRunner.runSchematic( + 'entry-point', + { + name: 'secondary', + project: 'non-existent', + }, + workspaceTree, + ), + ).toBeRejectedWithError('Project "non-existent" does not exist.'); + }); + + it('should throw if the project is not a library project', async () => { + const appOptions: ApplicationOptions = { + name: 'my-app', + skipInstall: true, + }; + const tree = await schematicRunner.runSchematic('application', appOptions, workspaceTree); + + await expectAsync( + schematicRunner.runSchematic( + 'entry-point', + { + name: 'secondary', + project: 'my-app', + }, + tree, + ), + ).toBeRejectedWithError('Project "my-app" is not a library project.'); + }); + + it('should throw if the library package.json is missing', async () => { + workspaceTree.delete('/projects/foo/package.json'); + + await expectAsync( + schematicRunner.runSchematic('entry-point', defaultOptions, workspaceTree), + ).toBeRejectedWithError('Could not find package.json at "projects/foo/package.json".'); + }); + + it('should throw if the entry-point name is "lib" or starts with "lib/"', async () => { + await expectAsync( + schematicRunner.runSchematic( + 'entry-point', + { + name: 'lib', + project: 'foo', + }, + workspaceTree, + ), + ).toBeRejectedWithError( + 'Entry point name "lib" cannot be "lib" or start with "lib/" as it conflicts with the primary entry point source directory.', + ); + + await expectAsync( + schematicRunner.runSchematic( + 'entry-point', + { + name: 'lib/sub', + project: 'foo', + }, + workspaceTree, + ), + ).toBeRejectedWithError( + 'Entry point name "lib/sub" cannot be "lib" or start with "lib/" as it conflicts with the primary entry point source directory.', + ); + }); + + it('should throw if the entry point already exists in package.json exports', async () => { + const tree = await schematicRunner.runSchematic('entry-point', defaultOptions, workspaceTree); + + await expectAsync( + schematicRunner.runSchematic('entry-point', defaultOptions, tree), + ).toBeRejectedWithError( + 'Entry point "./secondary" already exists in "projects/foo/package.json".', + ); + }); + }); +}); diff --git a/packages/schematics/angular/entry-point/schema.json b/packages/schematics/angular/entry-point/schema.json new file mode 100644 index 000000000000..0dbf29fe37ca --- /dev/null +++ b/packages/schematics/angular/entry-point/schema.json @@ -0,0 +1,56 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "SchematicsEntryPoint", + "title": "Entry Point Options Schema", + "type": "object", + "description": "Creates a new secondary entry point in an Angular library project.", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "The name of the secondary entry point (for example, 'testing' or 'feature/sub').", + "pattern": "^(?:\\.|[a-zA-Z0-9-~][a-zA-Z0-9-._~]*(?:/[a-zA-Z0-9-~][a-zA-Z0-9-._~]*)*)$", + "$default": { + "$source": "argv", + "index": 0 + }, + "x-prompt": "What name would you like to use for the secondary entry point?" + }, + "project": { + "type": "string", + "description": "The name of the library project.", + "$default": { + "$source": "projectName" + } + }, + "entryFile": { + "type": "string", + "format": "path", + "description": "The file name at which to create the secondary entry point's public API file, without the `.ts` extension.", + "default": "public-api" + }, + "prefix": { + "type": "string", + "format": "html-selector", + "description": "A prefix to apply to generated selectors.", + "alias": "p" + }, + "standalone": { + "description": "Creates a secondary entry point based upon the standalone API, without NgModules.", + "type": "boolean", + "default": true, + "x-user-analytics": "ep.ng_standalone" + }, + "skipTests": { + "type": "boolean", + "default": false, + "description": "Do not create \"spec.ts\" test files for the new secondary entry point." + }, + "skipTsConfig": { + "type": "boolean", + "default": false, + "description": "Do not update \"tsconfig.json\" to add a path mapping for the new secondary entry point." + } + }, + "required": ["name", "project"] +} diff --git a/packages/schematics/angular/library/files/ng-package.json.template b/packages/schematics/angular/library/files/ng-package.json.template deleted file mode 100644 index c0f915caf7b2..000000000000 --- a/packages/schematics/angular/library/files/ng-package.json.template +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "<%= relativePathToWorkspaceRoot %>/node_modules/ng-packagr/ng-package.schema.json", - "dest": "<%= relativePathToWorkspaceRoot %>/<%= distRoot %>", - "lib": { - "entryFile": "src/<%= entryFile %>.ts" - } -} \ No newline at end of file diff --git a/packages/schematics/angular/library/files/package.json.template b/packages/schematics/angular/library/files/package.json.template index 1d50c8cbcc34..992af68cfa44 100644 --- a/packages/schematics/angular/library/files/package.json.template +++ b/packages/schematics/angular/library/files/package.json.template @@ -5,8 +5,5 @@ "@angular/common": "^<%= angularLatestVersion %>", "@angular/core": "^<%= angularLatestVersion %>" }, - "dependencies": { - "tslib": "^<%= tsLibLatestVersion %>" - }, "sideEffects": false } diff --git a/packages/schematics/angular/library/files/tsconfig.lib.json.template b/packages/schematics/angular/library/files/tsconfig.lib.json.template index 779ab5549814..23e52647e3cf 100644 --- a/packages/schematics/angular/library/files/tsconfig.lib.json.template +++ b/packages/schematics/angular/library/files/tsconfig.lib.json.template @@ -3,9 +3,7 @@ { "extends": "<%= relativePathToWorkspaceRoot %>/tsconfig.json", "compilerOptions": { - "outDir": "<%= relativePathToWorkspaceRoot %>/out-tsc/lib", "declaration": true, - "declarationMap": true, "types": [] }, "include": [ diff --git a/packages/schematics/angular/library/files/tsconfig.lib.prod.json.template b/packages/schematics/angular/library/files/tsconfig.lib.prod.json.template deleted file mode 100644 index 9215caac46fb..000000000000 --- a/packages/schematics/angular/library/files/tsconfig.lib.prod.json.template +++ /dev/null @@ -1,11 +0,0 @@ -/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ -/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ -{ - "extends": "./tsconfig.lib.json", - "compilerOptions": { - "declarationMap": false - }, - "angularCompilerOptions": { - "compilationMode": "partial" - } -} diff --git a/packages/schematics/angular/library/index.ts b/packages/schematics/angular/library/index.ts index 3069664c02d7..f7fdc402b7cc 100644 --- a/packages/schematics/angular/library/index.ts +++ b/packages/schematics/angular/library/index.ts @@ -7,8 +7,8 @@ */ import { - Rule, - Tree, + type Rule, + type Tree, apply, applyTemplates, chain, @@ -33,28 +33,14 @@ import { latestVersions } from '../utility/latest-versions'; import { relativePathToWorkspaceRoot } from '../utility/paths'; import { getWorkspace, updateWorkspace } from '../utility/workspace'; import { Builders, ProjectType } from '../utility/workspace-models'; -import { Schema as LibraryOptions, TestRunner } from './schema'; +import { type Schema as LibraryOptions, TestRunner } from './schema'; const LIBRARY_DEV_DEPENDENCIES = [ { name: '@angular/compiler-cli', version: latestVersions.Angular }, { name: '@angular/build', version: latestVersions.AngularBuild }, - { name: 'ng-packagr', version: latestVersions.NgPackagr }, { name: 'typescript', version: latestVersions['typescript'] }, ]; -function updateTsConfig(packageName: string, ...paths: string[]) { - return (host: Tree) => { - if (!host.exists('tsconfig.json')) { - return host; - } - - const file = new JSONFile(host, 'tsconfig.json'); - const jsonPath = ['compilerOptions', 'paths', packageName]; - const value = file.get(jsonPath); - file.modify(jsonPath, Array.isArray(value) ? [...value, ...paths] : paths); - }; -} - function addTsProjectReference(...paths: string[]) { return (host: Tree) => { if (!host.exists('tsconfig.json')) { @@ -92,6 +78,8 @@ function addLibToWorkspaceFile( options: LibraryOptions, projectRoot: string, projectName: string, + folderName: string, + distRoot: string, hasZoneDependency: boolean, ): Rule { return updateWorkspace((workspace) => { @@ -103,14 +91,17 @@ function addLibToWorkspaceFile( prefix: options.prefix, targets: { build: { - builder: Builders.BuildNgPackagr, + builder: Builders.BuildLibrary, defaultConfiguration: 'production', + options: { + tsConfig: `${projectRoot}/tsconfig.lib.json`, + ...(folderName !== projectName ? { outputPath: distRoot } : {}), + }, configurations: { - production: { - tsConfig: `${projectRoot}/tsconfig.lib.prod.json`, - }, + production: {}, development: { - tsConfig: `${projectRoot}/tsconfig.lib.json`, + compilationMode: 'full', + declarationMap: true, }, }, }, @@ -137,13 +128,7 @@ function addLibToWorkspaceFile( export default function (options: LibraryOptions): Rule { return async (host: Tree) => { const prefix = options.prefix; - - // If scoped project (i.e. "@foo/bar"), convert projectDir to "foo/bar". const packageName = options.name; - if (/^@.*\/.*/.test(options.name)) { - const [, name] = options.name.split('/'); - options.name = name; - } const workspace = await getWorkspace(host); const newProjectRoot = (workspace.extensions.newProjectRoot as string | undefined) || ''; @@ -159,20 +144,18 @@ export default function (options: LibraryOptions): Rule { : join(newProjectRoot, folderName); const distRoot = `dist/${folderName}`; - const sourceDir = `${libDir}/src/lib`; + + // If scoped project (i.e. "@foo/bar"), convert name to "bar" for templates. + const name = /^@.*\/.*/.test(options.name) ? options.name.split('/')[1] : options.name; const templateSource = apply(url('./files'), [ applyTemplates({ ...strings, ...options, + name, packageName, - libDir, - distRoot, relativePathToWorkspaceRoot: relativePathToWorkspaceRoot(libDir), - prefix, angularLatestVersion: latestVersions.Angular.replace(/~|\^/, ''), - tsLibLatestVersion: latestVersions['tslib'].replace(/~|\^/, ''), - folderName, testTypesPackage: options.testRunner === TestRunner.Vitest ? 'vitest/globals' : 'jasmine', }), move(libDir), @@ -182,41 +165,21 @@ export default function (options: LibraryOptions): Rule { return chain([ mergeWith(templateSource), - addLibToWorkspaceFile(options, libDir, packageName, hasZoneDependency), + addLibToWorkspaceFile(options, libDir, packageName, folderName, distRoot, hasZoneDependency), options.skipPackageJson ? noop() : addDependenciesToPackageJson(options), - options.skipTsConfig ? noop() : updateTsConfig(packageName, './' + distRoot), options.skipTsConfig ? noop() : addTsProjectReference( './' + join(libDir, 'tsconfig.lib.json'), './' + join(libDir, 'tsconfig.spec.json'), ), - options.standalone - ? noop() - : schematic('module', { - name: options.name, - commonModule: false, - flat: true, - path: sourceDir, - project: packageName, - // Explicitly set the `typeSeparator` this also ensures that the generated files are valid even if the `module` schematic - // inherits its `typeSeparator` from the workspace. - typeSeparator: '-', - }), - schematic('component', { - name: options.name, - selector: `${prefix}-${options.name}`, - inlineStyle: true, - inlineTemplate: true, - flat: true, - path: sourceDir, - export: true, - standalone: options.standalone, + schematic('entry-point', { + name: '.', project: packageName, - // Explicitly set an empty `type` since it doesn't necessarily make sense in a library. - // This also ensures that the generated files are valid even if the `component` schematic - // inherits its `type` from the workspace. - type: '', + entryFile: options.entryFile, + prefix, + standalone: options.standalone, + skipTsConfig: options.skipTsConfig, }), ]); }; diff --git a/packages/schematics/angular/library/index_spec.ts b/packages/schematics/angular/library/index_spec.ts index 9d76bb913d17..e116919bdb25 100644 --- a/packages/schematics/angular/library/index_spec.ts +++ b/packages/schematics/angular/library/index_spec.ts @@ -6,12 +6,11 @@ * found in the LICENSE file at https://angular.dev/license */ -import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing'; -import { parse as parseJson } from 'jsonc-parser'; -import { Schema as ComponentOptions } from '../component/schema'; +import { SchematicTestRunner, type UnitTestTree } from '@angular-devkit/schematics/testing'; +import type { Schema as ComponentOptions } from '../component/schema'; import { latestVersions } from '../utility/latest-versions'; -import { Schema as WorkspaceOptions } from '../workspace/schema'; -import { Schema as GenerateLibrarySchema } from './schema'; +import type { Schema as WorkspaceOptions } from '../workspace/schema'; +import type { Schema as GenerateLibrarySchema } from './schema'; // eslint-disable-next-line @typescript-eslint/no-explicit-any function getJsonFileContent(tree: UnitTestTree, path: string): any { @@ -20,7 +19,7 @@ function getJsonFileContent(tree: UnitTestTree, path: string): any { describe('Library Schematic', () => { const schematicRunner = new SchematicTestRunner( - '@schematics/ng_packagr', + '@schematics/angular', require.resolve('../collection.json'), ); const defaultOptions: GenerateLibrarySchema = { @@ -48,16 +47,16 @@ describe('Library Schematic', () => { const files = tree.files; expect(files).toEqual( jasmine.arrayContaining([ - '/projects/foo/ng-package.json', '/projects/foo/package.json', '/projects/foo/README.md', '/projects/foo/tsconfig.lib.json', - '/projects/foo/tsconfig.lib.prod.json', '/projects/foo/src/my-index.ts', '/projects/foo/src/lib/foo.spec.ts', '/projects/foo/src/lib/foo.ts', ]), ); + expect(files).not.toContain('/projects/foo/ng-package.json'); + expect(files).not.toContain('/projects/foo/tsconfig.lib.prod.json'); }); it('should not add reference to module file in entry-file', async () => { @@ -91,16 +90,16 @@ describe('Library Schematic', () => { const files = tree.files; expect(files).toEqual( jasmine.arrayContaining([ - '/some/other/directory/bar/ng-package.json', '/some/other/directory/bar/package.json', '/some/other/directory/bar/README.md', '/some/other/directory/bar/tsconfig.lib.json', - '/some/other/directory/bar/tsconfig.lib.prod.json', '/some/other/directory/bar/src/my-index.ts', '/some/other/directory/bar/src/lib/foo.spec.ts', '/some/other/directory/bar/src/lib/foo.ts', ]), ); + expect(files).not.toContain('/some/other/directory/bar/ng-package.json'); + expect(files).not.toContain('/some/other/directory/bar/tsconfig.lib.prod.json'); }); it(`should add library to workspace`, async () => { @@ -149,15 +148,20 @@ describe('Library Schematic', () => { const fileContent = getJsonFileContent(tree, '/projects/foo/tsconfig.lib.json'); expect(fileContent).toBeDefined(); + expect(fileContent.compilerOptions.declaration).toBe(true); + expect(fileContent.compilerOptions.declarationMap).toBeUndefined(); + expect(fileContent.include).toEqual(['src/**/*.ts']); + expect(fileContent.exclude).toEqual(['**/*.spec.ts']); }); - it('should create a ng-package.json with ngPackage conf', async () => { + it('should configure exports and not include tslib in library package.json', async () => { const tree = await schematicRunner.runSchematic('library', defaultOptions, workspaceTree); - const fileContent = getJsonFileContent(tree, '/projects/foo/ng-package.json'); - expect(fileContent.lib).toBeDefined(); - expect(fileContent.lib.entryFile).toEqual('src/my-index.ts'); - expect(fileContent.dest).toEqual('../../dist/foo'); + const fileContent = getJsonFileContent(tree, '/projects/foo/package.json'); + expect(fileContent.exports).toEqual({ + '.': './src/my-index.ts', + }); + expect(fileContent.dependencies).toBeUndefined(); }); it('should use default value for baseDir and entryFile', async () => { @@ -170,6 +174,10 @@ describe('Library Schematic', () => { ); expect(tree.files).toContain('/projects/foobar/src/public-api.ts'); + const fileContent = getJsonFileContent(tree, '/projects/foobar/package.json'); + expect(fileContent.exports).toEqual({ + '.': './src/public-api.ts', + }); }); it(`should add library to workspace`, async () => { @@ -209,16 +217,18 @@ describe('Library Schematic', () => { const project = config.projects.pascalCasedName; expect(project).toBeDefined(); expect(project.root).toEqual('projects/pascal-cased-name'); + expect(project.architect.build.options.outputPath).toEqual('dist/pascal-cased-name'); const svcContent = tree.readContent('/projects/pascal-cased-name/src/lib/pascal-cased-name.ts'); expect(svcContent).toContain('@Component'); }); describe(`update package.json`, () => { - it(`should add ng-packagr to devDependencies`, async () => { + it(`should add @angular/build and not ng-packagr to devDependencies`, async () => { const tree = await schematicRunner.runSchematic('library', defaultOptions, workspaceTree); const packageJson = getJsonFileContent(tree, 'package.json'); - expect(packageJson.devDependencies['ng-packagr']).toBeDefined(); + expect(packageJson.devDependencies['@angular/build']).toBe(latestVersions.AngularBuild); + expect(packageJson.devDependencies['ng-packagr']).toBeUndefined(); }); it('should use the latest known versions in package.json', async () => { @@ -256,7 +266,7 @@ describe('Library Schematic', () => { ); const packageJson = getJsonFileContent(tree, 'package.json'); - expect(packageJson.devDependencies['ng-packagr']).toBeUndefined(); + expect(packageJson.devDependencies['@angular/build']).toBeUndefined(); expect(packageJson.devDependencies['@angular-devkit/build-angular']).toBeUndefined(); }); }); @@ -348,6 +358,9 @@ describe('Library Schematic', () => { const cfg = JSON.parse(tree.readContent('/angular.json')); expect(cfg.projects['@myscope/mylib']).toBeDefined(); + expect(cfg.projects['@myscope/mylib'].architect.build.options.outputPath).toEqual( + 'dist/myscope/mylib', + ); const rootTsCfg = getJsonFileContent(tree, '/tsconfig.json'); expect(rootTsCfg.compilerOptions.paths['@myscope/mylib']).toEqual(['./dist/myscope/mylib']); @@ -363,14 +376,19 @@ describe('Library Schematic', () => { const pkgJsonPath = '/projects/my-scope/my-lib/package.json'; expect(tree.readContent(pkgJsonPath)).toContain(expectedScopeName); - const ngPkgJsonPath = '/projects/my-scope/my-lib/ng-package.json'; - expect(tree.readContent(ngPkgJsonPath)).toContain(expectedFolderName); - const pkgJson = JSON.parse(tree.readContent(pkgJsonPath)); expect(pkgJson.name).toEqual(expectedScopeName); const cfg = JSON.parse(tree.readContent('/angular.json')); expect(cfg.projects['@myScope/myLib']).toBeDefined(); + expect(cfg.projects['@myScope/myLib'].architect.build.options.outputPath).toEqual( + `dist/${expectedFolderName}`, + ); + + const rootTsCfg = getJsonFileContent(tree, '/tsconfig.json'); + expect(rootTsCfg.compilerOptions.paths['@my-scope/my-lib']).toEqual([ + `./dist/${expectedFolderName}`, + ]); }); it(`should create correct paths when 'newProjectRoot' is blank`, async () => { @@ -385,7 +403,9 @@ describe('Library Schematic', () => { const project = config.projects.foo; expect(project.root).toEqual('foo'); const { options, configurations } = project.architect.build; - expect(configurations.production.tsConfig).toEqual('foo/tsconfig.lib.prod.json'); + expect(options.tsConfig).toEqual('foo/tsconfig.lib.json'); + expect(options.outputPath).toBeUndefined(); + expect(configurations.production).toEqual({}); const libTsConfig = getJsonFileContent(tree, '/foo/tsconfig.lib.json'); expect(libTsConfig.extends).toEqual('../tsconfig.json'); @@ -397,14 +417,23 @@ describe('Library Schematic', () => { const tree = await schematicRunner.runSchematic('library', defaultOptions, workspaceTree); const workspace = JSON.parse(tree.readContent('/angular.json')); - expect(workspace.projects.foo.architect.build.configurations.development).toBeDefined(); + expect(workspace.projects.foo.architect.build.configurations.development).toEqual({ + compilationMode: 'full', + declarationMap: true, + }); }); - it(`should add 'ng-packagr' builder`, async () => { + it(`should add '@angular/build:library' builder`, async () => { const tree = await schematicRunner.runSchematic('library', defaultOptions, workspaceTree); const workspace = JSON.parse(tree.readContent('/angular.json')); - expect(workspace.projects.foo.architect.build.builder).toBe('@angular/build:ng-packagr'); + expect(workspace.projects.foo.architect.build.builder).toBe('@angular/build:library'); + expect(workspace.projects.foo.architect.build.defaultConfiguration).toBe('production'); + expect(workspace.projects.foo.architect.build.options.tsConfig).toBe( + 'projects/foo/tsconfig.lib.json', + ); + expect(workspace.projects.foo.architect.build.options.outputPath).toBeUndefined(); + expect(workspace.projects.foo.architect.build.configurations.production).toEqual({}); }); it(`should add 'unit-test' test builder`, async () => { @@ -449,11 +478,9 @@ describe('Library Schematic', () => { const files = tree.files; expect(files).toEqual( jasmine.arrayContaining([ - '/projects/foo/ng-package.json', '/projects/foo/package.json', '/projects/foo/README.md', '/projects/foo/tsconfig.lib.json', - '/projects/foo/tsconfig.lib.prod.json', '/projects/foo/src/my-index.ts', '/projects/foo/src/lib/foo-module.ts', '/projects/foo/src/lib/foo.spec.ts', diff --git a/tests/e2e/tests/build/library-with-demo-app.ts b/tests/e2e/tests/build/library-with-demo-app.ts index 8e8d14ceb54f..4cabfb25d2f8 100644 --- a/tests/e2e/tests/build/library-with-demo-app.ts +++ b/tests/e2e/tests/build/library-with-demo-app.ts @@ -6,19 +6,14 @@ * found in the LICENSE file at https://angular.dev/license */ -import { appendToFile, createDir, writeFile } from '../../utils/fs'; +import { appendToFile } from '../../utils/fs'; import { ng } from '../../utils/process'; import { updateJsonFile } from '../../utils/project'; export default async function () { await ng('generate', 'library', 'mylib'); - await createLibraryEntryPoint('secondary'); - await createLibraryEntryPoint('another'); - - // Scenario #1 where we use wildcard path mappings for secondary entry-points. - await updateJsonFile('tsconfig.json', (json) => { - json.compilerOptions.paths = { 'mylib': ['./dist/mylib'], 'mylib/*': ['./dist/mylib/*'] }; - }); + await ng('generate', 'entry-point', 'secondary', '--project', 'mylib'); + await ng('generate', 'entry-point', 'another', '--project', 'mylib'); await appendToFile( 'src/app/app.config.ts', @@ -33,31 +28,14 @@ export default async function () { `, ); + // Scenario #1 where we use non-wildcard path mappings as generated by the schematics. await ng('build', 'mylib'); await ng('build'); - // Scenario #2 where we don't use wildcard path mappings. + // Scenario #2 where we use wildcard path mappings for secondary entry-points. await updateJsonFile('tsconfig.json', (json) => { - json.compilerOptions.paths = { - 'mylib': ['./dist/mylib'], - 'mylib/secondary': ['./dist/mylib/secondary'], - 'mylib/another': ['./dist/mylib/another'], - }; + json.compilerOptions.paths = { 'mylib': ['./dist/mylib'], 'mylib/*': ['./dist/mylib/*'] }; }); await ng('build'); } - -async function createLibraryEntryPoint(name: string): Promise { - await createDir(`projects/mylib/${name}`); - await writeFile(`projects/mylib/${name}/index.ts`, `export const foo = 'foo';`); - - await writeFile( - `projects/mylib/${name}/ng-package.json`, - JSON.stringify({ - lib: { - entryFile: 'index.ts', - }, - }), - ); -} diff --git a/tests/e2e/tests/vitest/library.ts b/tests/e2e/tests/vitest/library.ts index 0bb989057279..23a691ad7cca 100644 --- a/tests/e2e/tests/vitest/library.ts +++ b/tests/e2e/tests/vitest/library.ts @@ -1,3 +1,11 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + import assert from 'node:assert/strict'; import { updateJsonFile } from '../../utils/project'; import { ng } from '../../utils/process'; @@ -12,11 +20,11 @@ export default async function (): Promise { await createDir('projects/my-lib/src/styles'); await writeFile('projects/my-lib/src/styles/_vars.scss', '$primary-color: red;'); - // 2. Update ng-package.json to include the styles directory - await updateJsonFile('projects/my-lib/ng-package.json', (json) => { - json['lib'] = { - ...json['lib'], - styleIncludePaths: ['./src/styles'], + // 2. Update angular.json to include the styles directory + await updateJsonFile('angular.json', (json) => { + const buildOptions = (json['projects']['my-lib']['architect']['build']['options'] ??= {}); + buildOptions.stylePreprocessorOptions = { + includePaths: ['projects/my-lib/src/styles'], }; }); From a32141cf4ed54a167bf460b8928e65c5a8ae71a6 Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Thu, 5 Nov 2026 09:59:59 +0000 Subject: [PATCH 2/6] fixup! feat(@schematics/angular): use library builder and add entry-point schematic --- packages/schematics/angular/README.md | 1 + .../schematics/angular/entry-point/index.ts | 73 ++++++----- .../angular/entry-point/index_spec.ts | 114 +++++++++++++----- .../angular/entry-point/schema.json | 12 +- packages/schematics/angular/library/index.ts | 4 +- .../e2e/tests/build/library-with-demo-app.ts | 12 +- 6 files changed, 144 insertions(+), 72 deletions(-) diff --git a/packages/schematics/angular/README.md b/packages/schematics/angular/README.md index 8fa52ff7e608..86c5e2027871 100644 --- a/packages/schematics/angular/README.md +++ b/packages/schematics/angular/README.md @@ -13,6 +13,7 @@ for generating an Angular application. | component | Creates a new, generic component definition in the given project | | directive | Creates a new, generic directive definition in the given project | | enum | Generates a new, generic enum definition in the given project | +| entry-point | Creates a new secondary entry point in an Angular library project | | guard | Generates a new, generic route guard definition in the given project | | interceptor | Creates a new, generic interceptor definition in the given project | | interface | Creates a new, generic interface definition in the given project | diff --git a/packages/schematics/angular/entry-point/index.ts b/packages/schematics/angular/entry-point/index.ts index 29726f0bbee5..2ff15ea4a1e5 100644 --- a/packages/schematics/angular/entry-point/index.ts +++ b/packages/schematics/angular/entry-point/index.ts @@ -24,7 +24,7 @@ import { import { basename, join, relative } from 'node:path/posix'; import { JSONFile } from '../utility/json-file'; import { createProjectSchematic } from '../utility/project'; -import { ProjectType } from '../utility/workspace-models'; +import { Builders, ProjectType } from '../utility/workspace-models'; import type { Schema as EntryPointOptions } from './schema'; function updateTsConfig(packageName: string, ...paths: string[]): Rule { @@ -45,10 +45,21 @@ export default createProjectSchematic((options, { project, tr throw new SchematicsException(`Project "${options.project}" is not a library project.`); } - if (options.name === 'lib' || options.name.startsWith('lib/')) { + const buildTarget = project.targets.get('build'); + if (buildTarget?.builder !== Builders.BuildLibrary) { throw new SchematicsException( - `Entry point name "${options.name}" cannot be "lib" or start with "lib/" ` + - 'as it conflicts with the primary entry point source directory.', + `Project "${options.project}" does not use the "${Builders.BuildLibrary}" builder.`, + ); + } + + if (options.name === 'package.json') { + throw new SchematicsException('Entry point name "package.json" is reserved.'); + } + + if (options.name.split('/').includes('lib')) { + throw new SchematicsException( + `Entry point name "${options.name}" cannot be "lib" or contain a "lib" segment ` + + 'as it conflicts with the entry point source directory.', ); } @@ -66,7 +77,7 @@ export default createProjectSchematic((options, { project, tr : strings.dasherize(options.project); const isPrimary = options.name === '.'; - const entryFile = (options.entryFile ?? 'public-api').replace(/\.[cm]?ts$/i, ''); + const entryFile = (options.entryFile ?? 'public-api').replace(/\.ts$/i, ''); const sourceRoot = project.sourceRoot ?? join(libDir, 'src'); const entryPointDir = join(sourceRoot, options.name); const exportKey = isPrimary ? '.' : `./${options.name}`; @@ -95,15 +106,19 @@ export default createProjectSchematic((options, { project, tr isJsonObject(existingExports) && Object.keys(existingExports).some((k) => k.startsWith('.')) ) { - packageJson.modify(['exports', exportKey], exportTarget); + packageJson.modify(['exports', exportKey], exportTarget, false); } else { - packageJson.modify(['exports'], { - ...exportsObject, - [exportKey]: exportTarget, - }); + packageJson.modify( + ['exports'], + { + ...exportsObject, + [exportKey]: exportTarget, + }, + false, + ); } - const prefix = options.prefix ?? project.prefix ?? 'lib'; + const prefix = (options.prefix ?? project.prefix) || 'lib'; const sourceDir = join(entryPointDir, 'lib'); const artifactName = isPrimary ? /^@.*\/.*/.test(options.project) @@ -111,25 +126,22 @@ export default createProjectSchematic((options, { project, tr : options.project : basename(options.name); - const buildOptions = project.targets.get('build')?.options; - let distRoot: string; - if (typeof buildOptions?.['outputPath'] === 'string') { - distRoot = buildOptions['outputPath']; - } else { - let folderName = options.project.startsWith('@') ? options.project.slice(1) : options.project; - if (/[A-Z]/.test(folderName)) { - folderName = strings.dasherize(folderName); - } - distRoot = `dist/${folderName}`; - } + const buildOptions = buildTarget.options; + const distRoot = + typeof buildOptions?.['outputPath'] === 'string' + ? buildOptions['outputPath'] + : join('dist', options.project); + + const tsConfigPathKey = isPrimary ? packageName : `${packageName}/*`; + const tsConfigPathTarget = `./${join(distRoot, isPrimary ? '.' : '*')}`; - const tsConfigPathKey = isPrimary ? packageName : `${packageName}/${options.name}`; - const tsConfigPathTarget = `./${join(distRoot, options.name)}`; + const standalone = options.standalone ?? true; - const templateSource = apply(url('./files'), [ + const templateSource = apply(url('../entry-point/files'), [ applyTemplates({ ...strings, ...options, + standalone, name: artifactName, entryFile, }), @@ -139,7 +151,7 @@ export default createProjectSchematic((options, { project, tr return chain([ mergeWith(templateSource), options.skipTsConfig ? noop() : updateTsConfig(tsConfigPathKey, tsConfigPathTarget), - options.standalone + standalone ? noop() : schematic('module', { name: artifactName, @@ -159,13 +171,14 @@ export default createProjectSchematic((options, { project, tr flat: true, path: sourceDir, export: true, - standalone: options.standalone, + standalone, skipTests: options.skipTests, project: options.project, - // Explicitly set an empty `type` since it doesn't necessarily make sense in a library. - // This also ensures that the generated files are valid even if the `component` schematic - // inherits its `type` from the workspace. + // Explicitly set an empty `type` and `exportDefault: false` since they don't necessarily make + // sense in a library and ensure that the generated files match the entry-point exports even + // if the `component` schematic inherits options from the workspace. type: '', + exportDefault: false, }), ]); }); diff --git a/packages/schematics/angular/entry-point/index_spec.ts b/packages/schematics/angular/entry-point/index_spec.ts index 8eb0e875f844..ce682b9cfdd3 100644 --- a/packages/schematics/angular/entry-point/index_spec.ts +++ b/packages/schematics/angular/entry-point/index_spec.ts @@ -89,9 +89,7 @@ describe('EntryPoint Schematic', () => { expect(pkgJson.exports['./feature/sub']).toBe('./src/feature/sub/public-api.ts'); const tsConfigJson = getJsonFileContent(tree, '/tsconfig.json'); - expect(tsConfigJson.compilerOptions.paths['foo/feature/sub']).toEqual([ - './dist/foo/feature/sub', - ]); + expect(tsConfigJson.compilerOptions.paths['foo/*']).toEqual(['./dist/foo/*']); }); it('should support custom entryFile option', async () => { @@ -133,6 +131,7 @@ describe('EntryPoint Schematic', () => { '.': './src/public-api.ts', './secondary': './src/secondary/public-api.ts', }); + expect(Object.keys(pkgJson)[0]).toBe('name'); }); it('should convert string exports in library package.json to an exports object', async () => { @@ -177,23 +176,23 @@ describe('EntryPoint Schematic', () => { }); }); - it('should add paths mapping to root tsconfig.json', async () => { + it('should add wildcard paths mapping to root tsconfig.json', async () => { const tree = await schematicRunner.runSchematic('entry-point', defaultOptions, workspaceTree); const tsConfigJson = getJsonFileContent(tree, '/tsconfig.json'); expect(tsConfigJson.compilerOptions.paths['foo']).toEqual(['./dist/foo']); - expect(tsConfigJson.compilerOptions.paths['foo/secondary']).toEqual(['./dist/foo/secondary']); + expect(tsConfigJson.compilerOptions.paths['foo/*']).toEqual(['./dist/foo/*']); }); it('should not duplicate existing paths mapping in root tsconfig.json', async () => { const tsConfig = getJsonFileContent(workspaceTree, '/tsconfig.json'); - tsConfig.compilerOptions.paths['foo/secondary'] = ['./dist/foo/secondary']; + tsConfig.compilerOptions.paths['foo/*'] = ['./dist/foo/*']; workspaceTree.overwrite('/tsconfig.json', JSON.stringify(tsConfig, null, 2)); const tree = await schematicRunner.runSchematic('entry-point', defaultOptions, workspaceTree); const tsConfigJson = getJsonFileContent(tree, '/tsconfig.json'); - expect(tsConfigJson.compilerOptions.paths['foo/secondary']).toEqual(['./dist/foo/secondary']); + expect(tsConfigJson.compilerOptions.paths['foo/*']).toEqual(['./dist/foo/*']); }); it('should respect custom outputPath in angular.json for tsconfig paths mapping', async () => { @@ -204,9 +203,7 @@ describe('EntryPoint Schematic', () => { const tree = await schematicRunner.runSchematic('entry-point', defaultOptions, workspaceTree); const tsConfigJson = getJsonFileContent(tree, '/tsconfig.json'); - expect(tsConfigJson.compilerOptions.paths['foo/secondary']).toEqual([ - './custom-dist/foo/secondary', - ]); + expect(tsConfigJson.compilerOptions.paths['foo/*']).toEqual(['./custom-dist/foo/*']); }); it('should respect custom sourceRoot in angular.json', async () => { @@ -239,7 +236,7 @@ describe('EntryPoint Schematic', () => { ); const tsConfigJson = getJsonFileContent(tree, '/tsconfig.json'); - expect(tsConfigJson.compilerOptions.paths['foo/secondary']).toBeUndefined(); + expect(tsConfigJson.compilerOptions.paths['foo/*']).toBeUndefined(); }); it('should inherit prefix from the library project in angular.json', async () => { @@ -268,6 +265,17 @@ describe('EntryPoint Schematic', () => { expect(componentContent).toContain("selector: 'custom-secondary'"); }); + it('should fallback to "lib" prefix when project prefix is empty in angular.json', async () => { + const angularJson = getJsonFileContent(workspaceTree, '/angular.json'); + angularJson.projects.foo.prefix = ''; + workspaceTree.overwrite('/angular.json', JSON.stringify(angularJson, null, 2)); + + const tree = await schematicRunner.runSchematic('entry-point', defaultOptions, workspaceTree); + + const componentContent = tree.readContent('/projects/foo/src/secondary/lib/secondary.ts'); + expect(componentContent).toContain("selector: 'lib-secondary'"); + }); + it('should allow overriding prefix via option', async () => { const tree = await schematicRunner.runSchematic( 'entry-point', @@ -316,8 +324,8 @@ describe('EntryPoint Schematic', () => { }); const tsConfigJson = getJsonFileContent(tree, '/tsconfig.json'); - expect(tsConfigJson.compilerOptions.paths['@my-scope/my-lib/secondary']).toEqual([ - './dist/my-scope/my-lib/secondary', + expect(tsConfigJson.compilerOptions.paths['@my-scope/my-lib/*']).toEqual([ + './dist/my-scope/my-lib/*', ]); }); @@ -335,6 +343,24 @@ describe('EntryPoint Schematic', () => { expect(tree.files).not.toContain('/projects/foo/src/secondary/lib/secondary.spec.ts'); }); + it('should not be affected by workspace component schematic type and exportDefault defaults', async () => { + const angularJson = getJsonFileContent(workspaceTree, '/angular.json'); + angularJson.schematics = { + '@schematics/angular:component': { + type: 'component', + exportDefault: true, + }, + }; + workspaceTree.overwrite('/angular.json', JSON.stringify(angularJson, null, 2)); + + const tree = await schematicRunner.runSchematic('entry-point', defaultOptions, workspaceTree); + + expect(tree.files).toContain('/projects/foo/src/secondary/lib/secondary.ts'); + const componentContent = tree.readContent('/projects/foo/src/secondary/lib/secondary.ts'); + expect(componentContent).toContain('export class Secondary'); + expect(componentContent).not.toContain('export default'); + }); + describe('standalone=false', () => { it('should create an NgModule and export it in the entry file', async () => { const tree = await schematicRunner.runSchematic( @@ -394,6 +420,16 @@ describe('EntryPoint Schematic', () => { ).toBeRejectedWithError('Project "my-app" is not a library project.'); }); + it('should throw if the library project does not use @angular/build:library', async () => { + const angularJson = getJsonFileContent(workspaceTree, '/angular.json'); + angularJson.projects.foo.architect.build.builder = '@angular/build:ng-packagr'; + workspaceTree.overwrite('/angular.json', JSON.stringify(angularJson, null, 2)); + + await expectAsync( + schematicRunner.runSchematic('entry-point', defaultOptions, workspaceTree), + ).toBeRejectedWithError('Project "foo" does not use the "@angular/build:library" builder.'); + }); + it('should throw if the library package.json is missing', async () => { workspaceTree.delete('/projects/foo/package.json'); @@ -402,32 +438,50 @@ describe('EntryPoint Schematic', () => { ).toBeRejectedWithError('Could not find package.json at "projects/foo/package.json".'); }); - it('should throw if the entry-point name is "lib" or starts with "lib/"', async () => { + it('should throw if the entry-point name is "package.json"', async () => { await expectAsync( schematicRunner.runSchematic( 'entry-point', { - name: 'lib', + name: 'package.json', project: 'foo', }, workspaceTree, ), - ).toBeRejectedWithError( - 'Entry point name "lib" cannot be "lib" or start with "lib/" as it conflicts with the primary entry point source directory.', - ); + ).toBeRejectedWithError('Entry point name "package.json" is reserved.'); + }); - await expectAsync( - schematicRunner.runSchematic( - 'entry-point', - { - name: 'lib/sub', - project: 'foo', - }, - workspaceTree, - ), - ).toBeRejectedWithError( - 'Entry point name "lib/sub" cannot be "lib" or start with "lib/" as it conflicts with the primary entry point source directory.', - ); + it('should throw if the entry-point name is "lib" or contains a "lib" segment', async () => { + for (const invalidName of ['lib', 'lib/sub', 'feature/lib', 'feature/lib/sub']) { + await expectAsync( + schematicRunner.runSchematic( + 'entry-point', + { + name: invalidName, + project: 'foo', + }, + workspaceTree, + ), + ).toBeRejectedWithError( + `Entry point name "${invalidName}" cannot be "lib" or contain a "lib" segment ` + + 'as it conflicts with the entry point source directory.', + ); + } + }); + + it('should reject invalid entry-point names via schema validation', async () => { + for (const invalidName of ['.', '../foo', 'foo..bar', '/secondary', 'secondary/', './sub']) { + await expectAsync( + schematicRunner.runSchematic( + 'entry-point', + { + name: invalidName, + project: 'foo', + }, + workspaceTree, + ), + ).toBeRejected(); + } }); it('should throw if the entry point already exists in package.json exports', async () => { diff --git a/packages/schematics/angular/entry-point/schema.json b/packages/schematics/angular/entry-point/schema.json index 0dbf29fe37ca..743ffb795672 100644 --- a/packages/schematics/angular/entry-point/schema.json +++ b/packages/schematics/angular/entry-point/schema.json @@ -1,7 +1,7 @@ { "$schema": "http://json-schema.org/draft-07/schema", - "$id": "SchematicsEntryPoint", - "title": "Entry Point Options Schema", + "$id": "SchematicsAngularEntryPoint", + "title": "Angular Entry Point Options Schema", "type": "object", "description": "Creates a new secondary entry point in an Angular library project.", "additionalProperties": false, @@ -9,7 +9,7 @@ "name": { "type": "string", "description": "The name of the secondary entry point (for example, 'testing' or 'feature/sub').", - "pattern": "^(?:\\.|[a-zA-Z0-9-~][a-zA-Z0-9-._~]*(?:/[a-zA-Z0-9-~][a-zA-Z0-9-._~]*)*)$", + "pattern": "^(?!.*\\.\\.)[a-zA-Z0-9-~][a-zA-Z0-9-._~]*(?:/[a-zA-Z0-9-~][a-zA-Z0-9-._~]*)*$", "$default": { "$source": "argv", "index": 0 @@ -18,7 +18,7 @@ }, "project": { "type": "string", - "description": "The name of the library project.", + "description": "The name of the library project where the secondary entry point should be added. If not specified, the CLI will determine the project from the current directory.", "$default": { "$source": "projectName" } @@ -44,12 +44,12 @@ "skipTests": { "type": "boolean", "default": false, - "description": "Do not create \"spec.ts\" test files for the new secondary entry point." + "description": "Do not create `spec.ts` test files for the new secondary entry point." }, "skipTsConfig": { "type": "boolean", "default": false, - "description": "Do not update \"tsconfig.json\" to add a path mapping for the new secondary entry point." + "description": "Do not update `tsconfig.json` to add a path mapping for the new secondary entry point." } }, "required": ["name", "project"] diff --git a/packages/schematics/angular/library/index.ts b/packages/schematics/angular/library/index.ts index f7fdc402b7cc..d5b5332a1013 100644 --- a/packages/schematics/angular/library/index.ts +++ b/packages/schematics/angular/library/index.ts @@ -15,11 +15,11 @@ import { mergeWith, move, noop, - schematic, strings, url, } from '@angular-devkit/schematics'; import { join } from 'node:path/posix'; +import addEntryPoint from '../entry-point'; import { addTestRunnerDependencies } from '../utility/dependencies'; import { DependencyType, @@ -173,7 +173,7 @@ export default function (options: LibraryOptions): Rule { './' + join(libDir, 'tsconfig.lib.json'), './' + join(libDir, 'tsconfig.spec.json'), ), - schematic('entry-point', { + addEntryPoint({ name: '.', project: packageName, entryFile: options.entryFile, diff --git a/tests/e2e/tests/build/library-with-demo-app.ts b/tests/e2e/tests/build/library-with-demo-app.ts index 4cabfb25d2f8..0158e41d42d5 100644 --- a/tests/e2e/tests/build/library-with-demo-app.ts +++ b/tests/e2e/tests/build/library-with-demo-app.ts @@ -25,16 +25,20 @@ export default async function () { secondary, another }); - `, + `, ); - // Scenario #1 where we use non-wildcard path mappings as generated by the schematics. + // Scenario #1 where we use wildcard path mappings as generated by the schematics. await ng('build', 'mylib'); await ng('build'); - // Scenario #2 where we use wildcard path mappings for secondary entry-points. + // Scenario #2 where we don't use wildcard path mappings. await updateJsonFile('tsconfig.json', (json) => { - json.compilerOptions.paths = { 'mylib': ['./dist/mylib'], 'mylib/*': ['./dist/mylib/*'] }; + json.compilerOptions.paths = { + 'mylib': ['./dist/mylib'], + 'mylib/secondary': ['./dist/mylib/secondary'], + 'mylib/another': ['./dist/mylib/another'], + }; }); await ng('build'); From 6737a20baa21f2b35af362365f79bd1ab94da953 Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Thu, 5 Nov 2026 10:55:11 +0000 Subject: [PATCH 3/6] fixup! feat(@schematics/angular): use library builder and add entry-point schematic --- .../files/__entryFile__.ts.template | 4 +- .../schematics/angular/entry-point/index.ts | 20 ++-- .../angular/entry-point/index_spec.ts | 101 +++++++----------- .../library/files/tsconfig.lib.json.template | 2 +- .../library/files/tsconfig.spec.json.template | 4 +- packages/schematics/angular/library/index.ts | 2 + .../schematics/angular/library/index_spec.ts | 6 +- 7 files changed, 65 insertions(+), 74 deletions(-) diff --git a/packages/schematics/angular/entry-point/files/__entryFile__.ts.template b/packages/schematics/angular/entry-point/files/__entryFile__.ts.template index 2d13ba01016f..62eb3bb8cadb 100644 --- a/packages/schematics/angular/entry-point/files/__entryFile__.ts.template +++ b/packages/schematics/angular/entry-point/files/__entryFile__.ts.template @@ -2,5 +2,5 @@ * Public API Surface of <%= dasherize(name) %> */ -export * from './lib/<%= dasherize(name) %>';<% if (!standalone) { %> -export * from './lib/<%= dasherize(name) %>-module';<% } %> +export * from './<%= isPrimary ? 'lib/' : '' %><%= dasherize(name) %>';<% if (!standalone) { %> +export * from './<%= isPrimary ? 'lib/' : '' %><%= dasherize(name) %>-module';<% } %> diff --git a/packages/schematics/angular/entry-point/index.ts b/packages/schematics/angular/entry-point/index.ts index 2ff15ea4a1e5..75468144a8b0 100644 --- a/packages/schematics/angular/entry-point/index.ts +++ b/packages/schematics/angular/entry-point/index.ts @@ -56,14 +56,20 @@ export default createProjectSchematic((options, { project, tr throw new SchematicsException('Entry point name "package.json" is reserved.'); } - if (options.name.split('/').includes('lib')) { + const libDir = project.root; + const sourceRoot = project.sourceRoot ?? join(libDir, 'src'); + const relativeSourceRoot = relative(libDir, sourceRoot); + if ( + relativeSourceRoot && + !relativeSourceRoot.startsWith('..') && + (options.name === relativeSourceRoot || options.name.startsWith(`${relativeSourceRoot}/`)) + ) { throw new SchematicsException( - `Entry point name "${options.name}" cannot be "lib" or contain a "lib" segment ` + - 'as it conflicts with the entry point source directory.', + `Entry point name "${options.name}" cannot be "${relativeSourceRoot}" or start with "${relativeSourceRoot}/" ` + + 'as it conflicts with the primary entry point source directory.', ); } - const libDir = project.root; const packageJsonPath = join(libDir, 'package.json'); if (!tree.exists(packageJsonPath)) { throw new SchematicsException(`Could not find package.json at "${packageJsonPath}".`); @@ -78,8 +84,7 @@ export default createProjectSchematic((options, { project, tr const isPrimary = options.name === '.'; const entryFile = (options.entryFile ?? 'public-api').replace(/\.ts$/i, ''); - const sourceRoot = project.sourceRoot ?? join(libDir, 'src'); - const entryPointDir = join(sourceRoot, options.name); + const entryPointDir = isPrimary ? sourceRoot : join(libDir, options.name, 'src'); const exportKey = isPrimary ? '.' : `./${options.name}`; const exportTarget = `./${relative(libDir, join(entryPointDir, `${entryFile}.ts`))}`; @@ -119,7 +124,7 @@ export default createProjectSchematic((options, { project, tr } const prefix = (options.prefix ?? project.prefix) || 'lib'; - const sourceDir = join(entryPointDir, 'lib'); + const sourceDir = isPrimary ? join(entryPointDir, 'lib') : entryPointDir; const artifactName = isPrimary ? /^@.*\/.*/.test(options.project) ? options.project.split('/')[1] @@ -141,6 +146,7 @@ export default createProjectSchematic((options, { project, tr applyTemplates({ ...strings, ...options, + isPrimary, standalone, name: artifactName, entryFile, diff --git a/packages/schematics/angular/entry-point/index_spec.ts b/packages/schematics/angular/entry-point/index_spec.ts index ce682b9cfdd3..999721613d40 100644 --- a/packages/schematics/angular/entry-point/index_spec.ts +++ b/packages/schematics/angular/entry-point/index_spec.ts @@ -56,14 +56,14 @@ describe('EntryPoint Schematic', () => { expect(tree.files).toEqual( jasmine.arrayContaining([ - '/projects/foo/src/secondary/public-api.ts', - '/projects/foo/src/secondary/lib/secondary.ts', - '/projects/foo/src/secondary/lib/secondary.spec.ts', + '/projects/foo/secondary/src/public-api.ts', + '/projects/foo/secondary/src/secondary.ts', + '/projects/foo/secondary/src/secondary.spec.ts', ]), ); - const publicApiContent = tree.readContent('/projects/foo/src/secondary/public-api.ts'); - expect(publicApiContent).toContain("export * from './lib/secondary';"); + const publicApiContent = tree.readContent('/projects/foo/secondary/src/public-api.ts'); + expect(publicApiContent).toContain("export * from './secondary';"); expect(publicApiContent).not.toContain('secondary-module'); }); @@ -79,14 +79,14 @@ describe('EntryPoint Schematic', () => { expect(tree.files).toEqual( jasmine.arrayContaining([ - '/projects/foo/src/feature/sub/public-api.ts', - '/projects/foo/src/feature/sub/lib/sub.ts', - '/projects/foo/src/feature/sub/lib/sub.spec.ts', + '/projects/foo/feature/sub/src/public-api.ts', + '/projects/foo/feature/sub/src/sub.ts', + '/projects/foo/feature/sub/src/sub.spec.ts', ]), ); const pkgJson = getJsonFileContent(tree, '/projects/foo/package.json'); - expect(pkgJson.exports['./feature/sub']).toBe('./src/feature/sub/public-api.ts'); + expect(pkgJson.exports['./feature/sub']).toBe('./feature/sub/src/public-api.ts'); const tsConfigJson = getJsonFileContent(tree, '/tsconfig.json'); expect(tsConfigJson.compilerOptions.paths['foo/*']).toEqual(['./dist/foo/*']); @@ -102,9 +102,9 @@ describe('EntryPoint Schematic', () => { workspaceTree, ); - expect(tree.files).toContain('/projects/foo/src/secondary/my-entry.ts'); + expect(tree.files).toContain('/projects/foo/secondary/src/my-entry.ts'); const pkgJson = getJsonFileContent(tree, '/projects/foo/package.json'); - expect(pkgJson.exports['./secondary']).toBe('./src/secondary/my-entry.ts'); + expect(pkgJson.exports['./secondary']).toBe('./secondary/src/my-entry.ts'); }); it('should strip .ts suffix from entryFile option', async () => { @@ -117,10 +117,10 @@ describe('EntryPoint Schematic', () => { workspaceTree, ); - expect(tree.files).toContain('/projects/foo/src/secondary/my-entry.ts'); - expect(tree.files).not.toContain('/projects/foo/src/secondary/my-entry.ts.ts'); + expect(tree.files).toContain('/projects/foo/secondary/src/my-entry.ts'); + expect(tree.files).not.toContain('/projects/foo/secondary/src/my-entry.ts.ts'); const pkgJson = getJsonFileContent(tree, '/projects/foo/package.json'); - expect(pkgJson.exports['./secondary']).toBe('./src/secondary/my-entry.ts'); + expect(pkgJson.exports['./secondary']).toBe('./secondary/src/my-entry.ts'); }); it('should update library package.json exports', async () => { @@ -129,7 +129,7 @@ describe('EntryPoint Schematic', () => { const pkgJson = getJsonFileContent(tree, '/projects/foo/package.json'); expect(pkgJson.exports).toEqual({ '.': './src/public-api.ts', - './secondary': './src/secondary/public-api.ts', + './secondary': './secondary/src/public-api.ts', }); expect(Object.keys(pkgJson)[0]).toBe('name'); }); @@ -144,7 +144,7 @@ describe('EntryPoint Schematic', () => { const updatedPkgJson = getJsonFileContent(tree, '/projects/foo/package.json'); expect(updatedPkgJson.exports).toEqual({ '.': './src/public-api.ts', - './secondary': './src/secondary/public-api.ts', + './secondary': './secondary/src/public-api.ts', }); }); @@ -158,7 +158,7 @@ describe('EntryPoint Schematic', () => { const updatedPkgJson = getJsonFileContent(tree, '/projects/foo/package.json'); expect(updatedPkgJson.exports).toEqual({ '.': ['./src/public-api.ts'], - './secondary': './src/secondary/public-api.ts', + './secondary': './secondary/src/public-api.ts', }); }); @@ -172,7 +172,7 @@ describe('EntryPoint Schematic', () => { const updatedPkgJson = getJsonFileContent(tree, '/projects/foo/package.json'); expect(updatedPkgJson.exports).toEqual({ '.': { default: './src/public-api.ts' }, - './secondary': './src/secondary/public-api.ts', + './secondary': './secondary/src/public-api.ts', }); }); @@ -206,25 +206,6 @@ describe('EntryPoint Schematic', () => { expect(tsConfigJson.compilerOptions.paths['foo/*']).toEqual(['./custom-dist/foo/*']); }); - it('should respect custom sourceRoot in angular.json', async () => { - const angularJson = getJsonFileContent(workspaceTree, '/angular.json'); - angularJson.projects.foo.sourceRoot = 'projects/foo/custom-src'; - workspaceTree.overwrite('/angular.json', JSON.stringify(angularJson, null, 2)); - - const tree = await schematicRunner.runSchematic('entry-point', defaultOptions, workspaceTree); - - expect(tree.files).toEqual( - jasmine.arrayContaining([ - '/projects/foo/custom-src/secondary/public-api.ts', - '/projects/foo/custom-src/secondary/lib/secondary.ts', - '/projects/foo/custom-src/secondary/lib/secondary.spec.ts', - ]), - ); - - const pkgJson = getJsonFileContent(tree, '/projects/foo/package.json'); - expect(pkgJson.exports['./secondary']).toBe('./custom-src/secondary/public-api.ts'); - }); - it('should not modify root tsconfig.json when skipTsConfig is true', async () => { const tree = await schematicRunner.runSchematic( 'entry-point', @@ -259,9 +240,7 @@ describe('EntryPoint Schematic', () => { tree, ); - const componentContent = tree.readContent( - '/projects/prefixed-lib/src/secondary/lib/secondary.ts', - ); + const componentContent = tree.readContent('/projects/prefixed-lib/secondary/src/secondary.ts'); expect(componentContent).toContain("selector: 'custom-secondary'"); }); @@ -272,7 +251,7 @@ describe('EntryPoint Schematic', () => { const tree = await schematicRunner.runSchematic('entry-point', defaultOptions, workspaceTree); - const componentContent = tree.readContent('/projects/foo/src/secondary/lib/secondary.ts'); + const componentContent = tree.readContent('/projects/foo/secondary/src/secondary.ts'); expect(componentContent).toContain("selector: 'lib-secondary'"); }); @@ -286,7 +265,7 @@ describe('EntryPoint Schematic', () => { workspaceTree, ); - const componentContent = tree.readContent('/projects/foo/src/secondary/lib/secondary.ts'); + const componentContent = tree.readContent('/projects/foo/secondary/src/secondary.ts'); expect(componentContent).toContain("selector: 'override-secondary'"); }); @@ -311,16 +290,16 @@ describe('EntryPoint Schematic', () => { expect(tree.files).toEqual( jasmine.arrayContaining([ - '/projects/my-scope/my-lib/src/secondary/public-api.ts', - '/projects/my-scope/my-lib/src/secondary/lib/secondary.ts', - '/projects/my-scope/my-lib/src/secondary/lib/secondary.spec.ts', + '/projects/my-scope/my-lib/secondary/src/public-api.ts', + '/projects/my-scope/my-lib/secondary/src/secondary.ts', + '/projects/my-scope/my-lib/secondary/src/secondary.spec.ts', ]), ); const pkgJson = getJsonFileContent(tree, '/projects/my-scope/my-lib/package.json'); expect(pkgJson.exports).toEqual({ '.': './src/public-api.ts', - './secondary': './src/secondary/public-api.ts', + './secondary': './secondary/src/public-api.ts', }); const tsConfigJson = getJsonFileContent(tree, '/tsconfig.json'); @@ -339,8 +318,8 @@ describe('EntryPoint Schematic', () => { workspaceTree, ); - expect(tree.files).toContain('/projects/foo/src/secondary/lib/secondary.ts'); - expect(tree.files).not.toContain('/projects/foo/src/secondary/lib/secondary.spec.ts'); + expect(tree.files).toContain('/projects/foo/secondary/src/secondary.ts'); + expect(tree.files).not.toContain('/projects/foo/secondary/src/secondary.spec.ts'); }); it('should not be affected by workspace component schematic type and exportDefault defaults', async () => { @@ -355,8 +334,8 @@ describe('EntryPoint Schematic', () => { const tree = await schematicRunner.runSchematic('entry-point', defaultOptions, workspaceTree); - expect(tree.files).toContain('/projects/foo/src/secondary/lib/secondary.ts'); - const componentContent = tree.readContent('/projects/foo/src/secondary/lib/secondary.ts'); + expect(tree.files).toContain('/projects/foo/secondary/src/secondary.ts'); + const componentContent = tree.readContent('/projects/foo/secondary/src/secondary.ts'); expect(componentContent).toContain('export class Secondary'); expect(componentContent).not.toContain('export default'); }); @@ -374,16 +353,16 @@ describe('EntryPoint Schematic', () => { expect(tree.files).toEqual( jasmine.arrayContaining([ - '/projects/foo/src/secondary/public-api.ts', - '/projects/foo/src/secondary/lib/secondary-module.ts', - '/projects/foo/src/secondary/lib/secondary.ts', - '/projects/foo/src/secondary/lib/secondary.spec.ts', + '/projects/foo/secondary/src/public-api.ts', + '/projects/foo/secondary/src/secondary-module.ts', + '/projects/foo/secondary/src/secondary.ts', + '/projects/foo/secondary/src/secondary.spec.ts', ]), ); - const publicApiContent = tree.readContent('/projects/foo/src/secondary/public-api.ts'); - expect(publicApiContent).toContain("export * from './lib/secondary';"); - expect(publicApiContent).toContain("export * from './lib/secondary-module';"); + const publicApiContent = tree.readContent('/projects/foo/secondary/src/public-api.ts'); + expect(publicApiContent).toContain("export * from './secondary';"); + expect(publicApiContent).toContain("export * from './secondary-module';"); }); }); @@ -451,8 +430,8 @@ describe('EntryPoint Schematic', () => { ).toBeRejectedWithError('Entry point name "package.json" is reserved.'); }); - it('should throw if the entry-point name is "lib" or contains a "lib" segment', async () => { - for (const invalidName of ['lib', 'lib/sub', 'feature/lib', 'feature/lib/sub']) { + it('should throw if the entry-point name conflicts with the primary source directory', async () => { + for (const invalidName of ['src', 'src/sub']) { await expectAsync( schematicRunner.runSchematic( 'entry-point', @@ -463,8 +442,8 @@ describe('EntryPoint Schematic', () => { workspaceTree, ), ).toBeRejectedWithError( - `Entry point name "${invalidName}" cannot be "lib" or contain a "lib" segment ` + - 'as it conflicts with the entry point source directory.', + `Entry point name "${invalidName}" cannot be "src" or start with "src/" ` + + 'as it conflicts with the primary entry point source directory.', ); } }); diff --git a/packages/schematics/angular/library/files/tsconfig.lib.json.template b/packages/schematics/angular/library/files/tsconfig.lib.json.template index 23e52647e3cf..6f429ec7d056 100644 --- a/packages/schematics/angular/library/files/tsconfig.lib.json.template +++ b/packages/schematics/angular/library/files/tsconfig.lib.json.template @@ -7,7 +7,7 @@ "types": [] }, "include": [ - "src/**/*.ts" + "**/*.ts" ], "exclude": [ "**/*.spec.ts" diff --git a/packages/schematics/angular/library/files/tsconfig.spec.json.template b/packages/schematics/angular/library/files/tsconfig.spec.json.template index b2370befce4f..4f8ee2c037bb 100644 --- a/packages/schematics/angular/library/files/tsconfig.spec.json.template +++ b/packages/schematics/angular/library/files/tsconfig.spec.json.template @@ -9,7 +9,7 @@ ] }, "include": [ - "src/**/*.d.ts", - "src/**/*<% if (standalone) { %>.spec<% } %>.ts" + "**/*.d.ts", + "**/*<% if (standalone) { %>.spec<% } %>.ts" ] } diff --git a/packages/schematics/angular/library/index.ts b/packages/schematics/angular/library/index.ts index d5b5332a1013..279a59a48acf 100644 --- a/packages/schematics/angular/library/index.ts +++ b/packages/schematics/angular/library/index.ts @@ -111,6 +111,8 @@ function addLibToWorkspaceFile( builder: Builders.BuildUnitTest, options: { tsConfig: `${projectRoot}/tsconfig.spec.json`, + // Include spec files from secondary entry points located outside of the source root. + include: ['../**/*.spec.ts'], }, } : { diff --git a/packages/schematics/angular/library/index_spec.ts b/packages/schematics/angular/library/index_spec.ts index e116919bdb25..4426e7f258ac 100644 --- a/packages/schematics/angular/library/index_spec.ts +++ b/packages/schematics/angular/library/index_spec.ts @@ -150,7 +150,7 @@ describe('Library Schematic', () => { expect(fileContent).toBeDefined(); expect(fileContent.compilerOptions.declaration).toBe(true); expect(fileContent.compilerOptions.declarationMap).toBeUndefined(); - expect(fileContent.include).toEqual(['src/**/*.ts']); + expect(fileContent.include).toEqual(['**/*.ts']); expect(fileContent.exclude).toEqual(['**/*.spec.ts']); }); @@ -441,6 +441,10 @@ describe('Library Schematic', () => { const workspace = JSON.parse(tree.readContent('/angular.json')); expect(workspace.projects.foo.architect.test.builder).toBe('@angular/build:unit-test'); + expect(workspace.projects.foo.architect.test.options.include).toEqual(['../**/*.spec.ts']); + + const specTsConfig = getJsonFileContent(tree, '/projects/foo/tsconfig.spec.json'); + expect(specTsConfig.include).toEqual(['**/*.d.ts', '**/*.spec.ts']); }); it(`should add 'unit-test' test builder when vitest is available`, async () => { From b4486a5095b16dd2d476b6f4e5dd86f9b43e70d3 Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Thu, 5 Nov 2026 11:41:08 +0000 Subject: [PATCH 4/6] fixup! feat(@schematics/angular): use library builder and add entry-point schematic --- packages/schematics/angular/library/index.ts | 4 +--- tests/e2e/tests/vitest/library.ts | 16 ++-------------- 2 files changed, 3 insertions(+), 17 deletions(-) diff --git a/packages/schematics/angular/library/index.ts b/packages/schematics/angular/library/index.ts index 279a59a48acf..5a832948f1bf 100644 --- a/packages/schematics/angular/library/index.ts +++ b/packages/schematics/angular/library/index.ts @@ -86,7 +86,7 @@ function addLibToWorkspaceFile( workspace.projects.add({ name: projectName, root: projectRoot, - sourceRoot: `${projectRoot}/src`, + sourceRoot: projectRoot, projectType: ProjectType.Library, prefix: options.prefix, targets: { @@ -111,8 +111,6 @@ function addLibToWorkspaceFile( builder: Builders.BuildUnitTest, options: { tsConfig: `${projectRoot}/tsconfig.spec.json`, - // Include spec files from secondary entry points located outside of the source root. - include: ['../**/*.spec.ts'], }, } : { diff --git a/tests/e2e/tests/vitest/library.ts b/tests/e2e/tests/vitest/library.ts index 23a691ad7cca..af6c36645634 100644 --- a/tests/e2e/tests/vitest/library.ts +++ b/tests/e2e/tests/vitest/library.ts @@ -28,21 +28,9 @@ export default async function (): Promise { }; }); - // 3. Update the component to use SCSS and import the shared file - // Rename CSS to SCSS - await ng('generate', 'component', 'styled-comp', '--project=my-lib', '--style=scss'); - - await appendToFile( - 'projects/my-lib/src/lib/styled-comp/styled-comp.scss', - ` - @use 'vars'; - p { color: vars.$primary-color; } - `, - ); - // Run the library tests const { stdout } = await ng('test', 'my-lib'); - // Expect tests to pass - assert.match(stdout, /passed/, 'Expected library tests to pass.'); + // Expect tests to pass (my-lib, styled-comp, and secondary) + assert.match(stdout, /3 passed/, 'Expected 3 library tests to pass.'); } From 9636cf246980bc0360f790bd6e68ba8e12f6cc63 Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:13:10 +0000 Subject: [PATCH 5/6] fixup! feat(@schematics/angular): use library builder and add entry-point schematic --- packages/schematics/angular/entry-point/index.ts | 14 ++++---------- packages/schematics/angular/library/index_spec.ts | 1 - packages/schematics/angular/utility/workspace.ts | 5 ++++- tests/e2e/tests/vitest/library.ts | 15 +++++++++++++++ 4 files changed, 23 insertions(+), 12 deletions(-) diff --git a/packages/schematics/angular/entry-point/index.ts b/packages/schematics/angular/entry-point/index.ts index 75468144a8b0..7a8e773bb014 100644 --- a/packages/schematics/angular/entry-point/index.ts +++ b/packages/schematics/angular/entry-point/index.ts @@ -56,20 +56,14 @@ export default createProjectSchematic((options, { project, tr throw new SchematicsException('Entry point name "package.json" is reserved.'); } - const libDir = project.root; - const sourceRoot = project.sourceRoot ?? join(libDir, 'src'); - const relativeSourceRoot = relative(libDir, sourceRoot); - if ( - relativeSourceRoot && - !relativeSourceRoot.startsWith('..') && - (options.name === relativeSourceRoot || options.name.startsWith(`${relativeSourceRoot}/`)) - ) { + if (options.name === 'src' || options.name.startsWith('src/')) { throw new SchematicsException( - `Entry point name "${options.name}" cannot be "${relativeSourceRoot}" or start with "${relativeSourceRoot}/" ` + + `Entry point name "${options.name}" cannot be "src" or start with "src/" ` + 'as it conflicts with the primary entry point source directory.', ); } + const libDir = project.root; const packageJsonPath = join(libDir, 'package.json'); if (!tree.exists(packageJsonPath)) { throw new SchematicsException(`Could not find package.json at "${packageJsonPath}".`); @@ -84,7 +78,7 @@ export default createProjectSchematic((options, { project, tr const isPrimary = options.name === '.'; const entryFile = (options.entryFile ?? 'public-api').replace(/\.ts$/i, ''); - const entryPointDir = isPrimary ? sourceRoot : join(libDir, options.name, 'src'); + const entryPointDir = join(libDir, options.name, 'src'); const exportKey = isPrimary ? '.' : `./${options.name}`; const exportTarget = `./${relative(libDir, join(entryPointDir, `${entryFile}.ts`))}`; diff --git a/packages/schematics/angular/library/index_spec.ts b/packages/schematics/angular/library/index_spec.ts index 4426e7f258ac..a1836bedd78b 100644 --- a/packages/schematics/angular/library/index_spec.ts +++ b/packages/schematics/angular/library/index_spec.ts @@ -441,7 +441,6 @@ describe('Library Schematic', () => { const workspace = JSON.parse(tree.readContent('/angular.json')); expect(workspace.projects.foo.architect.test.builder).toBe('@angular/build:unit-test'); - expect(workspace.projects.foo.architect.test.options.include).toEqual(['../**/*.spec.ts']); const specTsConfig = getJsonFileContent(tree, '/projects/foo/tsconfig.spec.json'); expect(specTsConfig.include).toEqual(['**/*.d.ts', '**/*.spec.ts']); diff --git a/packages/schematics/angular/utility/workspace.ts b/packages/schematics/angular/utility/workspace.ts index f8567fc534a3..20a143877262 100644 --- a/packages/schematics/angular/utility/workspace.ts +++ b/packages/schematics/angular/utility/workspace.ts @@ -116,7 +116,10 @@ export async function writeWorkspace( * @param project The project which will have its default path generated. */ export function buildDefaultPath(project: workspaces.ProjectDefinition): string { - const root = project.sourceRoot ? `/${project.sourceRoot}/` : `/${project.root}/src/`; + const root = + project.sourceRoot && project.sourceRoot !== project.root + ? `/${project.sourceRoot}/` + : `/${project.root}/src/`; const projectDirName = project.extensions['projectType'] === ProjectType.Application ? 'app' : 'lib'; diff --git a/tests/e2e/tests/vitest/library.ts b/tests/e2e/tests/vitest/library.ts index af6c36645634..5d515681272b 100644 --- a/tests/e2e/tests/vitest/library.ts +++ b/tests/e2e/tests/vitest/library.ts @@ -28,6 +28,21 @@ export default async function (): Promise { }; }); + // 3. Update the component to use SCSS and import the shared file + // Rename CSS to SCSS + await ng('generate', 'component', 'styled-comp', '--project=my-lib', '--style=scss'); + + await appendToFile( + 'projects/my-lib/src/lib/styled-comp/styled-comp.scss', + ` + @use 'vars'; + p { color: vars.$primary-color; } + `, + ); + + // 4. Generate a secondary entry point and verify its tests are also executed + await ng('generate', 'entry-point', 'secondary', '--project=my-lib'); + // Run the library tests const { stdout } = await ng('test', 'my-lib'); From 98c90c57280983b3cd0e5e99783dcaa2395d674a Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Fri, 6 Nov 2026 08:00:19 +0000 Subject: [PATCH 6/6] fixup! feat(@schematics/angular): use library builder and add entry-point schematic --- .../schematics/angular/entry-point/index.ts | 7 ------- .../angular/entry-point/index_spec.ts | 18 ------------------ .../schematics/angular/utility/workspace.ts | 14 ++++++++++---- 3 files changed, 10 insertions(+), 29 deletions(-) diff --git a/packages/schematics/angular/entry-point/index.ts b/packages/schematics/angular/entry-point/index.ts index 7a8e773bb014..c889f3465eb4 100644 --- a/packages/schematics/angular/entry-point/index.ts +++ b/packages/schematics/angular/entry-point/index.ts @@ -56,13 +56,6 @@ export default createProjectSchematic((options, { project, tr throw new SchematicsException('Entry point name "package.json" is reserved.'); } - if (options.name === 'src' || options.name.startsWith('src/')) { - throw new SchematicsException( - `Entry point name "${options.name}" cannot be "src" or start with "src/" ` + - 'as it conflicts with the primary entry point source directory.', - ); - } - const libDir = project.root; const packageJsonPath = join(libDir, 'package.json'); if (!tree.exists(packageJsonPath)) { diff --git a/packages/schematics/angular/entry-point/index_spec.ts b/packages/schematics/angular/entry-point/index_spec.ts index 999721613d40..ff047c5ef3d7 100644 --- a/packages/schematics/angular/entry-point/index_spec.ts +++ b/packages/schematics/angular/entry-point/index_spec.ts @@ -430,24 +430,6 @@ describe('EntryPoint Schematic', () => { ).toBeRejectedWithError('Entry point name "package.json" is reserved.'); }); - it('should throw if the entry-point name conflicts with the primary source directory', async () => { - for (const invalidName of ['src', 'src/sub']) { - await expectAsync( - schematicRunner.runSchematic( - 'entry-point', - { - name: invalidName, - project: 'foo', - }, - workspaceTree, - ), - ).toBeRejectedWithError( - `Entry point name "${invalidName}" cannot be "src" or start with "src/" ` + - 'as it conflicts with the primary entry point source directory.', - ); - } - }); - it('should reject invalid entry-point names via schema validation', async () => { for (const invalidName of ['.', '../foo', 'foo..bar', '/secondary', 'secondary/', './sub']) { await expectAsync( diff --git a/packages/schematics/angular/utility/workspace.ts b/packages/schematics/angular/utility/workspace.ts index 20a143877262..9d8b9b475dcd 100644 --- a/packages/schematics/angular/utility/workspace.ts +++ b/packages/schematics/angular/utility/workspace.ts @@ -116,10 +116,16 @@ export async function writeWorkspace( * @param project The project which will have its default path generated. */ export function buildDefaultPath(project: workspaces.ProjectDefinition): string { - const root = - project.sourceRoot && project.sourceRoot !== project.root - ? `/${project.sourceRoot}/` - : `/${project.root}/src/`; + let root: string; + if (project.extensions['projectType'] === ProjectType.Library) { + root = + project.sourceRoot && project.sourceRoot !== project.root + ? `/${project.sourceRoot}/` + : `/${project.root}/src/`; + } else { + root = `/${project.sourceRoot || project.root}/`; // fallback for non-library projects + } + const projectDirName = project.extensions['projectType'] === ProjectType.Application ? 'app' : 'lib';