Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Mar 22, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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`.
8 changes: 4 additions & 4 deletions goldens/public-api/angular_devkit/schematics/src/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ export interface Collection<CollectionMetadataT extends object, SchematicMetadat
// (undocumented)
readonly description: CollectionDescription<CollectionMetadataT>;
// (undocumented)
listSchematicNames(): string[];
listSchematicNames(includeHidden?: boolean): string[];
}

// @public
Expand All @@ -178,7 +178,7 @@ export class CollectionImpl<CollectionT extends object, SchematicT extends objec
// (undocumented)
get description(): CollectionDescription<CollectionT>;
// (undocumented)
listSchematicNames(): string[];
listSchematicNames(includeHidden?: boolean): string[];
// (undocumented)
get name(): string;
}
Expand Down Expand Up @@ -380,7 +380,7 @@ export interface EngineHost<CollectionMetadataT extends object, SchematicMetadat
// (undocumented)
hasTaskExecutor(name: string): boolean;
// (undocumented)
listSchematicNames(collection: CollectionDescription<CollectionMetadataT>): string[];
listSchematicNames(collection: CollectionDescription<CollectionMetadataT>, includeHidden?: boolean): string[];
// (undocumented)
transformContext(context: TypedSchematicContext<CollectionMetadataT, SchematicMetadataT>): TypedSchematicContext<CollectionMetadataT, SchematicMetadataT> | void;
// (undocumented)
Expand Down Expand Up @@ -755,7 +755,7 @@ export class SchematicEngine<CollectionT extends object, SchematicT extends obje
// (undocumented)
executePostTasks(): Observable<void>;
// (undocumented)
listSchematicNames(collection: Collection<CollectionT, SchematicT>): string[];
listSchematicNames(collection: Collection<CollectionT, SchematicT>, includeHidden?: boolean): string[];
// (undocumented)
transformOptions<OptionT extends object, ResultT extends object>(schematic: Schematic<CollectionT, SchematicT>, options: OptionT, context?: TypedSchematicContext<CollectionT, SchematicT>): Observable<ResultT>;
// (undocumented)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ export abstract class FileSystemEngineHostBase implements FileSystemEngineHost_2
// (undocumented)
hasTaskExecutor(name: string): boolean;
// (undocumented)
listSchematicNames(collection: FileSystemCollectionDesc): string[];
listSchematicNames(collection: FileSystemCollectionDesc, includeHidden?: boolean): string[];
// (undocumented)
registerContextTransform(t: ContextTransform): void;
// (undocumented)
Expand Down
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' }),
);
}
}
Loading