docs: improve docs generation for namespaces (#64747)

Improves docs epxerience for interfaces / type aliases that are merged
with a namespace.

PR Close #64747
This commit is contained in:
Miles Malerba 2025-11-02 22:24:58 -08:00 committed by Jessica Janiuk
parent 96388b6d8a
commit 59635bba34
18 changed files with 174 additions and 64 deletions

View file

@ -14,32 +14,32 @@ import {
ComponentRef,
createComponent,
DestroyRef,
effect,
ElementRef,
EnvironmentInjector,
inject,
Injector,
input,
output,
PendingTasks,
PLATFORM_ID,
Type,
ViewContainerRef,
ViewEncapsulation,
PendingTasks,
output,
input,
effect,
} from '@angular/core';
import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
import {TOC_SKIP_CONTENT_MARKER, NavigationState} from '../../../services';
import {TableOfContents} from '../../table-of-contents/table-of-contents.component';
import {IconComponent} from '../../icon/icon.component';
import {handleHrefClickEventWithRouter} from '../../../utils';
import {Snippet} from '../../../interfaces';
import {Router} from '@angular/router';
import {fromEvent} from 'rxjs';
import {Snippet} from '../../../interfaces';
import {NavigationState, TOC_SKIP_CONTENT_MARKER} from '../../../services';
import {handleHrefClickEventWithRouter} from '../../../utils';
import {IconComponent} from '../../icon/icon.component';
import {TableOfContents} from '../../table-of-contents/table-of-contents.component';
import {DomSanitizer} from '@angular/platform-browser';
import {Breadcrumb} from '../../breadcrumb/breadcrumb.component';
import {CopySourceCodeButton} from '../../copy-source-code-button/copy-source-code-button.component';
import {ExampleViewer} from '../example-viewer/example-viewer.component';
import {DomSanitizer} from '@angular/platform-browser';
const TOC_HOST_ELEMENT_NAME = 'docs-table-of-contents';
export const ASSETS_EXAMPLES_PATH = 'assets/content/examples';
@ -253,7 +253,9 @@ export class DocViewer {
// the code
private loadCopySourceCodeButtons(): void {
const staticCodeSnippets = <Element[]>(
Array.from(this.elementRef.nativeElement.querySelectorAll('.docs-code:not([mermaid])'))
Array.from(
this.elementRef.nativeElement.querySelectorAll('.docs-code:not([mermaid],.docs-no-copy)'),
)
);
for (let codeSnippet of staticCodeSnippets) {

View file

@ -35,6 +35,8 @@ export enum MemberType {
Getter = 'getter',
Setter = 'setter',
EnumItem = 'enum_item',
Interface = 'interface',
TypeAlias = 'type_alias',
}
export enum DecoratorType {
@ -96,7 +98,7 @@ export interface ConstantEntry extends DocEntry {
/** Documentation entity for a type alias. */
export interface TypeAliasEntry extends ConstantEntry {
generics: GenericEntry[];
members?: MemberEntry[]; // For merged namespaces
members: MemberEntry[]; // For merged namespaces
}
/** Documentation entity for a TypeScript class. */
@ -192,6 +194,12 @@ export interface PropertyEntry extends MemberEntry {
/** Sub-entry for a class method. */
export type MethodEntry = MemberEntry & FunctionEntry;
/** Sub-entry for an interface (included via namespace). */
export type InterfaceMemberEntry = MemberEntry & InterfaceEntry;
/** Sub-entry for a type alias (included via namespace). */
export type TypeAliasMemberEntry = MemberEntry & TypeAliasEntry;
/** Sub-entry for a single function parameter. */
export interface ParameterEntry {
name: string;

View file

@ -84,10 +84,13 @@ export function isEnumEntry(entry: DocEntry): entry is EnumEntry {
}
/** Gets whether the given entry represents an interface. */
export function isInterfaceEntry(
entry: MemberEntryRenderable,
): entry is InterfaceEntryRenderable & MemberEntryRenderable;
export function isInterfaceEntry(entry: DocEntryRenderable): entry is InterfaceEntryRenderable;
export function isInterfaceEntry(entry: DocEntry): entry is InterfaceEntry;
export function isInterfaceEntry(entry: DocEntry): entry is InterfaceEntry {
return entry.entryType === EntryType.Interface;
export function isInterfaceEntry(entry: DocEntry | MemberEntryRenderable): entry is InterfaceEntry {
return (entry as DocEntry).entryType === EntryType.Interface;
}
/** Gets whether the given member entry is a method entry. */

View file

@ -59,7 +59,7 @@ export type ConstantEntryRenderable = ConstantEntry &
export type TypeAliasEntryRenderable = TypeAliasEntry &
DocEntryRenderable &
HasRenderableToc & {
members?: MemberEntryRenderable[];
members: MemberEntryRenderable[];
};
/** Documentation entity for a TypeScript class augmented transformed content for rendering. */
@ -108,6 +108,7 @@ export interface MemberEntryRenderable extends MemberEntry {
jsdocTags: JsDocTagRenderable[];
deprecationMessage: string | null;
htmlUsageNotes: string;
displayName?: string;
}
/** Sub-entry for a class method augmented transformed content for rendering. */

View file

@ -7,9 +7,11 @@
*/
import {Fragment, h} from 'preact';
import {MemberType} from '../entities.mjs';
import {
isClassMethodEntry,
isGetterEntry,
isInterfaceEntry,
isPropertyEntry,
isSetterEntry,
} from '../entities/categorization.mjs';
@ -23,6 +25,7 @@ import {
import {getFunctionMetadataRenderable} from '../transforms/function-transforms.mjs';
import {ClassMethodInfo} from './class-method-info';
import {CodeSymbol} from './code-symbols';
import {CodeTableOfContents} from './code-table-of-contents';
import {DeprecatedLabel} from './deprecated-label';
import {RawHtml} from './raw-html';
@ -53,11 +56,12 @@ export function ClassMember(props: {member: MemberEntryRenderable}) {
);
const memberName = member.name;
const displayName = member.displayName;
const returnType = getMemberType(member);
return (
<div id={memberName} className={REFERENCE_MEMBER_CARD}>
<header className={REFERENCE_MEMBER_CARD_HEADER}>
<h3>{memberName}</h3>
<h3>{displayName ?? memberName}</h3>
{isClassMethodEntry(member) && member.signatures.length > 1 ? (
<span>{member.signatures.length} overloads</span>
) : returnType ? (
@ -67,6 +71,11 @@ export function ClassMember(props: {member: MemberEntryRenderable}) {
)}
</header>
{body}
{isInterfaceEntry(member) ? (
<CodeTableOfContents entry={member} hideCopyButton={true} embedded={true} />
) : (
<></>
)}
</div>
);
}
@ -76,6 +85,10 @@ function getMemberType(entry: MemberEntryRenderable): string | null {
return entry.implementation.returnType;
} else if (isPropertyEntry(entry) || isGetterEntry(entry) || isSetterEntry(entry)) {
return entry.type;
} else if (entry.memberType === MemberType.TypeAlias) {
return 'type';
} else if (entry.memberType === MemberType.Interface) {
return 'interface';
}
return null;
}

View file

@ -8,10 +8,14 @@
import {h} from 'preact';
import {renderToString} from 'preact-render-to-string';
import {CodeLineGroup} from './code-line-group';
import {HasRenderableToc} from '../entities/traits.mjs';
import {CodeLineGroup} from './code-line-group';
export function CodeTableOfContents(props: {entry: HasRenderableToc}) {
export function CodeTableOfContents(props: {
entry: HasRenderableToc;
hideCopyButton?: boolean;
embedded?: boolean;
}) {
let html: string;
// Prefer the formatted code if available
if (props.entry.formattedCode) {
@ -26,5 +30,14 @@ export function CodeTableOfContents(props: {entry: HasRenderableToc}) {
${props.entry.afterCodeGroups}`;
}
return <div class="docs-code" dangerouslySetInnerHTML={{__html: html}}></div>;
return (
<div
className={
(props.hideCopyButton ? 'docs-no-copy' : '') +
(props.embedded ? ' embedded' : '') +
' docs-code'
}
dangerouslySetInnerHTML={{__html: html}}
></div>
);
}

View file

@ -28,7 +28,7 @@ export async function getClassRenderable(
): Promise<ClassEntryRenderable> {
return setEntryFlags(
await addRenderableCodeToc(
addRenderableMembers(
await addRenderableMembers(
addHtmlAdditionalLinks(
addHtmlUsageNotes(
addHtmlJsDocTagComments(
@ -36,6 +36,7 @@ export async function getClassRenderable(
),
),
),
classEntry.name,
),
),
);

View file

@ -7,12 +7,12 @@
*/
import {
DecoratorEntry,
DocEntry,
EntryType,
FunctionSignatureMetadata,
GenericEntry,
MemberEntry,
DecoratorEntry,
MemberTags,
ParameterEntry,
PropertyEntry,
@ -34,16 +34,20 @@ import {
} from '../entities/categorization.mjs';
import {CodeLineRenderable} from '../entities/renderables.mjs';
import {HasModuleName, HasRenderableToc} from '../entities/traits.mjs';
import {getSymbolUrl} from '../symbol-context.mjs';
import {
getHighlighterInstance,
insertParenthesesForDecoratorInShikiHtml,
replaceKeywordFromShikiHtml,
} from '../shiki/shiki.mjs';
import {getSymbolUrl} from '../symbol-context.mjs';
import {filterLifecycleMethods, mergeGettersAndSetters} from './member-transforms.mjs';
import {formatJs} from './format-code.mjs';
import {codeToHtml} from '../../../shared/shiki.mjs';
import {formatJs} from './format-code.mjs';
import {
filterLifecycleMethods,
filterMergedNamespaceMembers,
mergeGettersAndSetters,
} from './member-transforms.mjs';
const INDENT = ' ';
const SPACE = ' ';
@ -198,7 +202,10 @@ function mapDocEntryToCode(entry: DocEntry): CodeTableOfContentsData {
}
if (isInterfaceEntry(entry)) {
return getCodeTocData(mergeGettersAndSetters(entry.members), isDeprecated);
return getCodeTocData(
filterMergedNamespaceMembers(mergeGettersAndSetters(entry.members)),
isDeprecated,
);
}
if (isFunctionEntry(entry)) {

View file

@ -16,7 +16,6 @@ import {
addHtmlUsageNotes,
setEntryFlags,
} from './jsdoc-transforms.mjs';
import {addRenderableMembers} from './member-transforms.mjs';
import {addModuleName} from './module-name.mjs';
import {addRepo} from './repo.mjs';

View file

@ -28,7 +28,7 @@ export async function getEnumRenderable(
): Promise<EnumEntryRenderable> {
return setEntryFlags(
await addRenderableCodeToc(
addRenderableMembers(
await addRenderableMembers(
addHtmlAdditionalLinks(
addHtmlUsageNotes(
addHtmlJsDocTagComments(
@ -36,6 +36,7 @@ export async function getEnumRenderable(
),
),
),
classEntry.name,
),
),
);

View file

@ -28,7 +28,7 @@ export async function getInterfaceRenderable(
): Promise<InterfaceEntryRenderable> {
return setEntryFlags(
await addRenderableCodeToc(
addRenderableMembers(
await addRenderableMembers(
addHtmlAdditionalLinks(
addHtmlUsageNotes(
addHtmlJsDocTagComments(
@ -36,6 +36,7 @@ export async function getInterfaceRenderable(
),
),
),
entry.name,
),
),
);

View file

@ -15,24 +15,24 @@ import {
HasDeprecatedFlag,
HasDescription,
HasDeveloperPreviewFlag,
hasExperimentalFlag,
HasHtmlDescription,
HasHtmlUsageNotes,
HasJsDocTags,
HasModuleName,
HasRenderableJsDocTags,
hasExperimentalFlag,
HasStableFlag,
} from '../entities/traits.mjs';
import {addApiLinksToHtml} from './code-transforms.mjs';
import {parseMarkdown} from '../../../shared/marked/parse.mjs';
import {getHighlighterInstance} from '../shiki/shiki.mjs';
import {
getCurrentSymbol,
getSymbols,
getSymbolUrl,
unknownSymbolMessage,
} from '../symbol-context.mjs';
import {parseMarkdown} from '../../../shared/marked/parse.mjs';
import {getHighlighterInstance} from '../shiki/shiki.mjs';
import {addApiLinksToHtml} from './code-transforms.mjs';
const JS_DOC_USAGE_NOTE_TAGS: Set<string> = new Set(['remarks', 'usageNotes', 'example']);
export const JS_DOC_SEE_TAG = 'see';

View file

@ -6,10 +6,12 @@
* found in the LICENSE file at https://angular.dev/license
*/
import {MemberEntry, MemberTags, MemberType} from '../entities.mjs';
import {MemberEntry, MemberTags, MemberType, type DocEntry} from '../entities.mjs';
import {isHiddenEntry} from '../entities/categorization.mjs';
import type {MemberEntryRenderable} from '../entities/renderables.mjs';
import {HasMembers, HasModuleName, HasRenderableMembers, HasRepo} from '../entities/traits.mjs';
import {addRenderableCodeToc} from './code-transforms.mjs';
import {
addHtmlDescription,
@ -39,6 +41,13 @@ export function filterLifecycleMethods(members: MemberEntry[]): MemberEntry[] {
return members.filter((m) => !lifecycleMethods.includes(m.name));
}
/** Filters out members that come from merged namespace declarations. */
export function filterMergedNamespaceMembers(members: MemberEntry[]): MemberEntry[] {
return members.filter(
(m) => m.memberType !== MemberType.Interface && m.memberType !== MemberType.TypeAlias,
);
}
/** Merges getter and setter entries with the same name into a single entry. */
export function mergeGettersAndSetters(members: MemberEntry[]): MemberEntry[] {
const getters = new Set<string>();
@ -64,20 +73,39 @@ export function mergeGettersAndSetters(members: MemberEntry[]): MemberEntry[] {
);
}
export function addRenderableMembers<T extends HasMembers & HasModuleName & HasRepo>(
function addDisplayName<T extends MemberEntryRenderable>(entry: T, parentName: string): T {
return entry.memberType === MemberType.Interface || entry.memberType === MemberType.TypeAlias
? {...entry, displayName: `${parentName}.${entry.name}`}
: entry;
}
export async function addRenderableMembers<T extends HasMembers & HasModuleName & HasRepo>(
entry: T,
): T & HasRenderableMembers {
const members = entry.members
.filter((member) => !isHiddenEntry(member))
.map((member) =>
parentName: string,
): Promise<T & HasRenderableMembers> {
const members = (
await Promise.all(
// TODO: remove `?? []` when components repo is updated to include members array on type aliases.
(entry.members ?? [])
.filter((member) => !isHiddenEntry(member))
.map((member) => {
if (member.memberType === MemberType.Interface) {
return addRenderableCodeToc(
addModuleName(member as DocEntry & MemberEntry, entry.moduleName),
);
} else {
return addModuleName(member, entry.moduleName);
}
}),
)
).map((member) =>
addDisplayName(
setEntryFlags(
addHtmlDescription(
addHtmlUsageNotes(
addHtmlJsDocTagComments(addRepo(addModuleName(member, entry.moduleName), entry.repo)),
),
),
addHtmlDescription(addHtmlUsageNotes(addHtmlJsDocTagComments(addRepo(member, entry.repo)))),
),
);
parentName,
),
);
return {
...entry,

View file

@ -8,7 +8,6 @@
import {TypeAliasEntry} from '../entities.mjs';
import {TypeAliasEntryRenderable} from '../entities/renderables.mjs';
import type {HasMembers} from '../entities/traits.mjs';
import {addRenderableCodeToc} from './code-transforms.mjs';
import {
addHtmlAdditionalLinks,
@ -27,22 +26,20 @@ export async function getTypeAliasRenderable(
moduleName: string,
repo: string,
): Promise<TypeAliasEntryRenderable> {
const entryWithModuleAndRepo = addRepo(addModuleName(typeAliasEntry, moduleName), repo);
const renderableMembers = hasMembers(entryWithModuleAndRepo)
? (await addRenderableMembers(entryWithModuleAndRepo)).members
: undefined;
return setEntryFlags(
await addRenderableCodeToc({
...addHtmlAdditionalLinks(
addHtmlUsageNotes(addHtmlJsDocTagComments(addHtmlDescription(entryWithModuleAndRepo))),
await addRenderableCodeToc(
addHtmlAdditionalLinks(
addHtmlUsageNotes(
addHtmlJsDocTagComments(
addHtmlDescription(
await addRenderableMembers(
addRepo(addModuleName(typeAliasEntry, moduleName), repo),
typeAliasEntry.name,
),
),
),
),
),
members: renderableMembers,
}),
),
);
}
function hasMembers(typeAlias: TypeAliasEntry): typeAlias is TypeAliasEntry & HasMembers {
return typeAlias.members !== undefined;
}

View file

@ -161,6 +161,11 @@ $code-font-size: 0.875rem;
max-height: 300px;
}
}
&.embedded {
border-width: 0;
border-radius: 0;
}
}
.shiki {
@ -334,7 +339,7 @@ $code-font-size: 0.875rem;
text-align: right;
padding-block: 0.25rem;
}
.line {
&.highlighted {
background-color: color-mix(in srgb, var(--bright-blue) 9%, var(--page-background));

View file

@ -47,6 +47,8 @@ export enum MemberType {
Getter = 'getter',
Setter = 'setter',
EnumItem = 'enum_item',
Interface = 'interface',
TypeAlias = 'type_alias',
}
export enum DecoratorType {
@ -107,7 +109,7 @@ export interface ConstantEntry extends DocEntry {
/** Documentation entity for a type alias. */
export interface TypeAliasEntry extends ConstantEntry {
generics: GenericEntry[];
members?: DocEntry[]; // For merged namespaces
members: MemberEntry[]; // For merged namespaces
}
/** Documentation entity for a TypeScript class. */
@ -194,6 +196,12 @@ export interface PropertyEntry extends MemberEntry {
/** Sub-entry for a class method. */
export type MethodEntry = MemberEntry & FunctionEntry;
/** Sub-entry for an interface (included via namespace). */
export type InterfaceMemberEntry = MemberEntry & InterfaceEntry;
/** Sub-entry for a type alias (included via namespace). */
export type TypeAliasMemberEntry = MemberEntry & TypeAliasEntry;
/** Sub-entry for a single function parameter. */
export interface ParameterEntry {
name: string;

View file

@ -22,6 +22,9 @@ import {
DocEntry,
DocEntryWithSourceInfo,
EntryType,
MemberType,
type InterfaceEntry,
type MemberEntry,
type NamespaceEntry,
type TypeAliasEntry,
} from './entities';
@ -150,16 +153,31 @@ export class DocsExtractor {
// For now, we only support merging a type alias and a namespace.
const typeAlias = entries.find(isTypeAliasEntry);
const namespace = entries.find(isNamespaceEntry);
const interfaceEntry = entries.find(isInterfaceEntry);
if (typeAlias && namespace) {
typeAlias.members = namespace.members;
typeAlias.members.push(...this.getTypeMembersFromNamespace(namespace));
return typeAlias;
}
if (interfaceEntry && namespace) {
interfaceEntry.members.push(...this.getTypeMembersFromNamespace(namespace));
}
}
return entry ?? null;
}
private getTypeMembersFromNamespace(namespace: NamespaceEntry): MemberEntry[] {
return namespace.members
.filter((e) => isInterfaceEntry(e) || isTypeAliasEntry(e))
.map((e) => ({
...e,
memberType:
e.entryType === EntryType.Interface ? MemberType.Interface : MemberType.TypeAlias,
memberTags: [],
}));
}
/** Extract the doc entry for a single declaration. */
private extractDeclaration(node: ts.Declaration): DocEntry | null {
// Ignore anonymous classes.
@ -277,3 +295,7 @@ function isTypeAliasEntry(e: DocEntry | null): e is TypeAliasEntry {
function isNamespaceEntry(e: DocEntry | null): e is NamespaceEntry {
return e?.entryType === EntryType.Namespace;
}
function isInterfaceEntry(e: DocEntry | null): e is InterfaceEntry {
return e?.entryType === EntryType.Interface;
}

View file

@ -8,8 +8,8 @@
import ts from 'typescript';
import {EntryType} from './entities';
import {extractJsDocDescription, extractJsDocTags, extractRawJsDoc} from './jsdoc_extractor';
import {extractGenerics} from './generics_extractor';
import {extractJsDocDescription, extractJsDocTags, extractRawJsDoc} from './jsdoc_extractor';
/** Extract the documentation entry for a type alias. */
export function extractTypeAlias(declaration: ts.TypeAliasDeclaration) {
@ -25,5 +25,6 @@ export function extractTypeAlias(declaration: ts.TypeAliasDeclaration) {
rawComment: extractRawJsDoc(declaration),
description: extractJsDocDescription(declaration),
jsdocTags: extractJsDocTags(declaration),
members: [],
};
}