feat(@angular/cli): add support for multiple schematics collections

The `schematicCollections` can be placed under the `cli` option in the global `.angular.json` configuration, at the root or at project level in `angular.json` .

```jsonc
{
  "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
  "version": 1,
  "cli": {
    "schematicCollections": ["@schematics/angular", "@angular/material"]
  }
  // ...
}
```

**Rationale**
When this option is not configured and a user would like to run a schematic which is not part of `@schematics/angular`,
the collection name needs to be provided to `ng generate` command in the form of `[collection-name:schematic-name]`. This make the `ng generate` command too verbose for repeated usages.

This is where `schematicCollections` comes handle. When adding `@angular/material` to the list of `schematicCollections`, the generate command will try to locate the schematic in the specified collections.

```
ng generate navigation
```

is equivalent to:

```
ng generate @angular/material:navigation
```

**Conflicting schematic names**
When multiple collections have a schematic with the same name. Both `ng generate` and `ng new` will run the first schematic matched based on the ordering (as specified) of `schematicCollections`.

DEPRECATED:

The `defaultCollection` workspace option has been deprecated in favor of `schematicCollections`.

Before
```json
"defaultCollection": "@angular/material"
```

After
```json
"schematicCollections": ["@angular/material"]
```

Closes #12157
This commit is contained in:
Alan Agius 2022-03-22 15:30:06 +01:00 committed by Douglas Parker
parent c9c781c7d5
commit 366cabc66c
12 changed files with 435 additions and 62 deletions

View File

@ -0,0 +1,35 @@
# Schematics Collections (`schematicCollections`)
The `schematicCollections` can be placed under the `cli` option in the global `.angular.json` configuration, at the root or at project level in `angular.json` .
```jsonc
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"schematicCollections": ["@schematics/angular", "@angular/material"]
}
// ...
}
```
## Rationale
When this option is not configured and a user would like to run a schematic which is not part of `@schematics/angular`,
the collection name needs to be provided to `ng generate` command in the form of `[collection-name:schematic-name]`. This make the `ng generate` command too verbose for repeated usages.
This is where the `schematicCollections` option can be useful. When adding `@angular/material` to the list of `schematicCollections`, the generate command will try to locate the schematic in the specified collections.
```
ng generate navigation
```
is equivalent to:
```
ng generate @angular/material:navigation
```
## Conflicting schematic names
When multiple collections have a schematic with the same name. Both `ng generate` and `ng new` will run the first schematic matched based on the ordering (as specified) of `schematicCollections`.

View File

