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/config/files/__rulesName__.template b/packages/schematics/angular/config/files/__rulesName__.template new file mode 100644 index 000000000000..c3df3ccf17e5 --- /dev/null +++ b/packages/schematics/angular/config/files/__rulesName__.template @@ -0,0 +1,54 @@ +<% 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 + +## Common pitfalls + +- Control flow (`@if`): + - You cannot use `as` expressions in `@else if (...)`. E.g. invalid code: `@else if (bla(); as x)`. diff --git a/packages/schematics/angular/config/index.ts b/packages/schematics/angular/config/index.ts index 5878bd8c498a..f5777aea0797 100644 --- a/packages/schematics/angular/config/index.ts +++ b/packages/schematics/angular/config/index.ts @@ -11,6 +11,7 @@ import { SchematicsException, apply, applyTemplates, + chain, filter, mergeWith, move, @@ -24,12 +25,40 @@ import { getWorkspace as readWorkspace, updateWorkspace } from '../utility/works import { Builders as AngularBuilder } from '../utility/workspace-models'; import { Schema as ConfigOptions, Type as ConfigType } from './schema'; +const geminiFile: ContextFileInfo = { rulesName: 'GEMINI.md', directory: '.gemini' }; +const copilotFile: ContextFileInfo = { + rulesName: 'copilot-instructions.md', + directory: '.github', +}; +const claudeFile: ContextFileInfo = { rulesName: 'CLAUDE.md', directory: '.claude' }; +const windsurfFile: ContextFileInfo = { + rulesName: 'guidelines.md', + directory: path.join('.windsurf', 'rules'), +}; + +// Cursor file is a bit different, it has a front matter section. +const cursorFile: ContextFileInfo = { + rulesName: 'cursor.mdc', + directory: path.join('.cursor', 'rules'), + frontmatter: `---\ncontext: true\npriority: high\nscope: project\n---`, +}; + +const AI_TOOLS = { + 'gemini': geminiFile, + 'claude': claudeFile, + 'copilot': copilotFile, + 'cursor': cursorFile, + 'windsurf': windsurfFile, +}; + export default function (options: ConfigOptions): Rule { switch (options.type) { case ConfigType.Karma: return addKarmaConfig(options); case ConfigType.Browserslist: return addBrowserslistConfig(options); + case ConfigType.Ai: + return addAiContextFile(options); default: throw new SchematicsException(`"${options.type}" is an unknown configuration file type.`); } @@ -103,3 +132,39 @@ function addKarmaConfig(options: ConfigOptions): Rule { ); }); } + +interface ContextFileInfo { + rulesName: string; + directory: string; + frontmatter?: string; +} + +function addAiContextFile(options: ConfigOptions): Rule { + const files: ContextFileInfo[] = + options.tool === 'all' ? Object.values(AI_TOOLS) : [AI_TOOLS[options.tool!]]; + + return async (host) => { + const workspace = await readWorkspace(host); + const project = workspace.projects.get(options.project); + if (!project) { + throw new SchematicsException(`Project name "${options.project}" doesn't not exist.`); + } + + const rules = files.map(({ rulesName, directory, frontmatter }) => + mergeWith( + apply(url('./files'), [ + // Keep only the single source template + filter((p) => p.endsWith('__rulesName__.template')), + applyTemplates({ + ...strings, + rulesName, + frontmatter: frontmatter ?? '', + }), + move(directory), + ]), + ), + ); + + return chain(rules); + }; +} diff --git a/packages/schematics/angular/config/index_spec.ts b/packages/schematics/angular/config/index_spec.ts index c9349bcb609d..68451cb3b762 100644 --- a/packages/schematics/angular/config/index_spec.ts +++ b/packages/schematics/angular/config/index_spec.ts @@ -9,7 +9,7 @@ import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing'; import { Schema as ApplicationOptions } from '../application/schema'; import { Schema as WorkspaceOptions } from '../workspace/schema'; -import { Schema as ConfigOptions, Type as ConfigType } from './schema'; +import { Schema as ConfigOptions, Tool as ConfigTool, Type as ConfigType } from './schema'; describe('Config Schematic', () => { const schematicRunner = new SchematicTestRunner( @@ -32,12 +32,15 @@ describe('Config Schematic', () => { }; let applicationTree: UnitTestTree; - function runConfigSchematic(type: ConfigType): Promise { + function runConfigSchematic(type: ConfigType, tool?: ConfigTool): Promise; + function runConfigSchematic(type: ConfigType.Ai, tool: ConfigTool): Promise; + function runConfigSchematic(type: ConfigType, tool?: ConfigTool): Promise { return schematicRunner.runSchematic( 'config', { project: 'foo', type, + tool, }, applicationTree, ); @@ -97,4 +100,38 @@ describe('Config Schematic', () => { expect(tree.readContent('projects/foo/.browserslistrc')).toContain('Chrome >='); }); }); + + describe(`when 'type' is 'ai'`, () => { + it('should create a GEMINI.MD file', async () => { + const tree = await runConfigSchematic(ConfigType.Ai, ConfigTool.Gemini); + expect(tree.readContent('.gemini/GEMINI.md')).toMatch(/^You are an expert in TypeScript/); + }); + + it('should create a copilot-instructions.md file', async () => { + const tree = await runConfigSchematic(ConfigType.Ai, ConfigTool.Copilot); + expect(tree.readContent('.github/copilot-instructions.md')).toContain( + 'You are an expert in TypeScript', + ); + }); + + it('should create a cursor file', async () => { + const tree = await runConfigSchematic(ConfigType.Ai, ConfigTool.Cursor); + const cursorFile = tree.readContent('.cursor/rules/cursor.mdc'); + expect(cursorFile).toContain('You are an expert in TypeScript'); + expect(cursorFile).toContain('context: true'); + expect(cursorFile).toContain('---\n\nYou are an expert in TypeScript'); + }); + + it('should create a windsurf file', async () => { + const tree = await runConfigSchematic(ConfigType.Ai, ConfigTool.Windsurf); + expect(tree.readContent('.windsurf/rules/guidelines.md')).toContain( + 'You are an expert in TypeScript', + ); + }); + + it('should create a claude file', async () => { + const tree = await runConfigSchematic(ConfigType.Ai, ConfigTool.Claude); + expect(tree.readContent('.claude/CLAUDE.md')).toContain('You are an expert in TypeScript'); + }); + }); }); diff --git a/packages/schematics/angular/config/schema.json b/packages/schematics/angular/config/schema.json index 14bb34f07260..5b8e667a7f6f 100644 --- a/packages/schematics/angular/config/schema.json +++ b/packages/schematics/angular/config/schema.json @@ -16,13 +16,36 @@ "type": { "type": "string", "description": "Specifies the type of configuration file to generate.", - "enum": ["karma", "browserslist"], + "enum": ["karma", "browserslist", "ai"], "x-prompt": "Which type of configuration file would you like to create?", "$default": { "$source": "argv", "index": 0 } + }, + "tool": { + "type": "string", + "description": "Specifies the AI tool to configure when type is 'ai'.", + "enum": ["gemini", "copilot", "claude", "cursor", "windsurf", "all"] } }, - "required": ["project", "type"] + "required": ["project", "type"], + "allOf": [ + { + "if": { + "properties": { + "type": { + "not": { + "const": "ai" + } + } + } + }, + "then": { + "not": { + "required": ["tool"] + } + } + } + ] } diff --git a/packages/schematics/angular/ng-new/index.ts b/packages/schematics/angular/ng-new/index.ts index e9362726d4d1..009c1031b4ea 100644 --- a/packages/schematics/angular/ng-new/index.ts +++ b/packages/schematics/angular/ng-new/index.ts @@ -24,6 +24,7 @@ import { } from '@angular-devkit/schematics/tasks'; import { Schema as ApplicationOptions } from '../application/schema'; import { Schema as WorkspaceOptions } from '../workspace/schema'; +import { Schema as ConfigOptions, Type as ConfigType, Tool as ConfigAiTool } from '../config/schema'; import { Schema as NgNewOptions } from './schema'; export default function (options: NgNewOptions): Rule { @@ -60,11 +61,20 @@ export default function (options: NgNewOptions): Rule { zoneless: options.zoneless, }; + const configOptions: ConfigOptions = { + project: options.name, + type: ConfigType.Ai, + tool: options.aiConfig as any, + }; + + console.log("ai config", options.aiConfig, configOptions); + return chain([ mergeWith( apply(empty(), [ schematic('workspace', workspaceOptions), options.createApplication ? schematic('application', applicationOptions) : noop, + options.aiConfig !== "none" ? schematic('config', configOptions) : noop, 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..f1afc1e28e61 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' }; + + const tree = await schematicRunner.runSchematic('ng-new', options); + const files = tree.files; + expect(files).toContain('/bar/.gemini/GEMINI.md'); + expect(tree.readContent('/bar/.gemini/GEMINI.md')).toMatch(/^You are an expert in TypeScript/); + }); }); diff --git a/packages/schematics/angular/ng-new/schema.json b/packages/schematics/angular/ng-new/schema.json index d6381afce198..8672ff8a2fff 100644 --- a/packages/schematics/angular/ng-new/schema.json +++ b/packages/schematics/angular/ng-new/schema.json @@ -144,6 +144,12 @@ "x-prompt": "Do you want to create a 'zoneless' application without zone.js (Developer Preview)?", "type": "boolean", "default": false + }, + "aiConfig": { + "description": "Create an AI configuration file for the project. This file is used to improve the outputs of AI tools by following the best practices.", + "default": "none", + "type": "string", + "enum": ["none", "gemini", "copilot", "claude", "cursor", "windsurf", "all"] } }, "required": ["name", "version"]