diff --git a/packages/compiler-cli/src/ngtsc/docs/src/class_extractor.ts b/packages/compiler-cli/src/ngtsc/docs/src/class_extractor.ts index cbc871c1166..6b44343b10f 100644 --- a/packages/compiler-cli/src/ngtsc/docs/src/class_extractor.ts +++ b/packages/compiler-cli/src/ngtsc/docs/src/class_extractor.ts @@ -92,12 +92,16 @@ class DirectiveExtractor extends ClassExtractor { /** Extract docs info for directives and components (including underlying class info). */ override extract(): DirectiveEntry { + const selector = this.metadata.selector ?? ''; + const aliases = extractAliasesFromSelector(selector); + return { ...super.extract(), isStandalone: this.metadata.isStandalone, - selector: this.metadata.selector ?? '', + selector, exportAs: this.metadata.exportAs ?? [], entryType: this.metadata.isComponent ? EntryType.Component : EntryType.Directive, + ...(aliases.length > 0 && {aliases}), }; } @@ -222,3 +226,38 @@ function extractPipeSyntax(metadata: PipeMeta, classDeclaration: ts.ClassDeclara return `{{ value_expression | ${metadata.name}${paramNames.length ? ':' + paramNames.join(':') : ''} }}`; } + +/** + * Extracts aliases from a selector string. + * + * Parses selectors like: + * - `[ngTabs]` => `['ngTabs']` + * - `input[ngComboboxInput]` => `['ngComboboxInput']` + * - `ng-template[ngComboboxPopupContainer]` => `['ngComboboxPopupContainer']` + * - `[attr1][attr2]` => `['attr1', 'attr2']` + * - `.class-name` => `[]` (classes are not extracted) + * + * @param selector The CSS selector string from directive/component metadata + * @returns Array of attribute names that can be used as aliases + */ +function extractAliasesFromSelector(selector: string): string[] { + if (!selector) { + return []; + } + + const aliases: string[] = []; + // Match attribute selectors: [attributeName] or element[attributeName] + // This regex captures the attribute name inside square brackets + const attributeRegex = /\[([^\]=]+)(?:=[^\]]+)?\]/g; + + let match: RegExpExecArray | null; + while ((match = attributeRegex.exec(selector)) !== null) { + const attributeName = match[1].trim(); + // Skip empty attributes + if (attributeName) { + aliases.push(attributeName); + } + } + + return aliases; +} diff --git a/packages/compiler-cli/src/ngtsc/docs/src/extractor.ts b/packages/compiler-cli/src/ngtsc/docs/src/extractor.ts index 23e5fa8bb9f..d07d96217c9 100644 --- a/packages/compiler-cli/src/ngtsc/docs/src/extractor.ts +++ b/packages/compiler-cli/src/ngtsc/docs/src/extractor.ts @@ -23,6 +23,7 @@ import { DocEntryWithSourceInfo, EntryType, MemberType, + type DirectiveEntry, type InterfaceEntry, type MemberEntry, type NamespaceEntry, @@ -82,7 +83,7 @@ export class DocsExtractor { continue; } - const entry = this.extractDeclarations(exportName, declarations); + const entry = this.extractDeclarations(declarations); if (entry && !isIgnoredDocEntry(entry)) { // The source file parameter is the package entry: the index.ts // We want the real source file of the declaration. @@ -139,7 +140,7 @@ export class DocsExtractor { * the same name. This is used to combine entries, e.g. for a type and a namespace that are * exported under the same name. */ - private extractDeclarations(exportName: string, nodes: ts.Declaration[]): DocEntry | null { + private extractDeclarations(nodes: ts.Declaration[]): DocEntry | null { const entries = nodes.map((node) => this.extractDeclaration(node)); const decorator = entries.find((e) => e?.entryType === EntryType.Decorator); if (decorator) { diff --git a/packages/compiler-cli/src/ngtsc/docs/src/interface_extractor.ts b/packages/compiler-cli/src/ngtsc/docs/src/interface_extractor.ts index dc67d5a1f18..37e39b9b969 100644 --- a/packages/compiler-cli/src/ngtsc/docs/src/interface_extractor.ts +++ b/packages/compiler-cli/src/ngtsc/docs/src/interface_extractor.ts @@ -10,7 +10,7 @@ import ts from 'typescript'; import {ClassDeclaration} from '../../reflection'; -import {ClassEntry, EntryType, InterfaceEntry} from './entities'; +import {EntryType, InterfaceEntry} from './entities'; import {extractJsDocDescription, extractJsDocTags, extractRawJsDoc} from './jsdoc_extractor'; import {PropertiesExtractor} from './properties_extractor'; diff --git a/packages/compiler-cli/src/ngtsc/docs/src/properties_extractor.ts b/packages/compiler-cli/src/ngtsc/docs/src/properties_extractor.ts index 2e1518e8d29..d600e36887e 100644 --- a/packages/compiler-cli/src/ngtsc/docs/src/properties_extractor.ts +++ b/packages/compiler-cli/src/ngtsc/docs/src/properties_extractor.ts @@ -8,34 +8,14 @@ import ts from 'typescript'; -import {Reference} from '../../imports'; -import { - DirectiveMeta, - InputMapping, - InputOrOutput, - MetadataReader, - NgModuleMeta, - PipeMeta, -} from '../../metadata'; import {ClassDeclaration} from '../../reflection'; -import { - ClassEntry, - DirectiveEntry, - EntryType, - InterfaceEntry, - MemberEntry, - MemberTags, - MemberType, - MethodEntry, - PipeEntry, - PropertyEntry, -} from './entities'; +import {MemberEntry, MemberTags, MemberType, MethodEntry, PropertyEntry} from './entities'; import {isAngularPrivateName} from './filters'; import {FunctionExtractor} from './function_extractor'; import {extractGenerics} from './generics_extractor'; import {isInternal} from './internal'; -import {extractJsDocDescription, extractJsDocTags, extractRawJsDoc} from './jsdoc_extractor'; +import {extractJsDocDescription, extractJsDocTags} from './jsdoc_extractor'; import {extractResolvedTypeString} from './type_extractor'; // For the purpose of extraction, we can largely treat properties and accessors the same. diff --git a/packages/compiler-cli/test/ngtsc/doc_extraction/directive_doc_extraction_spec.ts b/packages/compiler-cli/test/ngtsc/doc_extraction/directive_doc_extraction_spec.ts index 45c6a60e82e..111c2a077e0 100644 --- a/packages/compiler-cli/test/ngtsc/doc_extraction/directive_doc_extraction_spec.ts +++ b/packages/compiler-cli/test/ngtsc/doc_extraction/directive_doc_extraction_spec.ts @@ -282,5 +282,161 @@ runInEachFileSystem(() => { expect(isAdminSetter.name).toBe('isAdmin'); expect(isAdminSetter.memberTags).toContain(MemberTags.Input); }); + + describe('selector alias extraction', () => { + it('should extract alias from simple attribute selector', () => { + env.write( + 'index.ts', + ` + import {Directive} from '@angular/core'; + @Directive({ + selector: '[ngTabs]', + }) + export class NgTabs { } + `, + ); + + const docs: DocEntry[] = env.driveDocsExtraction('index.ts'); + expect(docs.length).toBe(1); + + const directiveEntry = docs[0]; + expect(directiveEntry.aliases).toEqual(['ngTabs']); + }); + + it('should extract alias from element with attribute selector', () => { + env.write( + 'index.ts', + ` + import {Directive} from '@angular/core'; + @Directive({ + selector: 'input[ngComboboxInput]', + }) + export class NgComboboxInput { } + `, + ); + + const docs: DocEntry[] = env.driveDocsExtraction('index.ts'); + expect(docs.length).toBe(1); + + const directiveEntry = docs[0]; + expect(directiveEntry.aliases).toEqual(['ngComboboxInput']); + }); + + it('should extract alias from ng-template with attribute selector', () => { + env.write( + 'index.ts', + ` + import {Directive} from '@angular/core'; + @Directive({ + selector: 'ng-template[ngComboboxPopupContainer]', + }) + export class NgComboboxPopupContainer { } + `, + ); + + const docs: DocEntry[] = env.driveDocsExtraction('index.ts'); + expect(docs.length).toBe(1); + + const directiveEntry = docs[0]; + expect(directiveEntry.aliases).toEqual(['ngComboboxPopupContainer']); + }); + + it('should extract multiple aliases from multiple attribute selectors', () => { + env.write( + 'index.ts', + ` + import {Directive} from '@angular/core'; + @Directive({ + selector: '[attr1],[attr2]', + }) + export class MultiAttr { } + `, + ); + + const docs: DocEntry[] = env.driveDocsExtraction('index.ts'); + expect(docs.length).toBe(1); + + const directiveEntry = docs[0]; + expect(directiveEntry.aliases).toEqual(['attr1', 'attr2']); + }); + + it('should extract aliases from complex selector with element and multiple attributes', () => { + env.write( + 'index.ts', + ` + import {Directive} from '@angular/core'; + @Directive({ + selector: 'button[cdkButton]', + }) + export class CdkButton { } + `, + ); + + const docs: DocEntry[] = env.driveDocsExtraction('index.ts'); + expect(docs.length).toBe(1); + + const directiveEntry = docs[0]; + expect(directiveEntry.aliases).toEqual(['cdkButton']); + }); + + it('should not extract aliases from element-only selector', () => { + env.write( + 'index.ts', + ` + import {Component} from '@angular/core'; + @Component({ + selector: 'app-root', + template: '', + }) + export class AppRoot { } + `, + ); + + const docs: DocEntry[] = env.driveDocsExtraction('index.ts'); + expect(docs.length).toBe(1); + + const componentEntry = docs[0]; + expect(componentEntry.aliases).toBeUndefined(); + }); + + it('should not extract aliases from class selector', () => { + env.write( + 'index.ts', + ` + import {Component} from '@angular/core'; + @Component({ + selector: '.my-class', + template: '', + }) + export class MyClass { } + `, + ); + + const docs: DocEntry[] = env.driveDocsExtraction('index.ts'); + expect(docs.length).toBe(1); + + const componentEntry = docs[0]; + expect(componentEntry.aliases).toBeUndefined(); + }); + + it('should handle empty selector', () => { + env.write( + 'index.ts', + ` + import {Directive} from '@angular/core'; + @Directive({ + selector: '', + }) + export class EmptySelector { } + `, + ); + + const docs: DocEntry[] = env.driveDocsExtraction('index.ts'); + expect(docs.length).toBe(1); + + const directiveEntry = docs[0]; + expect(directiveEntry.aliases).toBeUndefined(); + }); + }); }); });