feat(docs-infra): Extracts aliases from doc entries

Implements a mechanism to extract alias names from a directive's selector.
This commit is contained in:
SkyZeroZx
2026-01-03 21:40:12 -05:00
committed by Kirill Cherkashin
parent b735c45974
commit ed7bfb3c88
5 changed files with 202 additions and 26 deletions
@@ -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;
}
@@ -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) {
@@ -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';
@@ -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.
@@ -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();
});
});
});
});