Skip to content

Commit

Permalink
feat(@angular/cli): add support for multiple schematics collections
Browse files Browse the repository at this point in the history
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
  • Loading branch information
alan-agius4 authored and dgp1130 committed Mar 22, 2022
1 parent c9c781c commit 366cabc
Show file tree
Hide file tree
Showing 12 changed files with 435 additions and 62 deletions.
35 changes: 35 additions & 0 deletions docs/specifications/schematic-collections-config.md
Original file line number Diff line number Diff line change
@@ -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`.
22 changes: 20 additions & 2 deletions packages/angular/cli/lib/config/workspace-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
if (project) {
const value = workspace.getProjectCli(project)['defaultCollection'];
if (typeof value == 'string') {
return (this._defaultSchematicCollection = value);
}
const { schematicCollections, defaultCollection } = configSection;
if (Array.isArray(schematicCollections)) {
return new Set(schematicCollections);
} else if (typeof defaultCollection === 'string') {
return new Set([defaultCollection]);
}

const value = workspace.getCli()['defaultCollection'];
if (typeof value === 'string') {
return (this._defaultSchematicCollection = value);
return undefined;
};

const localWorkspace = await getWorkspace('local');
if (localWorkspace) {
const project = getProjectByCwd(localWorkspace);
if (project) {
const value = getSchematicCollections(localWorkspace.getProjectCli(project));
if (value) {
this._schematicCollections = value;

return 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(
Expand Down
1 change: 1 addition & 0 deletions packages/angular/cli/src/commands/config/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ export class ConfigCommandModule
>([
['cli.warnings.versionMismatch', undefined],
['cli.defaultCollection', undefined],
['cli.schematicCollections', undefined],
['cli.packageManager', undefined],
['cli.analytics', undefined],

Expand Down
96 changes: 67 additions & 29 deletions packages/angular/cli/src/commands/generate/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import { strings } from '@angular-devkit/core';
import { Argv } from 'yargs';
import {
CommandModuleError,
CommandModuleImplementation,
Options,
OtherOptions,
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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,
Expand All @@ -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())];
}

/**
Expand All @@ -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;

Expand All @@ -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' }),
);
}
}
20 changes: 18 additions & 2 deletions packages/angular/cli/src/commands/new/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
OtherOptions,
} from '../../command-builder/command-module';
import {
DEFAULT_SCHEMATICS_COLLECTION,
SchematicsCommandArgs,
SchematicsCommandModule,
} from '../../command-builder/schematics-command-module';
Expand Down Expand Up @@ -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);
Expand All @@ -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);

Expand All @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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'."
}
}
}
Loading

0 comments on commit 366cabc

Please sign in to comment.