@ -43,7 +43,16 @@
"properties": {
"defaultCollection": {
"description": "The default schematics collection to use.",
"type": "string"
"type": "string",
"x-deprecated": "Use 'schematicCollections' instead."
},
"schematicCollections": {
"type": "array",
"description": "The list of schematic collections to use.",
"items": {
"type": "string",
"uniqueItems": true
}
},
"packageManager": {
"description": "Specify which package manager tool to use.",
@ -162,7 +171,16 @@
"cli": {
"defaultCollection": {
"description": "The default schematics collection to use.",
"type": "string"
"type": "string",
"x-deprecated": "Use 'schematicCollections' instead."
},
"schematicCollections": {
"type": "array",
"description": "The list of schematic collections to use.",
"items": {
"type": "string",
"uniqueItems": true
}
}
},
"schematics": {

View File

@ -33,7 +33,7 @@ import { Option, parseJsonSchemaToOptions } from './utilities/json-schema';
import { SchematicEngineHost } from './utilities/schematic-engine-host';
import { subscribeToWorkflow } from './utilities/schematic-workflow';
const DEFAULT_SCHEMATICS_COLLECTION = '@schematics/angular';
export const DEFAULT_SCHEMATICS_COLLECTION = '@schematics/angular';
export interface SchematicsCommandArgs {
interactive: boolean;
@ -95,16 +95,21 @@ export abstract class SchematicsCommandModule
return parseJsonSchemaToOptions(workflow.registry, schemaJson);
}
private _workflowForBuilder: NodeWorkflow | undefined;
private _workflowForBuilder = new Map<string, NodeWorkflow>();
protected getOrCreateWorkflowForBuilder(collectionName: string): NodeWorkflow {
if (this._workflowForBuilder) {
return this._workflowForBuilder;
const cached = this._workflowForBuilder.get(collectionName);
if (cached) {
return cached;
}
return (this._workflowForBuilder = new NodeWorkflow(this.context.root, {
const workflow = new NodeWorkflow(this.context.root, {
resolvePaths: this.getResolvePaths(collectionName),
engineHostCreator: (options) => new SchematicEngineHost(options.resolvePaths),
}));
});
this._workflowForBuilder.set(collectionName, workflow);
return workflow;
}
private _workflowForExecution: NodeWorkflow | undefined;
@ -238,36 +243,55 @@ export abstract class SchematicsCommandModule
return (this._workflowForExecution = workflow);
}
private _defaultSchematicCollection: string | undefined;
protected async getDefaultSchematicCollection(): Promise<string> {
if (this._defaultSchematicCollection) {
return this._defaultSchematicCollection;
private _schematicCollections: Set<string> | undefined;
protected async getSchematicCollections(): Promise<Set<string>> {
if (this._schematicCollections) {
return this._schematicCollections;
}
let workspace = await getWorkspace('local');
const getSchematicCollections = (
configSection: Record<string, unknown> | undefined,
): Set<string> | undefined => {
if (!configSection) {
return undefined;
}
if (workspace) {
const project = getProjectByCwd(workspace);
const { schematicCollections, defaultCollection } = configSection;
if (Array.isArray(schematicCollections)) {
return new Set(schematicCollections);
} else if (typeof defaultCollection === 'string') {
return new Set([defaultCollection]);
}
return undefined;
};
const localWorkspace = await getWorkspace('local');
if (localWorkspace) {
const project = getProjectByCwd(localWorkspace);
if (project) {
const value = workspace.getProjectCli(project)['defaultCollection'];
if (typeof value == 'string') {
return (this._defaultSchematicCollection = value);
const value = getSchematicCollections(localWorkspace.getProjectCli(project));
if (value) {
this._schematicCollections = value;
return value;
}
}
const value = workspace.getCli()['defaultCollection'];
if (typeof value === 'string') {
return (this._defaultSchematicCollection = value);
}
}
workspace = await getWorkspace('global');
const value = workspace?.getCli()['defaultCollection'];
if (typeof value === 'string') {
return (this._defaultSchematicCollection = value);
const globalWorkspace = await getWorkspace('global');
const value =
getSchematicCollections(localWorkspace?.getCli()) ??
getSchematicCollections(globalWorkspace?.getCli());
if (value) {
this._schematicCollections = value;
return value;
}
return (this._defaultSchematicCollection = DEFAULT_SCHEMATICS_COLLECTION);
this._schematicCollections = new Set([DEFAULT_SCHEMATICS_COLLECTION]);
return this._schematicCollections;
}
protected parseSchematicInfo(

View File

@ -103,6 +103,7 @@ export class ConfigCommandModule
>([
['cli.warnings.versionMismatch', undefined],
['cli.defaultCollection', undefined],
['cli.schematicCollections', undefined],
['cli.packageManager', undefined],
['cli.analytics', undefined],

View File

@ -9,6 +9,7 @@
import { strings } from '@angular-devkit/core';
import { Argv } from 'yargs';
import {
CommandModuleError,
CommandModuleImplementation,
Options,
OtherOptions,
@ -48,28 +49,9 @@ export class GenerateCommandModule
handler: (options) => this.handler(options),
});
const collectionName = await this.getCollectionName();
const workflow = this.getOrCreateWorkflowForBuilder(collectionName);
const collection = workflow.engine.createCollection(collectionName);
const schematicsInCollection = collection.description.schematics;
// We cannot use `collection.listSchematicNames()` as this doesn't return hidden schematics.
const schematicNames = new Set(Object.keys(schematicsInCollection).sort());
const [, schematicNameFromArgs] = this.parseSchematicInfo(
// positional = [generate, component] or [generate]
this.context.args.positional[1],
);
if (schematicNameFromArgs && schematicNames.has(schematicNameFromArgs)) {
// No need to process all schematics since we know which one the user invoked.
schematicNames.clear();
schematicNames.add(schematicNameFromArgs);
}
for (const schematicName of schematicNames) {
if (schematicsInCollection[schematicName].private) {
continue;
}
for (const [schematicName, collectionName] of await this.getSchematicsToRegister()) {
const workflow = this.getOrCreateWorkflowForBuilder(collectionName);
const collection = workflow.engine.createCollection(collectionName);
const {
description: {
@ -110,8 +92,11 @@ export class GenerateCommandModule
async run(options: Options<GenerateCommandArgs> & OtherOptions): Promise<number | void> {
const { dryRun, schematic, defaults, force, interactive, ...schematicOptions } = options;
const [collectionName = await this.getCollectionName(), schematicName = ''] =
this.parseSchematicInfo(schematic);
const [collectionName, schematicName] = this.parseSchematicInfo(schematic);
if (!collectionName || !schematicName) {
throw new CommandModuleError('A collection and schematic is required during execution.');
}
return this.runSchematic({
collectionName,
@ -126,13 +111,13 @@ export class GenerateCommandModule
});
}
private async getCollectionName(): Promise<string> {
const [collectionName = await this.getDefaultSchematicCollection()] = this.parseSchematicInfo(
private async getCollectionNames(): Promise<string[]> {
const [collectionName] = this.parseSchematicInfo(
// positional = [generate, component] or [generate]
this.context.args.positional[1],
);
return collectionName;
return collectionName ? [collectionName] : [...(await this.getSchematicCollections())];
}
/**
@ -151,12 +136,15 @@ export class GenerateCommandModule
);
const dasherizedSchematicName = strings.dasherize(schematicName);
const schematicCollectionsFromConfig = await this.getSchematicCollections();
const collectionNames = await this.getCollectionNames();
// Only add the collection name as part of the command when it's not the default collection or when it has been provided via the CLI.
// Only add the collection name as part of the command when it's not a known
// schematics collection or when it has been provided via the CLI.
// Ex:`ng generate @schematics/angular:component`
const commandName =
!!collectionNameFromArgs ||
(await this.getDefaultSchematicCollection()) !== (await this.getCollectionName())
!collectionNames.some((c) => schematicCollectionsFromConfig.has(c))
? collectionName + ':' + dasherizedSchematicName
: dasherizedSchematicName;
@ -171,4 +159,54 @@ export class GenerateCommandModule
return `${commandName}${positionalArgs ? ' ' + positionalArgs : ''}`;
}
/**
* Get schematics that can to be registered as subcommands.
*/
private async *getSchematics(): AsyncGenerator<{
schematicName: string;
collectionName: string;
}> {
const seenNames = new Set<string>();
for (const collectionName of await this.getCollectionNames()) {
const workflow = this.getOrCreateWorkflowForBuilder(collectionName);
const collection = workflow.engine.createCollection(collectionName);
for (const schematicName of collection.listSchematicNames(true /** includeHidden */)) {
// If a schematic with this same name is already registered skip.
if (!seenNames.has(schematicName)) {
seenNames.add(schematicName);
yield { schematicName, collectionName };
}
}
}
}
/**
* Get schematics that should to be registered as subcommands.
*
* @returns a sorted list of schematic that needs to be registered as subcommands.
*/
private async getSchematicsToRegister(): Promise<
[schematicName: string, collectionName: string][]
> {
const schematicsToRegister: [schematicName: string, collectionName: string][] = [];
const [, schematicNameFromArgs] = this.parseSchematicInfo(
// positional = [generate, component] or [generate]
this.context.args.positional[1],
);
for await (const { schematicName, collectionName } of this.getSchematics()) {
if (schematicName === schematicNameFromArgs) {
return [[schematicName, collectionName]];
}
schematicsToRegister.push([schematicName, collectionName]);
}
// Didn't find the schematic or no schematic name was provided Ex: `ng generate --help`.
return schematicsToRegister.sort(([nameA], [nameB]) =>
nameA.localeCompare(nameB, undefined, { sensitivity: 'accent' }),
);
}
}

View File

@ -14,6 +14,7 @@ import {
OtherOptions,
} from '../../command-builder/command-module';
import {
DEFAULT_SCHEMATICS_COLLECTION,
SchematicsCommandArgs,
SchematicsCommandModule,
} from '../../command-builder/schematics-command-module';
@ -51,7 +52,7 @@ export class NewCommandModule
const collectionName =
typeof collectionNameFromArgs === 'string'
? collectionNameFromArgs
: await this.getDefaultSchematicCollection();
: await this.getCollectionFromConfig();
const workflow = await this.getOrCreateWorkflowForBuilder(collectionName);
const collection = workflow.engine.createCollection(collectionName);
@ -62,7 +63,7 @@ export class NewCommandModule
async run(options: Options<NewCommandArgs> & OtherOptions): Promise<number | void> {
// Register the version of the CLI in the registry.
const collectionName = options.collection ?? (await this.getDefaultSchematicCollection());
const collectionName = options.collection ?? (await this.getCollectionFromConfig());
const workflow = await this.getOrCreateWorkflowForExecution(collectionName, options);
workflow.registry.addSmartDefaultProvider('ng-cli-version', () => VERSION.full);
@ -89,4 +90,19 @@ export class NewCommandModule
},
});
}
/** Find a collection from config that has an `ng-new` schematic. */
private async getCollectionFromConfig(): Promise<string> {
for (const collectionName of await this.getSchematicCollections()) {
const workflow = this.getOrCreateWorkflowForBuilder(collectionName);
const collection = workflow.engine.createCollection(collectionName);
const schematicsInCollection = collection.description.schematics;
if (Object.keys(schematicsInCollection).includes(this.schematicName)) {
return collectionName;
}
}
return DEFAULT_SCHEMATICS_COLLECTION;
}
}

View File

@ -19,6 +19,11 @@
"version": "14.0.0",
"factory": "./update-14/remove-default-project-option",
"description": "Remove 'defaultProject' option from workspace configuration. The project to use will be determined from the current working directory."
},
"replace-default-collection-option": {
"version": "14.0.0",
"factory": "./update-14/replace-default-collection-option",
"description": "Replace 'defaultCollection' option in workspace configuration with 'schematicCollections'."
}
}
}

View File

@ -0,0 +1,35 @@
/**
* @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 { JsonValue, isJsonObject } from '@angular-devkit/core';
import { Rule } from '@angular-devkit/schematics';
import { updateWorkspace } from '../../utility/workspace';
/** Migration to replace 'defaultCollection' option in angular.json. */
export default function (): Rule {
return updateWorkspace((workspace) => {
// workspace level
replaceDefaultCollection(workspace.extensions['cli']);
// Project level
for (const project of workspace.projects.values()) {
replaceDefaultCollection(project.extensions['cli']);
}
});
}
function replaceDefaultCollection(cliExtension: JsonValue | undefined): void {
if (cliExtension && isJsonObject(cliExtension) && cliExtension['defaultCollection']) {
// If `schematicsCollection` defined `defaultCollection` is ignored hence no need to warn.
if (!cliExtension['schematicCollections']) {
cliExtension['schematicCollections'] = [cliExtension['defaultCollection']];
}
delete cliExtension['defaultCollection'];
}
}

View File

@ -0,0 +1,101 @@
/**
* @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 } from '@angular-devkit/schematics';
import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing';
import { ProjectType, WorkspaceSchema } from '../../utility/workspace-models';
describe(`Migration to replace 'defaultCollection' option.`, () => {
const schematicName = 'replace-default-collection-option';
const schematicRunner = new SchematicTestRunner(
'migrations',
require.resolve('../migration-collection.json'),
);
let tree: UnitTestTree;
beforeEach(() => {
tree = new UnitTestTree(new EmptyTree());
});
it(`should replace 'defaultCollection' with 'schematicCollections' at the root level`, async () => {
const angularConfig: WorkspaceSchema = {
version: 1,
projects: {},
cli: {
defaultCollection: 'foo',
},
};
tree.create('/angular.json', JSON.stringify(angularConfig, undefined, 2));
const newTree = await schematicRunner.runSchematicAsync(schematicName, {}, tree).toPromise();
const { cli } = JSON.parse(newTree.readContent('/angular.json'));
expect(cli.defaultCollection).toBeUndefined();
expect(cli.schematicCollections).toEqual(['foo']);
});
it(`should not error when 'cli' is not defined`, async () => {
const angularConfig: WorkspaceSchema = {
version: 1,
projects: {},
};
tree.create('/angular.json', JSON.stringify(angularConfig, undefined, 2));
const newTree = await schematicRunner.runSchematicAsync(schematicName, {}, tree).toPromise();
const { cli } = JSON.parse(newTree.readContent('/angular.json'));
expect(cli).toBeUndefined();
});
it(`should replace 'defaultCollection' with 'schematicCollections' at the project level`, async () => {
const angularConfig: WorkspaceSchema = {
version: 1,
cli: {
defaultCollection: 'foo',
},
projects: {
test: {
sourceRoot: '',
root: '',
prefix: '',
projectType: ProjectType.Application,
cli: {
defaultCollection: 'bar',
},
},
},
};
tree.create('/angular.json', JSON.stringify(angularConfig, undefined, 2));
const newTree = await schematicRunner.runSchematicAsync(schematicName, {}, tree).toPromise();
const {
projects: { test },
} = JSON.parse(newTree.readContent('/angular.json'));
expect(test.cli.defaultCollection).toBeUndefined();
expect(test.cli.schematicCollections).toEqual(['bar']);
});
it(`should not replace 'defaultCollection' with 'schematicCollections', when it is already defined`, async () => {
const angularConfig: WorkspaceSchema = {
version: 1,
projects: {},
cli: {
defaultCollection: 'foo',
schematicCollections: ['bar'],
},
};
tree.create('/angular.json', JSON.stringify(angularConfig, undefined, 2));
const newTree = await schematicRunner.runSchematicAsync(schematicName, {}, tree).toPromise();
const { cli } = JSON.parse(newTree.readContent('/angular.json'));
expect(cli.defaultCollection).toBeUndefined();
expect(cli.schematicCollections).toEqual(['bar']);
});
});

View File

@ -129,10 +129,15 @@ export type ServeBuilderTarget = BuilderTarget<Builders.DevServer, ServeBuilderO
export type ExtractI18nBuilderTarget = BuilderTarget<Builders.ExtractI18n, ExtractI18nOptions>;
export type E2EBuilderTarget = BuilderTarget<Builders.Protractor, E2EOptions>;
interface WorkspaceCLISchema {
warnings?: Record<string, boolean>;
schematicCollections?: string[];
defaultCollection?: string;
}
export interface WorkspaceSchema {
version: 1;
defaultProject?: string;
cli?: { warnings?: Record<string, boolean> };
cli?: WorkspaceCLISchema;
projects: {
[key: string]: WorkspaceProject<ProjectType.Application | ProjectType.Library>;
};
@ -148,7 +153,7 @@ export interface WorkspaceProject<TProjectType extends ProjectType = ProjectType
sourceRoot: string;
prefix: string;
cli?: { warnings?: Record<string, boolean> };
cli?: WorkspaceCLISchema;
/**
* Tool options.

View File

@ -86,7 +86,7 @@ export default function () {
.then(() =>
updateJsonFile('angular.json', (json) => {
json.cli = json.cli || ({} as any);
json.cli.defaultCollection = 'fake-schematics';
json.cli.schematicCollections = ['fake-schematics'];
}),
)
.then(() => ng('generate', 'fake', '--help'))

View File

@ -0,0 +1,95 @@
import { join } from 'path';
import { ng } from '../../utils/process';
import { writeMultipleFiles, createDir, expectFileToExist } from '../../utils/fs';
import { updateJsonFile } from '../../utils/project';
export default async function () {
// setup temp collection
const genRoot = join('node_modules/fake-schematics/');
const fakeComponentSchematicDesc = 'Fake component schematic';
await createDir(genRoot);
await writeMultipleFiles({
[join(genRoot, 'package.json')]: JSON.stringify({
'schematics': './collection.json',
}),
[join(genRoot, 'collection.json')]: JSON.stringify({
'schematics': {
'fake': {
'description': 'Fake schematic',
'schema': './fake-schema.json',
'factory': './fake',
},
'component': {
'description': fakeComponentSchematicDesc,
'schema': './fake-schema.json',
'factory': './fake-component',
},
},
}),
[join(genRoot, 'fake-schema.json')]: JSON.stringify({
'$id': 'FakeSchema',
'title': 'Fake Schema',
'type': 'object',
}),
[join(genRoot, 'fake.js')]: `
exports.default = function (options) {
return (host, context) => {
console.log('fake schematic run.');
};
}
`,
[join(genRoot, 'fake-component.js')]: `
exports.default = function (options) {
return (host, context) => {
console.log('fake component schematic run.');
};
}
`,
});
await updateJsonFile('angular.json', (json) => {
json.cli ??= {};
json.cli.schematicCollections = ['fake-schematics', '@schematics/angular'];
});
// should display schematics for all schematics
const { stdout: stdout1 } = await ng('generate', '--help');
if (!stdout1.includes('ng generate component')) {
throw new Error(`Didn't show schematics of '@schematics/angular'.`);
}
if (!stdout1.includes('ng generate fake')) {
throw new Error(`Didn't show schematics of 'fake-schematics'.`);
}
// check registration order. Both schematics contain a component schematic verify that the first one wins.
if (!stdout1.includes(fakeComponentSchematicDesc)) {
throw new Error(`Didn't show fake component description.`);
}
// Verify execution based on ordering
const { stdout: stdout2 } = await ng('generate', 'component');
if (!stdout2.includes('fake component schematic run')) {
throw new Error(`stdout didn't contain 'fake component schematic run'.`);
}
await updateJsonFile('angular.json', (json) => {
json.cli ??= {};
json.cli.schematicCollections = ['@schematics/angular', 'fake-schematics'];
});
const { stdout: stdout3 } = await ng('generate', '--help');
if (!stdout3.includes('ng generate component [name]')) {
throw new Error(`Didn't show component description from @schematics/angular.`);
}
if (stdout3.includes(fakeComponentSchematicDesc)) {
throw new Error(`Shown fake component description, when it shouldn't.`);
}
// Verify execution based on ordering
const projectDir = join('src', 'app');
const componentDir = join(projectDir, 'test-component');
await ng('generate', 'component', 'test-component');
await expectFileToExist(componentDir);
}