From b07ccfbb1b2045d285c23dd4b654e1380892fcb2 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Tue, 26 Apr 2022 10:44:53 -0400 Subject: [PATCH] feat(@schematics/angular): introduce a utility subpath export for Angular rules and utilities The `@schematics/angular` package now contains a defined set of package `exports` including a `utility` subpath export. A wildcard export is also temporarily defined to support transition away from existing deep-import usage. The `@schematics/angular/utility` subpath export will contain supported utility methods used by the first-party schematics contained within the `@schematics/angular` package and can be considered public API that will follow SemVer stability constraints. The first group of utilities introduced in this change are used to modify the `angular.json` workspace file within the schematics and include the `updateWorkspace` rule and `readWorkspace`/`writeWorkspace` helpers. --- packages/schematics/angular/e2e/index.ts | 7 +- packages/schematics/angular/package.json | 7 + .../angular/service-worker/index.ts | 7 +- packages/schematics/angular/utility/index.ts | 21 +++ .../angular/utility/workspace-models.ts | 6 + .../schematics/angular/utility/workspace.ts | 121 ++++++++++++----- .../angular/utility/workspace_spec.ts | 126 ++++++++++++++++++ 7 files changed, 253 insertions(+), 42 deletions(-) create mode 100644 packages/schematics/angular/utility/index.ts create mode 100644 packages/schematics/angular/utility/workspace_spec.ts diff --git a/packages/schematics/angular/e2e/index.ts b/packages/schematics/angular/e2e/index.ts index de24c20c25..7bd48bf2c5 100644 --- a/packages/schematics/angular/e2e/index.ts +++ b/packages/schematics/angular/e2e/index.ts @@ -19,11 +19,11 @@ import { strings, url, } from '@angular-devkit/schematics'; +import { readWorkspace, writeWorkspace } from '../utility'; import { NodeDependencyType, addPackageJsonDependency } from '../utility/dependencies'; import { JSONFile } from '../utility/json-file'; import { latestVersions } from '../utility/latest-versions'; import { relativePathToWorkspaceRoot } from '../utility/paths'; -import { getWorkspace, updateWorkspace } from '../utility/workspace'; import { Builders } from '../utility/workspace-models'; import { Schema as E2eOptions } from './schema'; @@ -41,7 +41,7 @@ function addScriptsToPackageJson(): Rule { export default function (options: E2eOptions): Rule { return async (host: Tree) => { const appProject = options.relatedAppName; - const workspace = await getWorkspace(host); + const workspace = await readWorkspace(host); const project = workspace.projects.get(appProject); if (!project) { throw new SchematicsException(`Project name "${appProject}" doesn't not exist.`); @@ -66,8 +66,9 @@ export default function (options: E2eOptions): Rule { }, }); + await writeWorkspace(host, workspace); + return chain([ - updateWorkspace(workspace), mergeWith( apply(url('./files'), [ applyTemplates({ diff --git a/packages/schematics/angular/package.json b/packages/schematics/angular/package.json index 7845c5c03a..ba8e7cb756 100644 --- a/packages/schematics/angular/package.json +++ b/packages/schematics/angular/package.json @@ -8,6 +8,13 @@ "code generation", "schematics" ], + "exports": { + "./package.json": "./package.json", + "./utility": "./utility/index.js", + "./utility/*": "./utility/*.js", + "./migrations/migration-collection.json": "./migrations/migration-collection.json", + "./*": "./*.js" + }, "schematics": "./collection.json", "dependencies": { "@angular-devkit/core": "0.0.0-PLACEHOLDER", diff --git a/packages/schematics/angular/service-worker/index.ts b/packages/schematics/angular/service-worker/index.ts index f35e47019d..765bad7c4f 100644 --- a/packages/schematics/angular/service-worker/index.ts +++ b/packages/schematics/angular/service-worker/index.ts @@ -21,6 +21,7 @@ import { } from '@angular-devkit/schematics'; import { NodePackageInstallTask } from '@angular-devkit/schematics/tasks'; import * as ts from '../third_party/github.com/Microsoft/TypeScript/lib/typescript'; +import { readWorkspace, writeWorkspace } from '../utility'; import { addSymbolToNgModuleMetadata, getEnvironmentExportName, @@ -32,7 +33,6 @@ import { addPackageJsonDependency, getPackageJsonDependency } from '../utility/d import { getAppModulePath } from '../utility/ng-ast-utils'; import { relativePathToWorkspaceRoot } from '../utility/paths'; import { targetBuildNotFoundError } from '../utility/project-targets'; -import { getWorkspace, updateWorkspace } from '../utility/workspace'; import { BrowserBuilderOptions } from '../utility/workspace-models'; import { Schema as ServiceWorkerOptions } from './schema'; @@ -130,7 +130,7 @@ function getTsSourceFile(host: Tree, path: string): ts.SourceFile { export default function (options: ServiceWorkerOptions): Rule { return async (host: Tree, context: SchematicContext) => { - const workspace = await getWorkspace(host); + const workspace = await readWorkspace(host); const project = workspace.projects.get(options.project); if (!project) { throw new SchematicsException(`Invalid project name (${options.project})`); @@ -163,9 +163,10 @@ export default function (options: ServiceWorkerOptions): Rule { context.addTask(new NodePackageInstallTask()); + await writeWorkspace(host, workspace); + return chain([ mergeWith(templateSource), - updateWorkspace(workspace), addDependencies(), updateAppModule(buildOptions.main), ]); diff --git a/packages/schematics/angular/utility/index.ts b/packages/schematics/angular/utility/index.ts new file mode 100644 index 0000000000..c8fa9a1de6 --- /dev/null +++ b/packages/schematics/angular/utility/index.ts @@ -0,0 +1,21 @@ +/** + * @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.io/license + */ + +// Workspace related rules and types +export { + ProjectDefinition, + TargetDefinition, + WorkspaceDefinition, + getWorkspace as readWorkspace, + updateWorkspace, + writeWorkspace, +} from './workspace'; +export { Builders as AngularBuilder } from './workspace-models'; + +// Package dependency related rules and types +export { DependencyType, addDependency } from './dependency'; diff --git a/packages/schematics/angular/utility/workspace-models.ts b/packages/schematics/angular/utility/workspace-models.ts index 3fbaec1f58..fb7f18c3f7 100644 --- a/packages/schematics/angular/utility/workspace-models.ts +++ b/packages/schematics/angular/utility/workspace-models.ts @@ -11,6 +11,12 @@ export enum ProjectType { Library = 'library', } +/** + * An enum of the official Angular builders. + * Each enum value provides the fully qualified name of the associated builder. + * This enum can be used when analyzing the `builder` fields of project configurations from the + * `angular.json` workspace file. + */ export enum Builders { AppShell = '@angular-devkit/build-angular:app-shell', Server = '@angular-devkit/build-angular:server', diff --git a/packages/schematics/angular/utility/workspace.ts b/packages/schematics/angular/utility/workspace.ts index 9354f4beab..410c079e2f 100644 --- a/packages/schematics/angular/utility/workspace.ts +++ b/packages/schematics/angular/utility/workspace.ts @@ -10,60 +10,109 @@ import { json, workspaces } from '@angular-devkit/core'; import { Rule, Tree, noop } from '@angular-devkit/schematics'; import { ProjectType } from './workspace-models'; -function createHost(tree: Tree): workspaces.WorkspaceHost { - return { - async readFile(path: string): Promise { - return tree.readText(path); - }, - async writeFile(path: string, data: string): Promise { - return tree.overwrite(path, data); - }, - async isDirectory(path: string): Promise { - // approximate a directory check - return !tree.exists(path) && tree.getDir(path).subfiles.length > 0; - }, - async isFile(path: string): Promise { - return tree.exists(path); - }, - }; +const DEFAULT_WORKSPACE_PATH = '/angular.json'; + +// re-export the workspace definition types for convenience +export type WorkspaceDefinition = workspaces.WorkspaceDefinition; +export type ProjectDefinition = workspaces.ProjectDefinition; +export type TargetDefinition = workspaces.TargetDefinition; + +/** + * A {@link workspaces.WorkspaceHost} backed by a Schematics {@link Tree} instance. + */ +class TreeWorkspaceHost implements workspaces.WorkspaceHost { + constructor(private readonly tree: Tree) {} + + async readFile(path: string): Promise { + return this.tree.readText(path); + } + + async writeFile(path: string, data: string): Promise { + if (this.tree.exists(path)) { + this.tree.overwrite(path, data); + } else { + this.tree.create(path, data); + } + } + + async isDirectory(path: string): Promise { + // approximate a directory check + return !this.tree.exists(path) && this.tree.getDir(path).subfiles.length > 0; + } + + async isFile(path: string): Promise { + return this.tree.exists(path); + } } +/** + * Updates the workspace file (`angular.json`) found within the root of the schematic's tree. + * The workspace object model can be directly modified within the provided updater function + * with changes being written to the workspace file after the updater function returns. + * The spacing and overall layout of the file (including comments) will be maintained where + * possible when updating the file. + * + * @param updater An update function that can be used to modify the object model for the + * workspace. A {@link WorkspaceDefinition} is provided as the first argument to the function. + */ export function updateWorkspace( - updater: (workspace: workspaces.WorkspaceDefinition) => void | Rule | PromiseLike, -): Rule; -export function updateWorkspace(workspace: workspaces.WorkspaceDefinition): Rule; -export function updateWorkspace( - updaterOrWorkspace: - | workspaces.WorkspaceDefinition - | ((workspace: workspaces.WorkspaceDefinition) => void | Rule | PromiseLike), + updater: (workspace: WorkspaceDefinition) => void | Rule | PromiseLike, ): Rule { return async (tree: Tree) => { - const host = createHost(tree); + const host = new TreeWorkspaceHost(tree); - if (typeof updaterOrWorkspace === 'function') { - const { workspace } = await workspaces.readWorkspace('/', host); + const { workspace } = await workspaces.readWorkspace(DEFAULT_WORKSPACE_PATH, host); - const result = await updaterOrWorkspace(workspace); + const result = await updater(workspace); - await workspaces.writeWorkspace(workspace, host); + await workspaces.writeWorkspace(workspace, host); - return result || noop; - } else { - await workspaces.writeWorkspace(updaterOrWorkspace, host); - - return noop; - } + return result || noop; }; } -export async function getWorkspace(tree: Tree, path = '/') { - const host = createHost(tree); +// TODO: This should be renamed `readWorkspace` once deep imports are restricted (already exported from `utility` with that name) +/** + * Reads a workspace file (`angular.json`) from the provided {@link Tree} instance. + * + * @param tree A schematics {@link Tree} instance used to access the workspace file. + * @param path The path where a workspace file should be found. If a file is specified, the file + * path will be used. If a directory is specified, the file `angular.json` will be used from + * within the specified directory. Defaults to `/angular.json`. + * @returns A {@link WorkspaceDefinition} representing the workspace found at the specified path. + */ +export async function getWorkspace( + tree: Tree, + path = DEFAULT_WORKSPACE_PATH, +): Promise { + const host = new TreeWorkspaceHost(tree); const { workspace } = await workspaces.readWorkspace(path, host); return workspace; } +/** + * Writes a workspace file (`angular.json`) to the provided {@link Tree} instance. + * The spacing and overall layout of an exisitng file (including comments) will be maintained where + * possible when writing the file. + * + * @param tree A schematics {@link Tree} instance used to access the workspace file. + * @param workspace The {@link WorkspaceDefinition} to write. + * @param path The path where a workspace file should be written. If a file is specified, the file + * path will be used. If not provided, the definition's underlying file path stored during reading + * will be used. + */ +export async function writeWorkspace( + tree: Tree, + workspace: WorkspaceDefinition, + path?: string, +): Promise { + const host = new TreeWorkspaceHost(tree); + + return workspaces.writeWorkspace(workspace, host, path); +} + /** * Build a default project path for generating. * @param project The project which will have its default path generated. diff --git a/packages/schematics/angular/utility/workspace_spec.ts b/packages/schematics/angular/utility/workspace_spec.ts new file mode 100644 index 0000000000..f5b9978719 --- /dev/null +++ b/packages/schematics/angular/utility/workspace_spec.ts @@ -0,0 +1,126 @@ +/** + * @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.io/license + */ + +import { EmptyTree, Rule, SchematicContext, Tree, callRule } from '@angular-devkit/schematics'; +import { getWorkspace as readWorkspace, updateWorkspace, writeWorkspace } from './workspace'; + +const TEST_WORKSPACE_CONTENT = JSON.stringify({ + version: 1, + projects: { + 'test': {}, + }, +}); + +async function testRule(rule: Rule, tree: Tree): Promise { + await callRule(rule, tree, {} as unknown as SchematicContext).toPromise(); +} + +describe('readWorkspace', () => { + it('reads a workspace using the default path value', async () => { + const tree = new EmptyTree(); + tree.create('/angular.json', TEST_WORKSPACE_CONTENT); + + const workspace = await readWorkspace(tree); + expect(workspace.projects.has('test')).toBeTrue(); + }); + + it('reads a workspace when specifying a directory path', async () => { + const tree = new EmptyTree(); + tree.create('/xyz/angular.json', TEST_WORKSPACE_CONTENT); + + const workspace = await readWorkspace(tree, '/xyz/'); + expect(workspace.projects.has('test')).toBeTrue(); + }); + + it('reads a workspace when specifying a file path', async () => { + const tree = new EmptyTree(); + tree.create('/xyz/angular.json', TEST_WORKSPACE_CONTENT); + + const workspace = await readWorkspace(tree, '/xyz/angular.json'); + expect(workspace.projects.has('test')).toBeTrue(); + }); + + it('throws if workspace file does not exist when using the default path value', async () => { + const tree = new EmptyTree(); + + await expectAsync(readWorkspace(tree)).toBeRejectedWithError(); + }); + + it('throws if workspace file does not exist when specifying a file path', async () => { + const tree = new EmptyTree(); + tree.create('/angular.json', TEST_WORKSPACE_CONTENT); + + await expectAsync(readWorkspace(tree, 'abc.json')).toBeRejectedWithError(); + }); +}); + +describe('writeWorkspace', () => { + it('writes a workspace using the default path value', async () => { + const tree = new EmptyTree(); + tree.create('/angular.json', TEST_WORKSPACE_CONTENT); + const workspace = await readWorkspace(tree); + + workspace.extensions['x-abc'] = 1; + await writeWorkspace(tree, workspace); + expect(tree.readJson('/angular.json')).toEqual(jasmine.objectContaining({ 'x-abc': 1 })); + }); + + it('writes a workspace when specifying a path', async () => { + const tree = new EmptyTree(); + tree.create('/angular.json', TEST_WORKSPACE_CONTENT); + const workspace = await readWorkspace(tree); + + workspace.extensions['x-abc'] = 1; + await writeWorkspace(tree, workspace, '/xyz/angular.json'); + expect(tree.readJson('/xyz/angular.json')).toEqual(jasmine.objectContaining({ 'x-abc': 1 })); + }); +}); + +describe('updateWorkspace', () => { + it('updates a workspace using the default path value', async () => { + const tree = new EmptyTree(); + tree.create('/angular.json', TEST_WORKSPACE_CONTENT); + + const rule = updateWorkspace((workspace) => { + workspace.projects.add({ + name: 'abc', + root: 'src', + }); + }); + + await testRule(rule, tree); + + expect(tree.read('angular.json')?.toString()).toContain('"abc"'); + }); + + it('throws if workspace file does not exist', async () => { + const tree = new EmptyTree(); + + const rule = updateWorkspace((workspace) => { + workspace.projects.add({ + name: 'abc', + root: 'src', + }); + }); + + await expectAsync(testRule(rule, tree)).toBeRejectedWithError(); + }); + + it('allows executing a returned followup rule', async () => { + const tree = new EmptyTree(); + tree.create('/angular.json', TEST_WORKSPACE_CONTENT); + + const rule = updateWorkspace(() => { + return (tree) => tree.create('/followup.txt', '12345'); + }); + + await testRule(rule, tree); + + expect(tree.read('/followup.txt')?.toString()).toContain('12345'); + }); +});