diff --git a/goldens/public-api/angular_devkit/schematics/index.api.md b/goldens/public-api/angular_devkit/schematics/index.api.md index 3e3a0bc97ead..505bd2c39920 100644 --- a/goldens/public-api/angular_devkit/schematics/index.api.md +++ b/goldens/public-api/angular_devkit/schematics/index.api.md @@ -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; diff --git a/packages/angular_devkit/schematics/src/rules/move.ts b/packages/angular_devkit/schematics/src/rules/move.ts index 05cd2b36634e..4c6c1e8d2f39 100644 --- a/packages/angular_devkit/schematics/src/rules/move.ts +++ b/packages/angular_devkit/schematics/src/rules/move.ts @@ -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; diff --git a/packages/schematics/angular/ai-config/files/__rulesName__.template b/packages/schematics/angular/ai-config/files/__rulesName__.template new file mode 100644 index 000000000000..0d4f1bbf8d41 --- /dev/null +++ b/packages/schematics/angular/ai-config/files/__rulesName__.template @@ -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 diff --git a/packages/schematics/angular/ai-config/index.ts b/packages/schematics/angular/ai-config/index.ts new file mode 100644 index 000000000000..6790bc4a1c1d --- /dev/null +++ b/packages/schematics/angular/ai-config/index.ts @@ -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; + +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); +} diff --git a/packages/schematics/angular/ai-config/index_spec.ts b/packages/schematics/angular/ai-config/index_spec.ts new file mode 100644 index 000000000000..45518f7d17d6 --- /dev/null +++ b/packages/schematics/angular/ai-config/index_spec.ts @@ -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 { + return schematicRunner.runSchematic('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); + }); +}); diff --git a/packages/schematics/angular/ai-config/schema.json b/packages/schematics/angular/ai-config/schema.json new file mode 100644 index 000000000000..8f2d40f95d85 --- /dev/null +++ b/packages/schematics/angular/ai-config/schema.json @@ -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", + "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 + } + } + } +} diff --git a/packages/schematics/angular/collection.json b/packages/schematics/angular/collection.json index 5f691819544f..fcd6d330b166 100755 --- a/packages/schematics/angular/collection.json +++ b/packages/schematics/angular/collection.json @@ -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." } } } diff --git a/packages/schematics/angular/ng-new/index.ts b/packages/schematics/angular/ng-new/index.ts index e9362726d4d1..4ba4f3d48830 100644 --- a/packages/schematics/angular/ng-new/index.ts +++ b/packages/schematics/angular/ng-new/index.ts @@ -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), ]), ), diff --git a/packages/schematics/angular/ng-new/index_spec.ts b/packages/schematics/angular/ng-new/index_spec.ts index 413cc6841934..0b0334ba3432 100644 --- a/packages/schematics/angular/ng-new/index_spec.ts +++ b/packages/schematics/angular/ng-new/index_spec.ts @@ -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'); + }); }); diff --git a/packages/schematics/angular/ng-new/schema.json b/packages/schematics/angular/ng-new/schema.json index 370be58cde6a..8764f307ef01 100644 --- a/packages/schematics/angular/ng-new/schema.json +++ b/packages/schematics/angular/ng-new/schema.json @@ -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"]