diff --git a/packages/compiler-cli/src/ngtsc/docs/src/extractor.ts b/packages/compiler-cli/src/ngtsc/docs/src/extractor.ts index 23e5fa8bb9f2..b7543919bc04 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. @@ -125,6 +126,11 @@ export class DocsExtractor { ts.getLineAndCharacterOfPosition(realSourceFile, declarations[0].getEnd()).line + 1, }; + const aliases = getAliasesFromEntry(entry); + if (aliases.length > 0) { + entry.aliases = aliases; + } + // The exported name of an API may be different from its declaration name, so // use the declaration name. entries.push({...entry, name: exportName}); @@ -139,7 +145,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) { @@ -299,3 +305,53 @@ function isNamespaceEntry(e: DocEntry | null): e is NamespaceEntry { function isInterfaceEntry(e: DocEntry | null): e is InterfaceEntry { return e?.entryType === EntryType.Interface; } + +/** + * 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; +} + +/** + * Extracts alias names from selector (for directives/components). + */ +function getAliasesFromEntry(entry: DocEntry): string[] { + if (isDirectiveEntry(entry)) { + return extractAliasesFromSelector(entry.selector); + } + + return []; +} + +function isDirectiveEntry(entry: DocEntry): entry is DirectiveEntry { + return entry.entryType === EntryType.Directive || entry.entryType === EntryType.Component; +} 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 dc67d5a1f18e..37e39b9b9691 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 2e1518e8d299..d600e36887e4 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 45c6a60e82e2..5939fe162a6a 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(); + }); + }); }); });