diff --git a/packages/angular/cli/lib/config/workspace-schema.json b/packages/angular/cli/lib/config/workspace-schema.json index 5fe427d4f415..d2427532c050 100644 --- a/packages/angular/cli/lib/config/workspace-schema.json +++ b/packages/angular/cli/lib/config/workspace-schema.json @@ -162,6 +162,9 @@ "@schematics/angular:library": { "$ref": "../../../../schematics/angular/library/schema.json" }, + "@schematics/angular:library-entrypoint": { + "$ref": "../../../../schematics/angular/library-entrypoint/schema.json" + }, "@schematics/angular:pipe": { "$ref": "../../../../schematics/angular/pipe/schema.json" }, diff --git a/packages/schematics/angular/collection.json b/packages/schematics/angular/collection.json index 5f691819544f..1eb4c9f1ae9b 100755 --- a/packages/schematics/angular/collection.json +++ b/packages/schematics/angular/collection.json @@ -117,6 +117,12 @@ "schema": "./library/schema.json", "description": "Generate a library project for Angular." }, + "library-entrypoint": { + "aliases": ["lib-entry"], + "factory": "./library-entrypoint", + "schema": "./library-entrypoint/schema.json", + "description": "Generate a library-entrypoint in an Angular library project." + }, "web-worker": { "factory": "./web-worker", "schema": "./web-worker/schema.json", diff --git a/packages/schematics/angular/library-entrypoint/files/README.md.template b/packages/schematics/angular/library-entrypoint/files/README.md.template new file mode 100644 index 000000000000..1a1522b50d45 --- /dev/null +++ b/packages/schematics/angular/library-entrypoint/files/README.md.template @@ -0,0 +1,3 @@ +# <%= secondaryEntryPoint %> + +Secondary entry point of `<%= mainEntryPoint %>`. It can be used by importing from `<%= secondaryEntryPoint %>` \ No newline at end of file diff --git a/packages/schematics/angular/library-entrypoint/files/ng-package.json.template b/packages/schematics/angular/library-entrypoint/files/ng-package.json.template new file mode 100644 index 000000000000..32bf2a8ebc81 --- /dev/null +++ b/packages/schematics/angular/library-entrypoint/files/ng-package.json.template @@ -0,0 +1,6 @@ +{ + "$schema": "<%= relativePathToWorkspaceRoot %>/node_modules/ng-packagr/ng-package.schema.json", + "lib": { + "entryFile": "src/<%= entryFile %>.ts" + } +} \ No newline at end of file diff --git a/packages/schematics/angular/library-entrypoint/index.ts b/packages/schematics/angular/library-entrypoint/index.ts new file mode 100644 index 000000000000..6d73ed12dd8d --- /dev/null +++ b/packages/schematics/angular/library-entrypoint/index.ts @@ -0,0 +1,95 @@ +/** + * @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 { + Rule, + SchematicsException, + Tree, + apply, + applyTemplates, + chain, + mergeWith, + move, + strings, + url, +} from '@angular-devkit/schematics'; +import { JSONFile } from '../utility/json-file'; +import { relativePathToWorkspaceRoot } from '../utility/paths'; +import { buildDefaultPath, getWorkspace } from '../utility/workspace'; +import { ProjectType } from '../utility/workspace-models'; +import { Schema as LibraryOptions } from './schema'; + +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) ? [...paths, ...value] : paths); + }; +} + +export default function (options: LibraryOptions): Rule { + return async (host: Tree) => { + return async (host: Tree) => { + const workspace = await getWorkspace(host); + const project = workspace.projects.get(options.project); + + if (!project) { + throw new SchematicsException(`Project "${options.project}" does not exist.`); + } + + if (project?.extensions.projectType !== ProjectType.Library) { + throw new SchematicsException( + `Library entrypoint schematic requires a project type of "library".`, + ); + } + + const path = buildDefaultPath(project); + const libDir = `${path}/${options.name}`; + const pkgPath = `${project.root}/package.json`; + + const pkg = host.readJson(pkgPath) as { name: string } | null; + if (pkg === null) { + throw new SchematicsException(`Could not find ${pkgPath}`); + } + + const mainEntryPoint = pkg.name; + const secondaryEntryPoint = `${mainEntryPoint}/${options.name}`; + + let folderName = mainEntryPoint.startsWith('@') ? mainEntryPoint.slice(1) : mainEntryPoint; + if (/[A-Z]/.test(folderName)) { + folderName = strings.dasherize(folderName); + } + + const distRoot = `dist/${folderName}/${options.name}`; + + const templateSource = apply(url('./files'), [ + applyTemplates({ + ...strings, + ...options, + mainEntryPoint, + secondaryEntryPoint, + relativePathToWorkspaceRoot: relativePathToWorkspaceRoot(libDir), + packageName: options.name, + //TODO: fix this + entryFile: 'index.ts', + }), + move(libDir), + ]); + + return chain([ + mergeWith(templateSource), + updateTsConfig(secondaryEntryPoint, './' + distRoot), + ]); + }; + }; +} diff --git a/packages/schematics/angular/library-entrypoint/index_spec.ts b/packages/schematics/angular/library-entrypoint/index_spec.ts new file mode 100644 index 000000000000..c06baff53a26 --- /dev/null +++ b/packages/schematics/angular/library-entrypoint/index_spec.ts @@ -0,0 +1,142 @@ +/** + * @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, UnitTestTree } from '@angular-devkit/schematics/testing'; +import { parse as parseJson } from 'jsonc-parser'; +import { Schema as LibraryOptions } from '../library/schema'; +import { Schema as WorkspaceOptions } from '../workspace/schema'; +import { Schema as GenerateLibrarySchema } from './schema'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function getJsonFileContent(tree: UnitTestTree, path: string): any { + return parseJson(tree.readContent(path).toString()); +} + +describe('Secondary Entrypoint Schematic', () => { + const schematicRunner = new SchematicTestRunner( + '@schematics/ng_packagr', + require.resolve('../collection.json'), + ); + const defaultOptions: GenerateLibrarySchema = { + name: 'foo-secondary', + project: 'foo', + }; + + const workspaceOptions: WorkspaceOptions = { + name: 'workspace', + newProjectRoot: 'projects', + + version: '6.0.0', + }; + const libaryOptions: LibraryOptions = { + name: 'foo', + standalone: true, + skipPackageJson: false, + skipTsConfig: false, + skipInstall: false, + }; + + let workspaceTree: UnitTestTree; + beforeEach(async () => { + workspaceTree = await schematicRunner.runSchematic('workspace', workspaceOptions); + workspaceTree = await schematicRunner.runSchematic('library', libaryOptions, workspaceTree); + }); + + it('should create correct files', async () => { + const tree = await schematicRunner.runSchematic( + 'library-entrypoint', + { ...defaultOptions }, + workspaceTree, + ); + const files = tree.files; + + expect(files).toEqual( + jasmine.arrayContaining([ + '/projects/foo/src/lib/foo-secondary/README.md', + '/projects/foo/src/lib/foo-secondary/ng-package.json', + ]), + ); + }); + + it('should set correct main and secondary entrypoints in the README', async () => { + const tree = await schematicRunner.runSchematic( + 'library-entrypoint', + { ...defaultOptions }, + workspaceTree, + ); + const content = tree.readContent('/projects/foo/src/lib/foo-secondary/README.md'); + expect(content).toMatch('# foo/foo-secondary'); + }); + + it('should handle scope packages', async () => { + workspaceTree = await schematicRunner.runSchematic( + 'library', + { ...libaryOptions, name: '@scope/package' }, + workspaceTree, + ); + const tree = await schematicRunner.runSchematic( + 'library-entrypoint', + { ...defaultOptions, name: 'testing', project: '@scope/package' }, + workspaceTree, + ); + const files = tree.files; + expect(files).toEqual( + jasmine.arrayContaining([ + '/projects/scope/package/src/lib/testing/README.md', + '/projects/scope/package/src/lib/testing/ng-package.json', + ]), + ); + }); + + it(`should add paths mapping to the tsconfig`, async () => { + workspaceTree = await schematicRunner.runSchematic( + 'library', + { ...libaryOptions, name: '@scope/package' }, + workspaceTree, + ); + const tree = await schematicRunner.runSchematic( + 'library-entrypoint', + { ...defaultOptions, name: 'testing', project: '@scope/package' }, + workspaceTree, + ); + + const tsConfigJson = getJsonFileContent(tree, 'tsconfig.json'); + expect(tsConfigJson.compilerOptions.paths['@scope/package/testing']).toEqual([ + './dist/scope/package/testing', + ]); + }); + + it(`should append to existing paths mappings`, async () => { + workspaceTree = await schematicRunner.runSchematic( + 'library', + { ...libaryOptions, name: '@scope/package' }, + workspaceTree, + ); + workspaceTree.overwrite( + 'tsconfig.json', + JSON.stringify({ + compilerOptions: { + paths: { + 'unrelated': ['./something/else.ts'], + '@scope/package/testing': ['libs/*'], + }, + }, + }), + ); + const tree = await schematicRunner.runSchematic( + 'library-entrypoint', + { ...defaultOptions, name: 'testing', project: '@scope/package' }, + workspaceTree, + ); + const tsConfigJson = getJsonFileContent(tree, 'tsconfig.json'); + expect(tsConfigJson.compilerOptions.paths['@scope/package/testing']).toEqual([ + './dist/scope/package/testing', + 'libs/*', + ]); + }); +}); diff --git a/packages/schematics/angular/library-entrypoint/schema.json b/packages/schematics/angular/library-entrypoint/schema.json new file mode 100644 index 000000000000..d7e98fa61e05 --- /dev/null +++ b/packages/schematics/angular/library-entrypoint/schema.json @@ -0,0 +1,28 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "SchematicsLibrary", + "title": "Library Entrypoint Schema", + "type": "object", + "description": "Generate a library entrypoint in an Angular library project.", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "The name of the entrypoint to create.", + "pattern": "^[a-zA-Z0-9-._~]+/?[a-zA-Z0-9-._~]+$", + "$default": { + "$source": "argv", + "index": 0 + }, + "x-prompt": "What name would you like to use for the entrypoint?" + }, + "project": { + "type": "string", + "description": "The name of the project.", + "$default": { + "$source": "projectName" + } + } + }, + "required": ["name", "project"] +} diff --git a/tests/legacy-cli/e2e/tests/build/library-with-demo-app.ts b/tests/legacy-cli/e2e/tests/build/library-with-demo-app.ts index 8e8d14ceb54f..45d8e3ea4966 100644 --- a/tests/legacy-cli/e2e/tests/build/library-with-demo-app.ts +++ b/tests/legacy-cli/e2e/tests/build/library-with-demo-app.ts @@ -12,8 +12,8 @@ import { updateJsonFile } from '../../utils/project'; export default async function () { await ng('generate', 'library', 'mylib'); - await createLibraryEntryPoint('secondary'); - await createLibraryEntryPoint('another'); + await ng('generate', 'lib-entry', 'secondary'); + await ng('generate', 'lib-entry', 'another'); // Scenario #1 where we use wildcard path mappings for secondary entry-points. await updateJsonFile('tsconfig.json', (json) => { @@ -47,17 +47,3 @@ export default async function () { 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', - }, - }), - ); -}