diff --git a/packages/schematics/angular/collection.json b/packages/schematics/angular/collection.json index 5f691819544f..fc852bb454e3 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-secondary-entrypoint": { + "aliases": ["secondary"], + "factory": "./secondary-entrypoint", + "schema": "./secondary-entrypoint/schema.json", + "description": "Generate a secondary-entrypoint in a library project for Angular." + }, "web-worker": { "factory": "./web-worker", "schema": "./web-worker/schema.json", diff --git a/packages/schematics/angular/secondary-entrypoint/files/README.md.template b/packages/schematics/angular/secondary-entrypoint/files/README.md.template new file mode 100644 index 000000000000..f53f6d471f96 --- /dev/null +++ b/packages/schematics/angular/secondary-entrypoint/files/README.md.template @@ -0,0 +1,3 @@ +# <%= secondaryEntryPoint %> + +Secondary entry point of `<%= mainEntryPoint %>`. It can be used by importing from `<%= secondaryEntryPoint %>`. diff --git a/packages/schematics/angular/secondary-entrypoint/files/ng-package.json.template b/packages/schematics/angular/secondary-entrypoint/files/ng-package.json.template new file mode 100644 index 000000000000..32bf2a8ebc81 --- /dev/null +++ b/packages/schematics/angular/secondary-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/secondary-entrypoint/files/src/__entryFile__.ts.template b/packages/schematics/angular/secondary-entrypoint/files/src/__entryFile__.ts.template new file mode 100644 index 000000000000..b2b6bd17ce62 --- /dev/null +++ b/packages/schematics/angular/secondary-entrypoint/files/src/__entryFile__.ts.template @@ -0,0 +1,5 @@ +/* + * Public API Surface of <%= dasherize(name) %> + */ + +export const greeting = 'Hello Angular World!'; \ No newline at end of file diff --git a/packages/schematics/angular/secondary-entrypoint/index.ts b/packages/schematics/angular/secondary-entrypoint/index.ts new file mode 100644 index 000000000000..c6482370843c --- /dev/null +++ b/packages/schematics/angular/secondary-entrypoint/index.ts @@ -0,0 +1,96 @@ +/** + * @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 { latestVersions } from '../utility/latest-versions'; +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) ? [...value, ...paths] : 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( + `Secondary 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 package.json'); + } + + 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, + angularLatestVersion: latestVersions.Angular.replace(/~|\^/, ''), + tsLibLatestVersion: latestVersions['tslib'].replace(/~|\^/, ''), + }), + move(libDir), + ]); + + return chain([ + mergeWith(templateSource), + updateTsConfig(secondaryEntryPoint, './' + distRoot), + ]); + }; + }; +} diff --git a/packages/schematics/angular/secondary-entrypoint/index_spec.ts b/packages/schematics/angular/secondary-entrypoint/index_spec.ts new file mode 100644 index 000000000000..e4193148a714 --- /dev/null +++ b/packages/schematics/angular/secondary-entrypoint/index_spec.ts @@ -0,0 +1,164 @@ +/** + * @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 { Schema as LibraryOptions } from '../library/schema'; +import { Schema as WorkspaceOptions } from '../workspace/schema'; +import { Schema as GenerateLibrarySchema } from './schema'; +import { parse as parseJson } from 'jsonc-parser'; + +// 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', + entryFile: 'my-index', + standalone: true, + skipPackageJson: false, + skipTsConfig: false, + skipInstall: false, + }; + + let workspaceTree: UnitTestTree; + beforeEach(async () => { + workspaceTree = await schematicRunner.runSchematic('workspace', workspaceOptions); + }); + + it('should create correct files', async () => { + workspaceTree = await schematicRunner.runSchematic('library', libaryOptions, workspaceTree); + const tree = await schematicRunner.runSchematic( + 'secondary', + { ...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', + '/projects/foo/src/lib/foo-secondary/src/public-api.ts', + ]), + ); + }); + + it('should set correct main and secondary entrypoints in the README', async () => { + workspaceTree = await schematicRunner.runSchematic('library', libaryOptions, workspaceTree); + const tree = await schematicRunner.runSchematic( + 'secondary', + { ...defaultOptions }, + workspaceTree, + ); + const content = tree.readContent('/projects/foo/src/lib/foo-secondary/README.md'); + expect(content).toMatch('# foo/foo-secondary'); + }); + + it('should set a custom entryfile', async () => { + workspaceTree = await schematicRunner.runSchematic('library', libaryOptions, workspaceTree); + const tree = await schematicRunner.runSchematic( + 'secondary', + { ...defaultOptions, entryFile: 'my-index' }, + 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', + '/projects/foo/src/lib/foo-secondary/src/my-index.ts', + ]), + ); + }); + + it('should handle scope packages', async () => { + workspaceTree = await schematicRunner.runSchematic( + 'library', + { ...libaryOptions, name: '@scope/package' }, + workspaceTree, + ); + const tree = await schematicRunner.runSchematic( + 'secondary', + { ...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', + '/projects/scope/package/src/lib/testing/src/public-api.ts', + ]), + ); + }); + + it(`should add paths mapping to the tsconfig`, async () => { + workspaceTree = await schematicRunner.runSchematic( + 'library', + { ...libaryOptions, name: '@scope/package' }, + workspaceTree, + ); + const tree = await schematicRunner.runSchematic( + 'secondary', + { ...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( + 'secondary', + { ...defaultOptions, name: 'testing', project: '@scope/package' }, + workspaceTree, + ); + + const tsConfigJson = getJsonFileContent(tree, 'tsconfig.json'); + expect(tsConfigJson.compilerOptions.paths['@scope/package/testing']).toEqual([ + 'libs/*', + './dist/scope/package/testing', + ]); + }); +}); diff --git a/packages/schematics/angular/secondary-entrypoint/schema.json b/packages/schematics/angular/secondary-entrypoint/schema.json new file mode 100644 index 000000000000..218e2db38139 --- /dev/null +++ b/packages/schematics/angular/secondary-entrypoint/schema.json @@ -0,0 +1,34 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "SchematicsLibrary", + "title": "Secondary Entrypoint Schema", + "type": "object", + "description": "Creates a secondary entrypoint in a library project in a project.", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "The name of the library.", + "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 entrypoint?" + }, + "project": { + "type": "string", + "description": "The name of the project.", + "$default": { + "$source": "projectName" + } + }, + "entryFile": { + "type": "string", + "format": "path", + "description": "The path at which to create the library's secondary public API file, relative to the workspace root.", + "default": "public-api" + } + }, + "required": ["name", "project"] +}