Skip to content
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
5 changes: 4 additions & 1 deletion goldens/public-api/angular_devkit/schematics/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -637,7 +637,10 @@ export enum MergeStrategy {
export function mergeWith(source: Source, strategy?: MergeStrategy): Rule;

// @public (undocumented)
export function move(from: string, to?: string): Rule;
export function move(from: string, to: string): Rule;

// @public (undocumented)
export function move(to: string): Rule;

// @public (undocumented)
export function noop(): Rule;
Expand Down
2 changes: 2 additions & 0 deletions packages/angular_devkit/schematics/src/rules/move.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { join, normalize } from '@angular-devkit/core';
import { Rule } from '../engine/interface';
import { noop } from './base';

export function move(from: string, to: string): Rule;
export function move(to: string): Rule;
export function move(from: string, to?: string): Rule {
if (to === undefined) {
to = from;
Expand Down
49 changes: 49 additions & 0 deletions packages/schematics/angular/ai-config/files/__rulesName__.template
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<% if (frontmatter) { %><%= frontmatter %>

<% } %>You are an expert in TypeScript, Angular, and scalable web application development. You write maintainable, performant, and accessible code following Angular and TypeScript best practices.

## TypeScript Best Practices

- Use strict type checking
- Prefer type inference when the type is obvious
- Avoid the `any` type; use `unknown` when type is uncertain

## Angular Best Practices

- Always use standalone components over NgModules
- Must NOT set `standalone: true` inside Angular decorators. It's the default.
- Use signals for state management
- Implement lazy loading for feature routes
- Do NOT use the `@HostBinding` and `@HostListener` decorators. Put host bindings inside the `host` object of the `@Component` or `@Directive` decorator instead
- Use `NgOptimizedImage` for all static images.
- `NgOptimizedImage` does not work for inline base64 images.

## Components

- Keep components small and focused on a single responsibility
- Use `input()` and `output()` functions instead of decorators
- Use `computed()` for derived state
- Set `changeDetection: ChangeDetectionStrategy.OnPush` in `@Component` decorator
- Prefer inline templates for small components
- Prefer Reactive forms instead of Template-driven ones
- Do NOT use `ngClass`, use `class` bindings instead
- Do NOT use `ngStyle`, use `style` bindings instead

## State Management

- Use signals for local component state
- Use `computed()` for derived state
- Keep state transformations pure and predictable
- Do NOT use `mutate` on signals, use `update` or `set` instead

## Templates

- Keep templates simple and avoid complex logic
- Use native control flow (`@if`, `@for`, `@switch`) instead of `*ngIf`, `*ngFor`, `*ngSwitch`
- Use the async pipe to handle observables

## Services

- Design services around a single responsibility
- Use the `providedIn: 'root'` option for singleton services
- Use the `inject()` function instead of constructor injection
82 changes: 82 additions & 0 deletions packages/schematics/angular/ai-config/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* @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.dev/license
*/

import {
Rule,
apply,
applyTemplates,
chain,
mergeWith,
move,
noop,
strings,
url,
} from '@angular-devkit/schematics';
import { Schema as ConfigOptions, Tool } from './schema';

type ToolWithoutNone = Exclude<Tool, Tool.None>;

const AI_TOOLS: { [key in ToolWithoutNone]: ContextFileInfo } = {
gemini: {
rulesName: 'GEMINI.md',
directory: '.gemini',
},
claude: {
rulesName: 'CLAUDE.md',
directory: '.claude',
},
copilot: {
rulesName: 'copilot-instructions.md',
directory: '.github',
},
windsurf: {
rulesName: 'guidelines.md',
directory: '.windsurf/rules',
},
jetbrains: {
rulesName: 'guidelines.md',
directory: '.junie',
},
// Cursor file has a front matter section.
cursor: {
rulesName: 'cursor.mdc',
directory: '.cursor/rules',
frontmatter: `---\ncontext: true\npriority: high\nscope: project\n---`,
},
};

interface ContextFileInfo {
rulesName: string;
directory: string;
frontmatter?: string;
}

export default function ({ tool }: ConfigOptions): Rule {
if (!tool || tool.includes(Tool.None)) {
return noop();
}

const files: ContextFileInfo[] = (tool as ToolWithoutNone[]).map(
(selectedTool) => AI_TOOLS[selectedTool],
);

const rules = files.map(({ rulesName, directory, frontmatter }) =>
mergeWith(
apply(url('./files'), [
applyTemplates({
...strings,
rulesName,
frontmatter,
}),
move(directory),
]),
),
);

return chain(rules);
}
84 changes: 84 additions & 0 deletions packages/schematics/angular/ai-config/index_spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* @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.dev/license
*/

import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing';
import { Schema as WorkspaceOptions } from '../workspace/schema';
import { Schema as ConfigOptions, Tool as ConfigTool } from './schema';

describe('Ai Config Schematic', () => {
const schematicRunner = new SchematicTestRunner(
'@schematics/angular',
require.resolve('../collection.json'),
);

const workspaceOptions: WorkspaceOptions = {
name: 'workspace',
newProjectRoot: 'projects',
version: '15.0.0',
};

let workspaceTree: UnitTestTree;
function runConfigSchematic(tool: ConfigTool[]): Promise<UnitTestTree> {
return schematicRunner.runSchematic<ConfigOptions>('ai-config', { tool }, workspaceTree);
}

beforeEach(async () => {
workspaceTree = await schematicRunner.runSchematic('workspace', workspaceOptions);
});

it('should create a GEMINI.MD file', async () => {
const tree = await runConfigSchematic([ConfigTool.Gemini]);
expect(tree.exists('.gemini/GEMINI.md')).toBeTruthy();
});

it('should create a copilot-instructions.md file', async () => {
const tree = await runConfigSchematic([ConfigTool.Copilot]);
expect(tree.exists('.github/copilot-instructions.md')).toBeTruthy();
});

it('should create a cursor file', async () => {
const tree = await runConfigSchematic([ConfigTool.Cursor]);
expect(tree.exists('.cursor/rules/cursor.mdc')).toBeTruthy();
});

it('should create a windsurf file', async () => {
const tree = await runConfigSchematic([ConfigTool.Windsurf]);
expect(tree.exists('.windsurf/rules/guidelines.md')).toBeTruthy();
});

it('should create a claude file', async () => {
const tree = await runConfigSchematic([ConfigTool.Claude]);
expect(tree.exists('.claude/CLAUDE.md')).toBeTruthy();
});

it('should create a jetbrains file', async () => {
const tree = await runConfigSchematic([ConfigTool.Jetbrains]);
expect(tree.exists('.junie/guidelines.md')).toBeTruthy();
});

it('should create multiple files when multiple tools are selected', async () => {
const tree = await runConfigSchematic([
ConfigTool.Gemini,
ConfigTool.Copilot,
ConfigTool.Cursor,
]);
expect(tree.exists('.gemini/GEMINI.md')).toBeTruthy();
expect(tree.exists('.github/copilot-instructions.md')).toBeTruthy();
expect(tree.exists('.cursor/rules/cursor.mdc')).toBeTruthy();
});

it('should error is None is associated with other values', () => {
return expectAsync(runConfigSchematic([ConfigTool.None, ConfigTool.Cursor])).toBeRejected();
});

it('should not create any files if None is selected', async () => {
const filesCount = workspaceTree.files.length;
const tree = await runConfigSchematic([ConfigTool.None]);
expect(tree.files.length).toBe(filesCount);
});
});
39 changes: 39 additions & 0 deletions packages/schematics/angular/ai-config/schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"$id": "SchematicsAngularAIConfig",
"title": "Angular AI Config File Options Schema",
"type": "object",
"additionalProperties": false,
"description": "Generates AI configuration files for Angular projects. This schematic creates configuration files that help AI tools follow Angular best practices, improving the quality of AI-generated code and suggestions.",
"properties": {
"tool": {
"type": "array",
"uniqueItems": true,
"default": "none",
"x-prompt": "Which AI tools do you want to configure with Angular best practices? https://angular.dev/ai/develop-with-ai",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT: As a follow up can we improve the prompt to include some more details

  "x-prompt": {
        "message": "Which AI tools do you want to configure with Angular best practices? https://angular.dev/ai/develop-with-ai",
        "type": "list",
        "items": [
          {
            "value": "none",
            "label": "None"
          },
          {
            "value": "copilot",
            "label": "Copilot powered IDEs     [ https://https://code.visualstudio.com/docs/copilot/copilot-customization#_custom-instructions ]"
          },
          {
            "value": "cursor",
            "label": "Cursor                   [ https://docs.cursor.com/en/context/rules                                                      ]"
          }
        ]
       ...
      },

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will take care of it in a follow up.

"description": "Specifies which AI tools to generate configuration files for. These file are used to improve the outputs of AI tools by following the best practices.",
"minItems": 1,
"items": {
"type": "string",
"enum": ["none", "gemini", "copilot", "claude", "cursor", "jetbrains", "windsurf"]
}
}
},
"if": {
"properties": {
"tool": {
"contains": {
"const": "none"
}
}
},
"required": ["tool"]
},
"then": {
"properties": {
"tool": {
"maxItems": 1
}
}
}
}
5 changes: 5 additions & 0 deletions packages/schematics/angular/collection.json
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,11 @@
"factory": "./config",
"schema": "./config/schema.json",
"description": "Generates a configuration file."
},
"ai-config": {
"factory": "./ai-config",
"schema": "./ai-config/schema.json",
"description": "Generates an AI tool configuration file."
}
}
}
3 changes: 3 additions & 0 deletions packages/schematics/angular/ng-new/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ export default function (options: NgNewOptions): Rule {
apply(empty(), [
schematic('workspace', workspaceOptions),
options.createApplication ? schematic('application', applicationOptions) : noop,
schematic('ai-config', {
tool: options.aiConfig?.length ? options.aiConfig : undefined,
}),
move(options.directory),
]),
),
Expand Down
9 changes: 9 additions & 0 deletions packages/schematics/angular/ng-new/index_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,4 +103,13 @@ describe('Ng New Schematic', () => {
const { cli } = JSON.parse(tree.readContent('/bar/angular.json'));
expect(cli.packageManager).toBe('npm');
});

it('should add ai config file when aiConfig is set', async () => {
const options = { ...defaultOptions, aiConfig: ['gemini', 'claude'] };

const tree = await schematicRunner.runSchematic('ng-new', options);
const files = tree.files;
expect(files).toContain('/bar/.gemini/GEMINI.md');
expect(files).toContain('/bar/.claude/CLAUDE.md');
});
});
9 changes: 9 additions & 0 deletions packages/schematics/angular/ng-new/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,15 @@
"zoneless": {
"description": "Create an initial application that does not utilize `zone.js`.",
"type": "boolean"
},
"aiConfig": {
"type": "array",
"uniqueItems": true,
"description": "Specifies which AI tools to generate configuration files for. These file are used to improve the outputs of AI tools by following the best practices.",
"items": {
"type": "string",
"enum": ["none", "gemini", "copilot", "claude", "cursor", "jetbrains", "windsurf"]
}
}
},
"required": ["name", "version"]
Expand Down