Skip to content

Commit b07ccfb

Browse files
clydindgp1130
authored andcommitted
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.
1 parent 3fa38b0 commit b07ccfb

File tree

7 files changed

+253
-42
lines changed

7 files changed

+253
-42
lines changed

packages/schematics/angular/e2e/index.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,11 @@ import {
1919
strings,
2020
url,
2121
} from '@angular-devkit/schematics';
22+
import { readWorkspace, writeWorkspace } from '../utility';
2223
import { NodeDependencyType, addPackageJsonDependency } from '../utility/dependencies';
2324
import { JSONFile } from '../utility/json-file';
2425
import { latestVersions } from '../utility/latest-versions';
2526
import { relativePathToWorkspaceRoot } from '../utility/paths';
26-
import { getWorkspace, updateWorkspace } from '../utility/workspace';
2727
import { Builders } from '../utility/workspace-models';
2828
import { Schema as E2eOptions } from './schema';
2929

@@ -41,7 +41,7 @@ function addScriptsToPackageJson(): Rule {
4141
export default function (options: E2eOptions): Rule {
4242
return async (host: Tree) => {
4343
const appProject = options.relatedAppName;
44-
const workspace = await getWorkspace(host);
44+
const workspace = await readWorkspace(host);
4545
const project = workspace.projects.get(appProject);
4646
if (!project) {
4747
throw new SchematicsException(`Project name "${appProject}" doesn't not exist.`);
@@ -66,8 +66,9 @@ export default function (options: E2eOptions): Rule {
6666
},
6767
});
6868

69+
await writeWorkspace(host, workspace);
70+
6971
return chain([
70-
updateWorkspace(workspace),
7172
mergeWith(
7273
apply(url('./files'), [
7374
applyTemplates({

packages/schematics/angular/package.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,13 @@
88
"code generation",
99
"schematics"
1010
],
11+
"exports": {
12+
"./package.json": "./package.json",
13+
"./utility": "./utility/index.js",
14+
"./utility/*": "./utility/*.js",
15+
"./migrations/migration-collection.json": "./migrations/migration-collection.json",
16+
"./*": "./*.js"
17+
},
1118
"schematics": "./collection.json",
1219
"dependencies": {
1320
"@angular-devkit/core": "0.0.0-PLACEHOLDER",

packages/schematics/angular/service-worker/index.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
} from '@angular-devkit/schematics';
2222
import { NodePackageInstallTask } from '@angular-devkit/schematics/tasks';
2323
import * as ts from '../third_party/github.com/Microsoft/TypeScript/lib/typescript';
24+
import { readWorkspace, writeWorkspace } from '../utility';
2425
import {
2526
addSymbolToNgModuleMetadata,
2627
getEnvironmentExportName,
@@ -32,7 +33,6 @@ import { addPackageJsonDependency, getPackageJsonDependency } from '../utility/d
3233
import { getAppModulePath } from '../utility/ng-ast-utils';
3334
import { relativePathToWorkspaceRoot } from '../utility/paths';
3435
import { targetBuildNotFoundError } from '../utility/project-targets';
35-
import { getWorkspace, updateWorkspace } from '../utility/workspace';
3636
import { BrowserBuilderOptions } from '../utility/workspace-models';
3737
import { Schema as ServiceWorkerOptions } from './schema';
3838

@@ -130,7 +130,7 @@ function getTsSourceFile(host: Tree, path: string): ts.SourceFile {
130130

131131
export default function (options: ServiceWorkerOptions): Rule {
132132
return async (host: Tree, context: SchematicContext) => {
133-
const workspace = await getWorkspace(host);
133+
const workspace = await readWorkspace(host);
134134
const project = workspace.projects.get(options.project);
135135
if (!project) {
136136
throw new SchematicsException(`Invalid project name (${options.project})`);
@@ -163,9 +163,10 @@ export default function (options: ServiceWorkerOptions): Rule {
163163

164164
context.addTask(new NodePackageInstallTask());
165165

166+
await writeWorkspace(host, workspace);
167+
166168
return chain([
167169
mergeWith(templateSource),
168-
updateWorkspace(workspace),
169170
addDependencies(),
170171
updateAppModule(buildOptions.main),
171172
]);
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
// Workspace related rules and types
10+
export {
11+
ProjectDefinition,
12+
TargetDefinition,
13+
WorkspaceDefinition,
14+
getWorkspace as readWorkspace,
15+
updateWorkspace,
16+
writeWorkspace,
17+
} from './workspace';
18+
export { Builders as AngularBuilder } from './workspace-models';
19+
20+
// Package dependency related rules and types
21+
export { DependencyType, addDependency } from './dependency';

packages/schematics/angular/utility/workspace-models.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@ export enum ProjectType {
1111
Library = 'library',
1212
}
1313

14+
/**
15+
* An enum of the official Angular builders.
16+
* Each enum value provides the fully qualified name of the associated builder.
17+
* This enum can be used when analyzing the `builder` fields of project configurations from the
18+
* `angular.json` workspace file.
19+
*/
1420
export enum Builders {
1521
AppShell = '@angular-devkit/build-angular:app-shell',
1622
Server = '@angular-devkit/build-angular:server',

packages/schematics/angular/utility/workspace.ts

Lines changed: 85 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -10,60 +10,109 @@ import { json, workspaces } from '@angular-devkit/core';
1010
import { Rule, Tree, noop } from '@angular-devkit/schematics';
1111
import { ProjectType } from './workspace-models';
1212

13-
function createHost(tree: Tree): workspaces.WorkspaceHost {
14-
return {
15-
async readFile(path: string): Promise<string> {
16-
return tree.readText(path);
17-
},
18-
async writeFile(path: string, data: string): Promise<void> {
19-
return tree.overwrite(path, data);
20-
},
21-
async isDirectory(path: string): Promise<boolean> {
22-
// approximate a directory check
23-
return !tree.exists(path) && tree.getDir(path).subfiles.length > 0;
24-
},
25-
async isFile(path: string): Promise<boolean> {
26-
return tree.exists(path);
27-
},
28-
};
13+
const DEFAULT_WORKSPACE_PATH = '/angular.json';
14+
15+
// re-export the workspace definition types for convenience
16+
export type WorkspaceDefinition = workspaces.WorkspaceDefinition;
17+
export type ProjectDefinition = workspaces.ProjectDefinition;
18+
export type TargetDefinition = workspaces.TargetDefinition;
19+
20+
/**
21+
* A {@link workspaces.WorkspaceHost} backed by a Schematics {@link Tree} instance.
22+
*/
23+
class TreeWorkspaceHost implements workspaces.WorkspaceHost {
24+
constructor(private readonly tree: Tree) {}
25+
26+
async readFile(path: string): Promise<string> {
27+
return this.tree.readText(path);
28+
}
29+
30+
async writeFile(path: string, data: string): Promise<void> {
31+
if (this.tree.exists(path)) {
32+
this.tree.overwrite(path, data);
33+
} else {
34+
this.tree.create(path, data);
35+
}
36+
}
37+
38+
async isDirectory(path: string): Promise<boolean> {
39+
// approximate a directory check
40+
return !this.tree.exists(path) && this.tree.getDir(path).subfiles.length > 0;
41+
}
42+
43+
async isFile(path: string): Promise<boolean> {
44+
return this.tree.exists(path);
45+
}
2946
}
3047

48+
/**
49+
* Updates the workspace file (`angular.json`) found within the root of the schematic's tree.
50+
* The workspace object model can be directly modified within the provided updater function
51+
* with changes being written to the workspace file after the updater function returns.
52+
* The spacing and overall layout of the file (including comments) will be maintained where
53+
* possible when updating the file.
54+
*
55+
* @param updater An update function that can be used to modify the object model for the
56+
* workspace. A {@link WorkspaceDefinition} is provided as the first argument to the function.
57+
*/
3158
export function updateWorkspace(
32-
updater: (workspace: workspaces.WorkspaceDefinition) => void | Rule | PromiseLike<void | Rule>,
33-
): Rule;
34-
export function updateWorkspace(workspace: workspaces.WorkspaceDefinition): Rule;
35-
export function updateWorkspace(
36-
updaterOrWorkspace:
37-
| workspaces.WorkspaceDefinition
38-
| ((workspace: workspaces.WorkspaceDefinition) => void | Rule | PromiseLike<void | Rule>),
59+
updater: (workspace: WorkspaceDefinition) => void | Rule | PromiseLike<void | Rule>,
3960
): Rule {
4061
return async (tree: Tree) => {
41-
const host = createHost(tree);
62+
const host = new TreeWorkspaceHost(tree);
4263

43-
if (typeof updaterOrWorkspace === 'function') {
44-
const { workspace } = await workspaces.readWorkspace('/', host);
64+
const { workspace } = await workspaces.readWorkspace(DEFAULT_WORKSPACE_PATH, host);
4565

46-
const result = await updaterOrWorkspace(workspace);
66+
const result = await updater(workspace);
4767

48-
await workspaces.writeWorkspace(workspace, host);
68+
await workspaces.writeWorkspace(workspace, host);
4969

50-
return result || noop;
51-
} else {
52-
await workspaces.writeWorkspace(updaterOrWorkspace, host);
53-
54-
return noop;
55-
}
70+
return result || noop;
5671
};
5772
}
5873

59-
export async function getWorkspace(tree: Tree, path = '/') {
60-
const host = createHost(tree);
74+
// TODO: This should be renamed `readWorkspace` once deep imports are restricted (already exported from `utility` with that name)
75+
/**
76+
* Reads a workspace file (`angular.json`) from the provided {@link Tree} instance.
77+
*
78+
* @param tree A schematics {@link Tree} instance used to access the workspace file.
79+
* @param path The path where a workspace file should be found. If a file is specified, the file
80+
* path will be used. If a directory is specified, the file `angular.json` will be used from
81+
* within the specified directory. Defaults to `/angular.json`.
82+
* @returns A {@link WorkspaceDefinition} representing the workspace found at the specified path.
83+
*/
84+
export async function getWorkspace(
85+
tree: Tree,
86+
path = DEFAULT_WORKSPACE_PATH,
87+
): Promise<WorkspaceDefinition> {
88+
const host = new TreeWorkspaceHost(tree);
6189

6290
const { workspace } = await workspaces.readWorkspace(path, host);
6391

6492
return workspace;
6593
}
6694

95+
/**
96+
* Writes a workspace file (`angular.json`) to the provided {@link Tree} instance.
97+
* The spacing and overall layout of an exisitng file (including comments) will be maintained where
98+
* possible when writing the file.
99+
*
100+
* @param tree A schematics {@link Tree} instance used to access the workspace file.
101+
* @param workspace The {@link WorkspaceDefinition} to write.
102+
* @param path The path where a workspace file should be written. If a file is specified, the file
103+
* path will be used. If not provided, the definition's underlying file path stored during reading
104+
* will be used.
105+
*/
106+
export async function writeWorkspace(
107+
tree: Tree,
108+
workspace: WorkspaceDefinition,
109+
path?: string,
110+
): Promise<void> {
111+
const host = new TreeWorkspaceHost(tree);
112+
113+
return workspaces.writeWorkspace(workspace, host, path);
114+
}
115+
67116
/**
68117
* Build a default project path for generating.
69118
* @param project The project which will have its default path generated.
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import { EmptyTree, Rule, SchematicContext, Tree, callRule } from '@angular-devkit/schematics';
10+
import { getWorkspace as readWorkspace, updateWorkspace, writeWorkspace } from './workspace';
11+
12+
const TEST_WORKSPACE_CONTENT = JSON.stringify({
13+
version: 1,
14+
projects: {
15+
'test': {},
16+
},
17+
});
18+
19+
async function testRule(rule: Rule, tree: Tree): Promise<void> {
20+
await callRule(rule, tree, {} as unknown as SchematicContext).toPromise();
21+
}
22+
23+
describe('readWorkspace', () => {
24+
it('reads a workspace using the default path value', async () => {
25+
const tree = new EmptyTree();
26+
tree.create('/angular.json', TEST_WORKSPACE_CONTENT);
27+
28+
const workspace = await readWorkspace(tree);
29+
expect(workspace.projects.has('test')).toBeTrue();
30+
});
31+
32+
it('reads a workspace when specifying a directory path', async () => {
33+
const tree = new EmptyTree();
34+
tree.create('/xyz/angular.json', TEST_WORKSPACE_CONTENT);
35+
36+
const workspace = await readWorkspace(tree, '/xyz/');
37+
expect(workspace.projects.has('test')).toBeTrue();
38+
});
39+
40+
it('reads a workspace when specifying a file path', async () => {
41+
const tree = new EmptyTree();
42+
tree.create('/xyz/angular.json', TEST_WORKSPACE_CONTENT);
43+
44+
const workspace = await readWorkspace(tree, '/xyz/angular.json');
45+
expect(workspace.projects.has('test')).toBeTrue();
46+
});
47+
48+
it('throws if workspace file does not exist when using the default path value', async () => {
49+
const tree = new EmptyTree();
50+
51+
await expectAsync(readWorkspace(tree)).toBeRejectedWithError();
52+
});
53+
54+
it('throws if workspace file does not exist when specifying a file path', async () => {
55+
const tree = new EmptyTree();
56+
tree.create('/angular.json', TEST_WORKSPACE_CONTENT);
57+
58+
await expectAsync(readWorkspace(tree, 'abc.json')).toBeRejectedWithError();
59+
});
60+
});
61+
62+
describe('writeWorkspace', () => {
63+
it('writes a workspace using the default path value', async () => {
64+
const tree = new EmptyTree();
65+
tree.create('/angular.json', TEST_WORKSPACE_CONTENT);
66+
const workspace = await readWorkspace(tree);
67+
68+
workspace.extensions['x-abc'] = 1;
69+
await writeWorkspace(tree, workspace);
70+
expect(tree.readJson('/angular.json')).toEqual(jasmine.objectContaining({ 'x-abc': 1 }));
71+
});
72+
73+
it('writes a workspace when specifying a path', async () => {
74+
const tree = new EmptyTree();
75+
tree.create('/angular.json', TEST_WORKSPACE_CONTENT);
76+
const workspace = await readWorkspace(tree);
77+
78+
workspace.extensions['x-abc'] = 1;
79+
await writeWorkspace(tree, workspace, '/xyz/angular.json');
80+
expect(tree.readJson('/xyz/angular.json')).toEqual(jasmine.objectContaining({ 'x-abc': 1 }));
81+
});
82+
});
83+
84+
describe('updateWorkspace', () => {
85+
it('updates a workspace using the default path value', async () => {
86+
const tree = new EmptyTree();
87+
tree.create('/angular.json', TEST_WORKSPACE_CONTENT);
88+
89+
const rule = updateWorkspace((workspace) => {
90+
workspace.projects.add({
91+
name: 'abc',
92+
root: 'src',
93+
});
94+
});
95+
96+
await testRule(rule, tree);
97+
98+
expect(tree.read('angular.json')?.toString()).toContain('"abc"');
99+
});
100+
101+
it('throws if workspace file does not exist', async () => {
102+
const tree = new EmptyTree();
103+
104+
const rule = updateWorkspace((workspace) => {
105+
workspace.projects.add({
106+
name: 'abc',
107+
root: 'src',
108+
});
109+
});
110+
111+
await expectAsync(testRule(rule, tree)).toBeRejectedWithError();
112+
});
113+
114+
it('allows executing a returned followup rule', async () => {
115+
const tree = new EmptyTree();
116+
tree.create('/angular.json', TEST_WORKSPACE_CONTENT);
117+
118+
const rule = updateWorkspace(() => {
119+
return (tree) => tree.create('/followup.txt', '12345');
120+
});
121+
122+
await testRule(rule, tree);
123+
124+
expect(tree.read('/followup.txt')?.toString()).toContain('12345');
125+
});
126+
});

0 commit comments

Comments
 (0)